diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000000..8cf0d87c5c7 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "extraKnownMarketplaces": { + "bitwarden-marketplace": { + "source": { + "source": "github", + "repo": "bitwarden/ai-plugins" + } + } + } +} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c04c9cdeea1..7b8330a27f3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,17 +4,25 @@ # # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +## Global styles are owned by UIF +*.scss @bitwarden/team-ui-foundation +*.css @bitwarden/team-ui-foundation + ## Desktop native module ## 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 +30,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 +90,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,13 +160,16 @@ 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/core/src/ssh_agent @bitwarden/team-autofill-desktop-dev @bitwarden/wg-ssh-keys +apps/desktop/desktop_native/ssh_agent @bitwarden/team-autofill-desktop-dev @bitwarden/wg-ssh-keys +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 apps/desktop/src/services/encrypted-message-handler.service.ts @bitwarden/team-autofill-desktop-dev .github/workflows/alert-ddg-files-modified.yml @bitwarden/team-autofill-desktop-dev -# SSH Agent -apps/desktop/desktop_native/core/src/ssh_agent @bitwarden/team-autofill-desktop-dev @bitwarden/wg-ssh-keys ## UI Foundation ## .github/workflows/chromatic.yml @bitwarden/team-ui-foundation @@ -220,6 +230,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 +240,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/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index edbc9d98cc9..224020991d1 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,27 +9,3 @@ ## 📸 Screenshots - -## ⏰ Reminders before review - -- Contributor guidelines followed -- All formatters and local linters executed and passed -- Written new unit and / or integration tests where applicable -- Protected functional changes with optionality (feature flags) -- Used internationalization (i18n) for all UI strings -- CI builds passed -- Communicated to DevOps any deployment requirements -- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team - -## 🦮 Reviewer guidelines - - - -- 👍 (`:+1:`) or similar for great changes -- 📝 (`:memo:`) or ℹ️ (`:information_source:`) for notes or general info -- ❓ (`:question:`) for questions -- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion -- 🎨 (`:art:`) for suggestions / improvements -- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention -- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt -- ⛏ (`:pick:`) for minor or nitpick changes diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 1b6522c94dd..b264514e736 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -79,7 +79,6 @@ matchPackageNames: [ "@emotion/css", "@webcomponents/custom-elements", - "bitwarden-russh", "concurrently", "cross-env", "del", @@ -187,6 +186,7 @@ "semver", "serde", "serde_json", + "serde_with", "simplelog", "style-loader", "sysinfo", @@ -312,7 +312,6 @@ "@types/inquirer", "@types/koa", "@types/koa__multer", - "@types/koa__router", "@types/koa-bodyparser", "@types/koa-json", "@types/lunr", @@ -562,5 +561,6 @@ "node-ipc", "@bitwarden/sdk-internal", "@bitwarden/commercial-sdk-internal", + "bitwarden-russh", ], } 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..6a334e31a18 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@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.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 6b652149d8d..f3b76ae462d 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -57,7 +57,7 @@ jobs: - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Verify @@ -90,7 +90,7 @@ jobs: - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: true - name: Get Package Version @@ -176,14 +176,14 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Free disk space 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: | @@ -335,11 +335,11 @@ jobs: - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Set up Node - 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: | @@ -483,11 +483,11 @@ jobs: - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Set up Node - 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: | @@ -996,18 +996,18 @@ jobs: - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Set up Node - 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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.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: | @@ -1236,18 +1236,18 @@ jobs: - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Set up Node - 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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.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: | @@ -1511,18 +1511,18 @@ jobs: - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Set up Node - 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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.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: | @@ -1852,7 +1852,7 @@ jobs: - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Log in to Azure @@ -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@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} @@ -1894,15 +1894,16 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Download deb artifact uses: bitwarden/gh-actions/download-artifacts@main with: + run_id: ${{ github.run_id }} path: apps/desktop/artifacts/linux/deb artifacts: Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb @@ -1937,15 +1938,16 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Download appimage artifact uses: bitwarden/gh-actions/download-artifacts@main with: + run_id: ${{ github.run_id }} path: apps/desktop/artifacts/linux/appimage artifacts: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage @@ -1978,15 +1980,16 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Download appimage artifact uses: bitwarden/gh-actions/download-artifacts@main with: + run_id: ${{ github.run_id }} path: apps/desktop/artifacts/linux/appimage artifacts: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage @@ -2033,15 +2036,16 @@ jobs: - linux-arm64 steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Download flatpak artifact uses: bitwarden/gh-actions/download-artifacts@main with: + run_id: ${{ github.run_id }} path: apps/desktop/artifacts/linux/flatpak/ artifacts: com.bitwarden.${{ matrix.os == 'ubuntu-22.04' && 'desktop' || 'desktop-arm64' }}.flatpak @@ -2050,7 +2054,6 @@ jobs: sudo apt-get update sudo apt-get install -y libasound2 flatpak xvfb dbus-x11 flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo - flatpak install -y --user flathub - name: Install flatpak working-directory: apps/desktop/artifacts/linux/flatpak @@ -2086,15 +2089,16 @@ jobs: _CPU_ARCH: ${{ matrix.os == 'ubuntu-22.04' && 'amd64' || 'arm64' }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Download snap artifact uses: bitwarden/gh-actions/download-artifacts@main with: + run_id: ${{ github.run_id }} path: apps/desktop/artifacts/linux/snap artifacts: bitwarden_${{ env._PACKAGE_VERSION }}_${{ env._CPU_ARCH }}.snap @@ -2130,15 +2134,16 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Download dmg artifact uses: bitwarden/gh-actions/download-artifacts@main with: + run_id: ${{ github.run_id }} path: apps/desktop/artifacts/macos/dmg artifacts: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg @@ -2174,15 +2179,16 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Download portable artifact uses: bitwarden/gh-actions/download-artifacts@main with: + run_id: ${{ github.run_id }} path: apps/desktop/artifacts/windows/portable artifacts: Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 24a8df084a2..688bd30bfe5 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: @@ -112,7 +117,7 @@ jobs: npm_command: dist:bit:selfhost - artifact_name: selfhosted-DEV license_type: "commercial" - image_name: web + image_name: web-dev npm_command: build:bit:selfhost:dev git_metadata: true - artifact_name: cloud-QA @@ -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@0d444ed77d83ee2ba7f5ced0d90d640a1281d762 # v7.3.0 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@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.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..e1e620c864d 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@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.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 ea6894dab8e..efc8c25fc5e 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' @@ -115,7 +115,7 @@ jobs: run: rustup --version - name: Cache cargo registry - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 - name: Run cargo fmt working-directory: ./apps/desktop/desktop_native @@ -128,7 +128,7 @@ jobs: RUSTFLAGS: "-D warnings" - name: Install cargo-sort - run: cargo install cargo-sort --locked --git https://github.com/DevinR528/cargo-sort.git --rev f5047967021cbb1f822faddc355b3b07674305a1 + run: cargo install cargo-sort --locked --git https://github.com/DevinR528/cargo-sort.git --rev ac6e328faf467a39e38ab48dc60dcf4f6a46d7a5 # v2.0.2 - name: Cargo sort working-directory: ./apps/desktop/desktop_native @@ -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@887bc4e03483810873d617344dd5189cd82e7b8b # v2.67.11 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..45665f459e8 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@90be1154f987f4dc0fe0dd0feedac9e473aa4ba8 # v1.286.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/release-cli.yml b/.github/workflows/release-cli.yml index 3f7b7e326d9..5d37c00c2d9 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -91,7 +91,9 @@ jobs: apps/cli/bw-macos-${{ env.PKG_VERSION }}.zip, apps/cli/bw-macos-arm64-${{ env.PKG_VERSION }}.zip, apps/cli/bw-oss-linux-${{ env.PKG_VERSION }}.zip, + apps/cli/bw-oss-linux-arm64-${{ env.PKG_VERSION }}.zip, apps/cli/bw-linux-${{ env.PKG_VERSION }}.zip, + apps/cli/bw-linux-arm64-${{ env.PKG_VERSION }}.zip, apps/cli/bitwarden-cli.${{ env.PKG_VERSION }}.nupkg, apps/cli/bw_${{ env.PKG_VERSION }}_amd64.snap, apps/cli/bitwarden-cli-${{ env.PKG_VERSION }}-npm-build.zip" diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 79f3335313e..33b4df24d7a 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -72,7 +72,6 @@ jobs: permissions: id-token: write contents: write - pull-requests: write steps: - name: Validate version input format @@ -106,13 +105,12 @@ 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 }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - permission-contents: write # for creating, committing to, and pushing new branches - permission-pull-requests: write # for generating pull requests + permission-contents: write # for committing and pushing to main (bypasses rulesets) - name: Check out branch uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 @@ -448,53 +446,15 @@ jobs: echo "No changes to commit!"; fi - - name: Create version bump branch - if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} - run: | - BRANCH_NAME="version-bump-$(date +%s)" - git checkout -b "$BRANCH_NAME" - echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV - - name: Commit version bumps with GPG signature if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} run: | git commit -m "Bumped client version(s)" -a - - name: Push version bump branch + - name: Push changes to main if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} run: | - git push --set-upstream origin "$BRANCH_NAME" - - - name: Create Pull Request for version bump - if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - env: - VERSION_BROWSER: ${{ steps.set-final-version-output.outputs.version_browser }} - VERSION_CLI: ${{ steps.set-final-version-output.outputs.version_cli }} - VERSION_DESKTOP: ${{ steps.set-final-version-output.outputs.version_desktop }} - VERSION_WEB: ${{ steps.set-final-version-output.outputs.version_web }} - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const versions = []; - if (process.env.VERSION_BROWSER) versions.push(`- Browser: ${process.env.VERSION_BROWSER}`); - if (process.env.VERSION_CLI) versions.push(`- CLI: ${process.env.VERSION_CLI}`); - if (process.env.VERSION_DESKTOP) versions.push(`- Desktop: ${process.env.VERSION_DESKTOP}`); - if (process.env.VERSION_WEB) versions.push(`- Web: ${process.env.VERSION_WEB}`); - - const body = versions.length > 0 - ? `Automated version bump:\n\n${versions.join('\n')}` - : 'Automated version bump'; - - const { data: pr } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: 'Bumped client version(s)', - body: body, - head: process.env.BRANCH_NAME, - base: context.ref.replace('refs/heads/', '') - }); - console.log(`Created PR #${pr.number}: ${pr.html_url}`); + git push cut_branch: name: Cut branch @@ -525,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 cf7251b259a..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,33 +115,32 @@ 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 - name: Upload results to codecov.io - uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + with: + report_type: test_results - 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: @@ -111,7 +175,7 @@ jobs: working-directory: ./apps/desktop/desktop_native run: cargo build - - name: Test Ubuntu + - name: Linux unit tests if: ${{ matrix.os=='ubuntu-22.04' }} working-directory: ./apps/desktop/desktop_native run: | @@ -120,17 +184,21 @@ jobs: mkdir -p ~/.local/share/keyrings eval "$(printf '\n' | gnome-keyring-daemon --unlock)" eval "$(printf '\n' | /usr/bin/gnome-keyring-daemon --start)" - cargo test -- --test-threads=1 + cargo test --lib -- --test-threads=1 - - name: Test macOS + - name: MacOS unit tests if: ${{ matrix.os=='macos-14' }} working-directory: ./apps/desktop/desktop_native - run: cargo test -- --test-threads=1 + run: cargo test --lib -- --test-threads=1 - - name: Test Windows + - name: Windows unit tests if: ${{ matrix.os=='windows-2022'}} working-directory: ./apps/desktop/desktop_native - run: cargo test --workspace --exclude=desktop_napi -- --test-threads=1 + run: cargo test --lib --workspace --exclude=desktop_napi -- --test-threads=1 + + - name: Doc tests + working-directory: ./apps/desktop/desktop_native + run: cargo test --doc rust-coverage: name: Rust Coverage @@ -177,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 @@ -193,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 debe3f82c1b..78cf90c3555 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": "استخدام تسجيل الدخول الأحادي" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "لا" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "الموقع" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "خيارات تسجيل الدخول بخطوتين المملوكة لجهات اخرى مثل YubiKey و Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "نظافة كلمة المرور، صحة الحساب، وتقارير تسريبات البيانات للحفاظ على سلامة خزانتك." }, @@ -2027,6 +2054,9 @@ "email": { "message": "البريد الإلكتروني" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "الهاتف" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "تم حذف العنصر بشكل دائم" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "استعادة العنصر" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden لن يطلب حفظ تفاصيل تسجيل الدخول لهذه النطاقات. يجب عليك تحديث الصفحة حتى تصبح التغييرات سارية المفعول." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden لن يطلب حفظ تفاصيل تسجيل الدخول لهذه النطافات لجميع الحسابات مسجلة الدخول. يجب عليك تحديث الصفحة لكي تصبح التغييرات نافذة المفعول." - }, "blockedDomainsDesc": { "message": "لن يتم توفير الملء التلقائي والمميزات الأخرى ذات الصلة لهذه المواقع. يجب عليك تحديث الصفحة لكي تصبح التغييرات نافذة المفعول." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "مُخصّص" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "إرسال محفوظ", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "تمديد مستخرج؟", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "لإنشاء إرسال ملف، تحتاج إلى تثبيت الملحق إلى نافذة جديدة.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "من أجل اختيار ملف، قم بفتح الملحق في الشريط الجانبي (إن أمكن) أو الإنتقال إلى نافذة جديدة بالنقر على هذا الشعار." - }, - "sendFirefoxFileWarning": { - "message": "من أجل اختيار ملف باستخدام فايرفوكس، افتح الملحق في الشريط الجانبي أو اخرج إلى نافذة جديدة من خلال النقر على هذا الشعار." - }, - "sendSafariFileWarning": { - "message": "من أجل اختيار ملف باستخدام سافاري، انتقل إلى نافذة جديدة بالنقر على هذا الشعار." - }, "popOut": { "message": "انبثق" }, - "sendFileCalloutHeader": { - "message": "قبل أن تبدأ" - }, "expirationDateIsInvalid": { "message": "صلاحية تاريخ الانتهاء المقدّم غير صحيح." }, @@ -3346,6 +3352,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": "خطأ فك التشفير" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 547ebee9500..6a43475da32 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" }, @@ -573,20 +576,29 @@ "itemWasSentToArchive": { "message": "Element arxivə göndərildi" }, + "itemWasUnarchived": { + "message": "Element arxivdən çıxarıldı" + }, "itemUnarchived": { "message": "Element arxivdən çıxarıldı" }, "archiveItem": { "message": "Elementi arxivlə" }, - "archiveItemConfirmDesc": { - "message": "Arxivlənmiş elementlər ümumi axtarış nəticələrindən və avto-doldurma təkliflərindən xaric ediləcək. Bu elementi arxivləmək istədiyinizə əminsiniz?" + "archiveItemDialogContent": { + "message": "Arxivləndikdən sonra, bu element axtarış nəticələrindən və avto-doldurma təkliflərindən xaric ediləcək." + }, + "archived": { + "message": "Arxivləndi" + }, + "unarchiveAndSave": { + "message": "Arxivdən çıxart və saxla" }, "upgradeToUseArchive": { "message": "Arxivi istifadə etmək üçün premium üzvlük tələb olunur." }, "itemRestored": { - "message": "Item has been restored" + "message": "Element bərpa edildi" }, "edit": { "message": "Düzəliş et" @@ -978,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ə" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "YubiKey və Duo kimi mülkiyyətçi iki addımlı giriş seçimləri." }, + "premiumSubscriptionEnded": { + "message": "Premium abunəliyiniz bitdi" + }, + "archivePremiumRestart": { + "message": "Arxivinizə təkrar erişmək üçün Premium abunəliyinizi yenidən başladın. Təkrar başlatmazdan əvvəl arxivlənmiş elementin detallarına düzəliş etsəniz, həmin element seyfinizə daşınacaq." + }, + "restartPremium": { + "message": "\"Premium\"u yenidən başlat" + }, "ppremiumSignUpReports": { "message": "Seyfinizi güvəndə saxlamaq üçün parol gigiyenası, hesab sağlamlığı və veri pozuntusu hesabatları." }, @@ -2027,6 +2054,9 @@ "email": { "message": "E-poçt" }, + "emails": { + "message": "E-poçtlar" + }, "phone": { "message": "Telefon" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Element birdəfəlik silindi" }, + "archivedItemRestored": { + "message": "Arxivlənmiş element bərpa edildi" + }, "restoreItem": { "message": "Elementi bərpa et" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden, bu domenlər üçün giriş detallarını saxlamağı soruşmayacaq. Dəyişikliklərin qüvvəyə minməsi üçün səhifəni təzələməlisiniz." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden, giriş etmiş bütün hesablar üçün bu domenlərin giriş detallarını saxlamağı soruşmayacaq. Dəyişikliklərin qüvvəyə minməsi üçün səhifəni təzələməlisiniz." - }, "blockedDomainsDesc": { "message": "Bu veb saytlar üçün avto-doldurma və digər əlaqəli özəlliklər təklif olunmayacaq. Dəyişikliklərin qüvvəyə minməsi üçün səhifəni təzələməlisiniz." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Özəl" }, - "sendPasswordDescV3": { - "message": "Alıcıların bu \"Send\"ə erişməsi üçün ixtiyari bir parol əlavə edin.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Yeni \"Send\"", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "\"Send\" saxlanıldı", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Uzantı yeni pəncərədə açılsın?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Bir fayl \"Send\"i yaratmaq üçün uzantını yeni bir pəncərədə açmalısınız.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Bir fayl seçmək üçün (mümkünsə) yan çubuqdakı uzantını açın və ya bu bannerə klikləyərək yeni bir pəncərədə açın." - }, - "sendFirefoxFileWarning": { - "message": "Firefox istifadə edərək bir fayl seçmək üçün yan çubuqdakı uzantını açın və ya bu bannerə klikləyərək yeni bir pəncərədə açın." - }, - "sendSafariFileWarning": { - "message": "Safari istifadə edərək bir fayl seçmək üçün bu bannerə klikləyərək yeni bir pəncərədə açın." - }, "popOut": { "message": "Pəncərədə aç" }, - "sendFileCalloutHeader": { - "message": "Başlamazdan əvvəl" - }, "expirationDateIsInvalid": { "message": "Göstərilən son istifadə tarixi yararsızdır." }, @@ -3346,6 +3352,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ı" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Daha çox seçim" + }, "moreOptionsTitle": { "message": "Daha çox seçim - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Konsolu" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Avtomatik istifadəçi təsdiqi" + }, + "automaticUserConfirmationHint": { + "message": "Bu cihazın kilidi açıq olduqda gözləyən istifadəçiləri avtomatik təsdiqlə" + }, + "autoConfirmOnboardingCallout": { + "message": "Avtomatik istifadəçi təsdiqi ilə vaxta qənaət edin" + }, + "autoConfirmWarning": { + "message": "Bu, təşkilatınızın veri təhlükəsizliyinə təsir edə bilər. " + }, + "autoConfirmWarningLink": { + "message": "Risklər barədə öyrən" + }, + "autoConfirmSetup": { + "message": "Yeni istifadəçiləri avtomatik təsdiqlə" + }, + "autoConfirmSetupDesc": { + "message": "Bu cihazın kilidi açıq olduqda yeni istifadəçilər avtomatik təsdiqlənəcək." + }, + "autoConfirmSetupHint": { + "message": "Potensial təhlükəsizlik riskləri nələrdir?" + }, + "autoConfirmEnabled": { + "message": "Avtomatik təsdiq işə salındı" + }, + "availableNow": { + "message": "İndi mmövcuddur" + }, "accountSecurity": { "message": "Hesab güvənliyi" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Qoşmanı endir" + }, "downloadBitwarden": { "message": "Bitwarden-i endir" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "$WEBSITE$ ilə uyuşma aşkarlamasını gizlət", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Uyuşma aşkarlamasını göstər" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Uyuşma aşkarlamasını gizlət" }, "autoFillOnPageLoad": { "message": "Səhifə yüklənəndə avto-doldurulsun?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Ekstra enli" }, + "narrow": { + "message": "Dar" + }, "sshKeyWrongPassword": { "message": "Daxil etdiyiniz parol yanlışdır." }, @@ -5668,10 +5719,10 @@ "message": "Bu giriş risk altındadır və bir veb sayt əskikdir. Daha güclü təhlükəsizlik üçün bir veb sayt əlavə edin və parolu dəyişdirin." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Zəifliyi olan parol." }, "changeNow": { - "message": "Change now" + "message": "İndi dəyişdir" }, "missingWebsite": { "message": "Əskik veb sayt" @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Kart nömrəsi" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Təşkilatınız, artıq Bitwarden-ə giriş etmək üçün ana parol istifadə etmir. Davam etmək üçün təşkilatı və domeni doğrulayın." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Bunu niyə görürəm?" }, + "items": { + "message": "Elementlər" + }, + "searchResults": { + "message": "Axtarış nəticələri" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "E-poçt qorunur" + }, + "sendPasswordHelperText": { + "message": "Şəxslər, Send-ə baxması üçün parolu daxil etməli olacaqlar", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 5f765c0045d..9f4a65e3072 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": "Выкарыстаць аднаразовы ўваход" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Прапрыетарныя варыянты двухэтапнага ўваходу, такія як YubiKey і Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Гігіена пароляў, здароўе ўліковага запісу і справаздачы аб уцечках даных для забеспячэння бяспекі вашага сховішча." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Электронная пошта" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Тэлефон" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Элемент выдалены назаўсёды" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Аднавіць элемент" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Праграма не будзе прапаноўваць захаваць падрабязнасці ўваходу для гэтых даменаў. Вы павінны абнавіць старонку, каб змяненні пачалі дзейнічаць." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Карыстальніцкі" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Стварыць новы Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send адрэдагаваны", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Для выбару файла неабходна адкрыць пашырэнне на бакавой панэлі (калі ёсць такая магчымасць) або перайсці ў новае акно, націснуўшы на гэты банэр." - }, - "sendFirefoxFileWarning": { - "message": "Для выбару файла з выкарыстаннем Firefox неабходна адкрыць пашырэнне на бакавой панэлі або перайсці ў новае акно, націснуўшы на гэты банэр." - }, - "sendSafariFileWarning": { - "message": "Для выбару файла з выкарыстаннем Safari неабходна перайсці ў новае акно, націснуўшы на гэты банэр." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Перад тым, як пачаць" - }, "expirationDateIsInvalid": { "message": "Азначаная дата завяршэння тэрміну дзеяння з'яўляецца няправільнай." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Кансоль адміністратара" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Бяспеке акаўнта" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index c94e8ff2fdc..a46ad75065e 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": "Използване на еднократна идентификация" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Елементът беше преместен в архива" }, + "itemWasUnarchived": { + "message": "Елементът беше изваден от архива" + }, "itemUnarchived": { "message": "Елементът беше изваден от архива" }, "archiveItem": { "message": "Архивиране на елемента" }, - "archiveItemConfirmDesc": { - "message": "Архивираните елементи са изключени от общите резултати при търсене и от предложенията за автоматично попълване. Наистина ли искате да архивирате този елемент?" + "archiveItemDialogContent": { + "message": "След като бъде архивиран, този елемент няма да се показва в резултатите при търсене, нито в предложенията за автоматично попълване." + }, + "archived": { + "message": "Архивирано" + }, + "unarchiveAndSave": { + "message": "Разархивиране и запазване" }, "upgradeToUseArchive": { "message": "За да се възползвате от архивирането, трябва да ползвате платен абонамент." @@ -978,6 +990,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Всеки с връзката" + }, + "anyOneWithPassword": { + "message": "Всеки с парола, зададена от Вас" + }, "location": { "message": "Местоположение" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Частно двустепенно удостоверяване чрез YubiKey и Duo." }, + "premiumSubscriptionEnded": { + "message": "Вашият абонамент за платения план е приключил" + }, + "archivePremiumRestart": { + "message": "Ако искате отново да получите достъп до архива си, трябва да подновите платения си абонамент. Ако редактирате данните за архивиран елемент преди подновяването, той ще бъде върнат в трезора." + }, + "restartPremium": { + "message": "Подновяване на платения абонамент" + }, "ppremiumSignUpReports": { "message": "Проверки в списъците с публикувани пароли, проверка на регистрациите и доклади за пробивите в сигурността, което спомага трезорът ви да е допълнително защитен." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Електронна поща" }, + "emails": { + "message": "Е-пощи" + }, "phone": { "message": "Телефон" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Записът е изтрит окончателно" }, + "archivedItemRestored": { + "message": "Архивираният елемент е възстановен" + }, "restoreItem": { "message": "Възстановяване на запис" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Битуорден няма да пита дали да запазва данните за вход в тези сайтове. За да влезе правилото в сила, презаредете страницата." }, - "excludedDomainsDescAlt": { - "message": "Битуорден няма да пита дали да запазва данните за вход в тези сайтове за всички регистрации, в които сте вписан(а). За да влезе правилото в сила, презаредете страницата." - }, "blockedDomainsDesc": { "message": "Автоматичното попълване и други свързани функции няма да бъдат предлагани за тези уеб сайтове. Трябва да презаредите страницата, за да влязат в сила промените." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "По избор" }, - "sendPasswordDescV3": { - "message": "Добавете незадължителна парола, с която получателите да имат достъп до това Изпращане.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Създаване на изпращане", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Редактирано изпращане", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Отваряне на разширението в нов прозорец?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "За да създадете файлово Изпращане, трябва да отворите разширението в нов прозорец.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "За да изберете файл, отворете разширението в страничната лента (ако е възможно) или в нов прозорец, като натиснете това съобщение." - }, - "sendFirefoxFileWarning": { - "message": "За да изберете файл във Firefox, отворете разширението в страничната лента или в нов прозорец, като натиснете това съобщение." - }, - "sendSafariFileWarning": { - "message": "За да изберете файл в Safari, отворете разширението в нов прозорец, като натиснете това съобщение." - }, "popOut": { "message": "Отваряне в прозорец" }, - "sendFileCalloutHeader": { - "message": "Преди да почнете" - }, "expirationDateIsInvalid": { "message": "Неправилна дата на валидност." }, @@ -3346,6 +3352,12 @@ "error": { "message": "Грешка" }, + "prfUnlockFailed": { + "message": "Отключването със секретен ключ не беше успешно. Опитайте отново или използвайте друг начин за отключване." + }, + "noPrfCredentialsAvailable": { + "message": "Няма секретни ключове с включено PRF, налични за отключване. Първо се впишете със секретен ключ." + }, "decryptionError": { "message": "Грешка при дешифриране" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Още настройки" + }, "moreOptionsTitle": { "message": "Още опции – $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Административна конзола" }, + "admin": { + "message": "Администратор" + }, + "automaticUserConfirmation": { + "message": "Автоматично потвърждение на потребителите" + }, + "automaticUserConfirmationHint": { + "message": "Автоматично потвърждение на потребителите, когато това устройство е отключено" + }, + "autoConfirmOnboardingCallout": { + "message": "Спестете време с автоматичното потвърждение на потребителите" + }, + "autoConfirmWarning": { + "message": "Това може да се отрази на сигурността на данните в организацията Ви. " + }, + "autoConfirmWarningLink": { + "message": "Научете повече за рисковете" + }, + "autoConfirmSetup": { + "message": "Автоматично потвърждаване на новите потребители" + }, + "autoConfirmSetupDesc": { + "message": "Новите потребители ще бъдат потвърждавани автоматично, докато това устройство е отключено." + }, + "autoConfirmSetupHint": { + "message": "Какви са възможните рискове за сигурността?" + }, + "autoConfirmEnabled": { + "message": "Автоматичното потвърждаване е включено" + }, + "availableNow": { + "message": "Налично сега" + }, "accountSecurity": { "message": "Защита на регистрацията" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Сваляне на прикачения файл" + }, "downloadBitwarden": { "message": "Сваляне на Битуорден" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Скриване на откритото съвпадение $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Показване на откриването на съвпадения" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Скриване на откриването на съвпадения" }, "autoFillOnPageLoad": { "message": "Автоматично попълване при зареждане на страницата?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Много широко" }, + "narrow": { + "message": "Тясно" + }, "sshKeyWrongPassword": { "message": "Въведената парола е неправилна." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Номер на картата" }, + "errorCannotDecrypt": { + "message": "Грешка: не може да се дешифрира" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Вашата организация вече не използва главни пароли за вписване в Битуорден. За да продължите, потвърдете организацията и домейна." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Защо виждам това?" }, + "items": { + "message": "Елементи" + }, + "searchResults": { + "message": "Резултати от търсенето" + }, "resizeSideNavigation": { "message": "Преоразмеряване на страничната навигация" + }, + "whoCanView": { + "message": "Кой може да преглежда" + }, + "specificPeople": { + "message": "Определени хора" + }, + "emailVerificationDesc": { + "message": "След като споделите тази връзка към Изпращане, хората ще трябва да потвърдят е-пощата си чрез код, за да могат да видят това Изпращане." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Можете да въведете повече е-пощи, като ги разделите със запетая." + }, + "emailPlaceholder": { + "message": "потребител@bitwarden.com , потребител@acme.com" + }, + "downloadBitwardenApps": { + "message": "Сваляне на приложенията на Битуорден" + }, + "emailProtected": { + "message": "Е-пощата е защитена" + }, + "sendPasswordHelperText": { + "message": "Хората ще трябва да въведат паролата, за да видят това Изпращане", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 106b61dd9f8..b46d0664231 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -26,7 +26,10 @@ "message": "বিটওয়ার্ডেনে নতুন?" }, "logInWithPasskey": { - "message": "Log in with passkey" + "message": "পাসকী দিয়ে লগইন করুন" + }, + "unlockWithPasskey": { + "message": "পাসকী দিয়ে আনলক করুন" }, "useSingleSignOn": { "message": "Use single sign-on" @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "না" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "আপনার ভল্টটি সুরক্ষিত রাখতে পাসওয়ার্ড স্বাস্থ্যকরন, অ্যাকাউন্ট স্বাস্থ্য এবং ডেটা লঙ্ঘনের প্রতিবেদন।" }, @@ -2027,6 +2054,9 @@ "email": { "message": "ই-মেইল" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ফোন" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "বস্তুটি স্থায়ীভাবে মুছে ফেলা হয়েছে" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "বস্তু পুনরুদ্ধার" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Custom" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." - }, - "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." - }, - "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Before you start" - }, "expirationDateIsInvalid": { "message": "The expiration date provided is not valid." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index ac17bac7097..e81fc637b5c 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Custom" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." - }, - "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." - }, - "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Before you start" - }, "expirationDateIsInvalid": { "message": "The expiration date provided is not valid." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 96944f45b5b..2bd53876953 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Ubicació" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Opcions propietàries de doble factor com ara YubiKey i Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Requisits d'higiene de la contrasenya, salut del compte i informe d'infraccions de dades per mantenir la seguretat de la vostra caixa forta." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Correu electrònic" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telèfon" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Element suprimit definitivament" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restaura l'element" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden no demanarà que es guarden les dades d’inici de sessió d’aquests dominis. Heu d'actualitzar la pàgina perquè els canvis tinguen efecte." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden no demanarà que es guarden les dades d'inici de sessió d'aquests dominis per a tots els comptes iniciats. Heu d'actualitzar la pàgina perquè els canvis tinguen efecte." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Personalitzat" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Crea un nou Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send guardat", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Per triar un fitxer, obriu l'extensió a la barra lateral (si és possible) o eixiu a una finestra nova fent clic a aquest bàner." - }, - "sendFirefoxFileWarning": { - "message": "Per triar un fitxer mitjançant Firefox, obriu l'extensió a la barra lateral o bé apareixerà a una finestra nova fent clic a aquest bàner." - }, - "sendSafariFileWarning": { - "message": "Per triar un fitxer mitjançant Safari, eixiu a una finestra nova fent clic en aquest bàner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Abans de començar" - }, "expirationDateIsInvalid": { "message": "La data de caducitat proporcionada no és vàlida." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Més opcions - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Consola d'administració" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Seguretat del compte" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Habilita l'emplenament automàtic en carregar la pàgina?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra ample" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 1d00f81a62c..1501c7d7c4a 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í" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Položka byla přesunuta do archivu" }, + "itemWasUnarchived": { + "message": "Položka byla odebrána z archivu" + }, "itemUnarchived": { "message": "Položka byla odebrána z archivu" }, "archiveItem": { "message": "Archivovat položku" }, - "archiveItemConfirmDesc": { - "message": "Archivované položky jsou vyloučeny z obecných výsledků vyhledávání a z návrhů automatického vyplňování. Jste si jisti, že chcete tuto položku archivovat?" + "archiveItemDialogContent": { + "message": "Jakmile bude tato položka archivována, bude vyloučena z výsledků vyhledávání a z návrhů automatického vyplňování." + }, + "archived": { + "message": "Archivováno" + }, + "unarchiveAndSave": { + "message": "Odebrat z archivu a uložit" }, "upgradeToUseArchive": { "message": "Pro použití funkce Archiv je potřebné prémiové členství." @@ -978,6 +990,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Kdokoli s odkazem" + }, + "anyOneWithPassword": { + "message": "Kdokoli s heslem od Vás" + }, "location": { "message": "Umístění" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Volby proprietálních dvoufázových přihlášení jako je YubiKey a Duo." }, + "premiumSubscriptionEnded": { + "message": "Vaše předplatné Premium skončilo" + }, + "archivePremiumRestart": { + "message": "Chcete-li získat přístup k Vašemu archivu, restartujte předplatné Premium. Pokud upravíte detaily archivované položky před restartováním, bude přesunuta zpět do Vašeho trezoru." + }, + "restartPremium": { + "message": "Restartovat Premium" + }, "ppremiumSignUpReports": { "message": "Reporty o hygieně Vašich hesel, zdraví účtu a narušeních bezpečnosti." }, @@ -2027,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "E-maily" + }, "phone": { "message": "Telefon" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Položka byla trvale smazána" }, + "archivedItemRestored": { + "message": "Archivovaná položka byla obnovena" + }, "restoreItem": { "message": "Obnovit položku" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden nebude žádat o uložení přihlašovacích údajů pro tyto domény. Aby se změny projevily, musíte stránku obnovit." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden nebude žádat o uložení přihlašovacích údajů pro tyto domény pro všechny přihlášené účty. Aby se změny projevily, musíte stránku obnovit." - }, "blockedDomainsDesc": { "message": "Automatické vyplňování a další související funkce nebudou pro tyto webové stránky nabízeny. Aby se změny projevily, musíte stránku aktualizovat." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Vlastní" }, - "sendPasswordDescV3": { - "message": "Přidá volitelné heslo pro příjemce pro přístup k tomuto Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Nový Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send upraven", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Zobrazit rozšíření v novém okně?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Chcete-li vytvořit Send souboru, musí se zobrazit rozšíření v novém okně.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Chcete-li vybrat soubor, otevřete rozšíření v postranním panelu (pokud je to možné) nebo jej otevřete v novém okně klepnutím na tento banner." - }, - "sendFirefoxFileWarning": { - "message": "Chcete-li vybrat soubor pomocí prohlížeče Firefox, otevřete rozšíření v postranním panelu (pokud je to možné) nebo jej otevřete v novém okně klepnutím na tento banner." - }, - "sendSafariFileWarning": { - "message": "Chcete-li vybrat soubor pomocí prohlížeče Safari, otevřete nové okno klepnutím na tento banner." - }, "popOut": { "message": "Nové okno" }, - "sendFileCalloutHeader": { - "message": "Než začnete" - }, "expirationDateIsInvalid": { "message": "Uvedené datum vypršení platnosti není platné." }, @@ -3134,7 +3140,7 @@ "message": "Aktualizovat hlavní heslo" }, "updateMasterPasswordWarning": { - "message": "Administrátor v organizaci nedávno změnil Vaše hlavní heslo. Pro přístup k trezoru jej nyní musíte změnit. Pokračování Vás odhlásí z Vaší aktuální relace a bude nutné se znovu přihlásit. Aktivní relace na jiných zařízeních mohou zůstat aktivní až po dobu jedné hodiny." + "message": "Správce v organizaci nedávno změnil Vaše hlavní heslo. Pro přístup k trezoru jej nyní musíte změnit. Pokračování Vás odhlásí z Vaší aktuální relace a bude nutné se znovu přihlásit. Aktivní relace na jiných zařízeních mohou zůstat aktivní až po dobu jedné hodiny." }, "updateWeakMasterPasswordWarning": { "message": "Vaše hlavní heslo nesplňuje jednu nebo více zásad Vaší organizace. Pro přístup k trezoru musíte nyní aktualizovat své hlavní heslo. Pokračování Vás odhlásí z Vaší aktuální relace a bude nutné se přihlásit. Aktivní relace na jiných zařízeních mohou zůstat aktivní až po dobu jedné hodiny." @@ -3346,6 +3352,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í" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Více voleb" + }, "moreOptionsTitle": { "message": "Více voleb - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Konzole správce" }, + "admin": { + "message": "Správce" + }, + "automaticUserConfirmation": { + "message": "Automatické potvrzení uživatele" + }, + "automaticUserConfirmationHint": { + "message": "Automaticky potvrdit čekající uživatele, když je toto zařízení odemčeno" + }, + "autoConfirmOnboardingCallout": { + "message": "Ušetřete čas s automatickým potvrzením uživatele" + }, + "autoConfirmWarning": { + "message": "To by mohlo ovlivnit bezpečnost dat Vaší organizace. " + }, + "autoConfirmWarningLink": { + "message": "Více o rizicích" + }, + "autoConfirmSetup": { + "message": "Automaticky potvrdit nové uživatele" + }, + "autoConfirmSetupDesc": { + "message": "Noví uživatelé budou automaticky potvrzeni, když bude toto zařízení odemčeno." + }, + "autoConfirmSetupHint": { + "message": "Jaká jsou možná bezpečnostní rizika?" + }, + "autoConfirmEnabled": { + "message": "Zapnuto automatické potvrzení" + }, + "availableNow": { + "message": "Nyní k dispozici" + }, "accountSecurity": { "message": "Zabezpečení účtu" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Stáhnout přílohu" + }, "downloadBitwarden": { "message": "Stáhnout Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Skrýt detekci shody $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Zobrazit detekci shody" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Skrýt detekci shody" }, "autoFillOnPageLoad": { "message": "Automaticky vyplnit při načtení stránky?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra široké" }, + "narrow": { + "message": "Úzké" + }, "sshKeyWrongPassword": { "message": "Zadané heslo není správné." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Číslo karty" }, + "errorCannotDecrypt": { + "message": "Chyba: Nelze dešifrovat" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Vaše organizace již k přihlášení do Bitwardenu nepoužívá hlavní hesla. Chcete-li pokračovat, ověřte organizaci a doménu." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Proč se mi toto zobrazuje?" }, + "items": { + "message": "Položky" + }, + "searchResults": { + "message": "Výsledky hledání" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Stáhnout aplikace Bitwarden" + }, + "emailProtected": { + "message": "E-mail je chráněný" + }, + "sendPasswordHelperText": { + "message": "Pro zobrazení tohoto Send budou muset jednotlivci zadat heslo", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index c6a380da1a6..6910fe2efb3 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" }, @@ -344,16 +347,16 @@ "message": "Bitwarden for Business" }, "bitwardenAuthenticator": { - "message": "Dilyswr Bitwarden" + "message": "Dilysydd Bitwarden" }, "continueToAuthenticatorPageDesc": { "message": "Bitwarden Authenticator allows you to store authenticator keys and generate TOTP codes for 2-step verification flows. Learn more on the bitwarden.com website" }, "bitwardenSecretsManager": { - "message": "Bitwarden Secrets Manager" + "message": "Rheolydd Cyfrinachau Bitwarden" }, "continueToSecretsManagerPageDesc": { - "message": "Securely store, manage, and share developer secrets with Bitwarden Secrets Manager. Learn more on the bitwarden.com website." + "message": "Gallwch storio, rheoli a rhannu cyfrinachau datblygwyr yn ddiogel gyda Rheolydd Cyfrinachau Bitwarden. Dysgwch fwy ar wefan bitwarden.com." }, "passwordlessDotDev": { "message": "Passwordless.dev" @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "Na" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lleoliad" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Dewisiadau mewngofnodi dau gam perchenogol megis YubiKey a Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Ebost" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Ffôn" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Eitem wedi'i dileu'n barhaol" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Adfer yr eitem" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Fydd Bitwarden ddim yn gofyn i gadw manylion mewngofnodi'r parthau hyn. Rhaid i chi ail-lwytho'r dudalen i newidiadau ddod i rym." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Addasedig" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." - }, - "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." - }, - "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Cyn i chi ddechrau" - }, "expirationDateIsInvalid": { "message": "Dyw'r dyddiad dod i ben a roddwyd ddim yn ddilys." }, @@ -3346,6 +3352,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" }, @@ -4155,7 +4167,7 @@ "description": "Text to display in overlay when the account is locked." }, "unlockYourAccountToViewAutofillSuggestions": { - "message": "Unlock your account to view autofill suggestions", + "message": "Datglowch eich cyfrif i weld argymhellion llenwi awtomatig", "description": "Text to display in overlay when the account is locked." }, "unlockAccount": { @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Diogelwch eich cyfrif" }, @@ -4935,11 +4983,14 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, "downloadBitwardenOnAllDevices": { - "message": "Download Bitwarden on all devices" + "message": "Lawrlwytho Bitwarden ar bob dyfais" }, "getTheMobileApp": { "message": "Get the mobile app" @@ -4969,7 +5020,7 @@ "message": "Premium" }, "unlockFeaturesWithPremium": { - "message": "Unlock reporting, emergency access, and more security features with Premium." + "message": "Datglowch nodweddion diogelwch megis adroddiadau, mynediad mewn argyfwng, a mwy drwy gyfrif Premium." }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" @@ -4978,7 +5029,7 @@ "message": "Filters" }, "filterVault": { - "message": "Filter vault" + "message": "Hidlo'r gell" }, "filterApplied": { "message": "One filter applied" @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Llydan iawn" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5864,7 +5915,7 @@ "message": "Great job securing your at-risk logins!" }, "upgradeNow": { - "message": "Upgrade now" + "message": "Uwchraddio nawr" }, "builtInAuthenticator": { "message": "Built-in authenticator" @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 4871e7e7100..faf4fc855ec 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "Nej" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietære totrins-login muligheder, såsom YubiKey og Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Adgangskodehygiejne, kontosundhed og rapporter om datalæk til at holde din boks sikker." }, @@ -2027,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Element slettet permanent" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Gendan element" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden vil ikke bede om at gemme login-detaljer for disse domæner. Du skal opdatere siden for at ændringerne kan træde i kraft." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden vil ikke anmode om at gemme login-detaljer for disse domæner for alle indloggede konti. Siden skal opfriskes for at effektuere ændringerne." - }, "blockedDomainsDesc": { "message": "Autofyldning og andre relaterede funktioner tilbydes ikke på disse websteder. Siden skal opdateres for at effektuere ændringerne." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Tilpasset" }, - "sendPasswordDescV3": { - "message": "Tilføj en valgfri adgangskode til modtagere for adgang til denne Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Ny Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send gemt", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop udvidelsen ud?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "For at oprette en fil-Send, skal udvidelsen poppes ud i et nyt vindue.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "For at vælge en fil, åben udvidelsen i sidepanelet (om muligt) eller pop ud til et nyt vindue ved at klikke på dette banner." - }, - "sendFirefoxFileWarning": { - "message": "For at vælge en fil i Firefox skal du flytte udvidelsen til sidepanelet eller åbne i et nyt vindue ved at klikke på dette banner." - }, - "sendSafariFileWarning": { - "message": "For at vælge en fil i Safari skal du åbne i et nyt vindue ved at klikke på dette banner." - }, "popOut": { "message": "Pop ud" }, - "sendFileCalloutHeader": { - "message": "Før du starter" - }, "expirationDateIsInvalid": { "message": "Den angivne udløbsdato er ikke gyldig." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Flere valgmuligheder - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin-konsol" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Kontosikkerhed" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Skjul matchdetektion $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autoudfyld ved sideindlæsning?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Ekstra bred" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index d996c1d0835..ad5b45159df 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" }, @@ -573,20 +576,29 @@ "itemWasSentToArchive": { "message": "Eintrag wurde archiviert" }, + "itemWasUnarchived": { + "message": "Eintrag wird nicht mehr archiviert" + }, "itemUnarchived": { "message": "Eintrag wird nicht mehr archiviert" }, "archiveItem": { "message": "Eintrag archivieren" }, - "archiveItemConfirmDesc": { - "message": "Archivierte Einträge werden von allgemeinen Suchergebnissen sowie Vorschlägen zum automatischen Ausfüllen ausgeschlossen. Bist du sicher, dass du diesen Eintrag archivieren möchtest?" + "archiveItemDialogContent": { + "message": "Nach der Archivierung wird dieser Eintrag aus den Suchergebnissen und Auto-Ausfüllen-Vorschlägen ausgeschlossen." + }, + "archived": { + "message": "Archiviert" + }, + "unarchiveAndSave": { + "message": "Nicht mehr archivieren und speichern" }, "upgradeToUseArchive": { "message": "Für die Nutzung des Archivs ist eine Premium-Mitgliedschaft erforderlich." }, "itemRestored": { - "message": "Item has been restored" + "message": "Eintrag wurde wiederhergestellt" }, "edit": { "message": "Bearbeiten" @@ -978,6 +990,12 @@ "no": { "message": "Nein" }, + "noAuth": { + "message": "Alle mit dem Link" + }, + "anyOneWithPassword": { + "message": "Alle mit einem von dir festgelegtem Passwort" + }, "location": { "message": "Standort" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietäre Optionen für die Zwei-Faktor Authentifizierung wie YubiKey und Duo." }, + "premiumSubscriptionEnded": { + "message": "Dein Premium-Abonnement ist abgelaufen" + }, + "archivePremiumRestart": { + "message": "Starte dein Premium-Abonnement neu, um den Zugriff auf dein Archiv wiederherzustellen. Wenn du die Details für einen archivierten Eintrag vor dem Neustart bearbeitest, wird er wieder zurück in deinen Tresor verschoben." + }, + "restartPremium": { + "message": "Premium neu starten" + }, "ppremiumSignUpReports": { "message": "Berichte über Kennworthygiene, Kontostatus und Datenschutzverletzungen, um deinen Tresor sicher zu halten." }, @@ -2027,6 +2054,9 @@ "email": { "message": "E-Mail" }, + "emails": { + "message": "E-Mails" + }, "phone": { "message": "Telefon" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Eintrag dauerhaft gelöscht" }, + "archivedItemRestored": { + "message": "Archivierter Eintrag wiederhergestellt" + }, "restoreItem": { "message": "Eintrag wiederherstellen" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden wird keine Login-Daten für diese Domäne speichern. Du musst die Seite aktualisieren, damit die Änderungen wirksam werden." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden wird für alle angemeldeten Konten nicht danach fragen Zugangsdaten für diese Domains speichern. Du musst die Seite neu laden, damit die Änderungen wirksam werden." - }, "blockedDomainsDesc": { "message": "Automatisches Ausfüllen und andere zugehörige Funktionen werden für diese Webseiten nicht angeboten. Du musst die Seite neu laden, damit die Änderungen wirksam werden." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Benutzerdefiniert" }, - "sendPasswordDescV3": { - "message": "Füge ein optionales Passwort hinzu, mit dem Empfänger auf dieses Send zugreifen können.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Neues Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send gespeichert", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Erweiterung in einem neuen Fenster öffnen?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Um ein Datei-Send zu erstellen, musst du die Erweiterung in einem neuen Fenster öffnen.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Um eine Datei auszuwählen, öffne die Erweiterung in der Sidebar (falls möglich) oder öffne sie in einem neuem Fenster, indem du auf dieses Banner klickst." - }, - "sendFirefoxFileWarning": { - "message": "Um eine Datei mit Firefox auszuwählen, öffne die Erweiterung in der Sidebar oder öffne ein neues Fenster, indem du auf dieses Banner klickst." - }, - "sendSafariFileWarning": { - "message": "Um eine Datei mit Safari auszuwählen, öffne ein neues Fenster, indem du auf dieses Banner klickst." - }, "popOut": { "message": "Abkoppeln" }, - "sendFileCalloutHeader": { - "message": "Bevor du beginnst" - }, "expirationDateIsInvalid": { "message": "Das angegebene Verfallsdatum ist nicht gültig." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Weitere Optionen" + }, "moreOptionsTitle": { "message": "Weitere Optionen - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Administrator-Konsole" }, + "admin": { + "message": "Administrator" + }, + "automaticUserConfirmation": { + "message": "Automatische Benutzerbestätigung" + }, + "automaticUserConfirmationHint": { + "message": "Ausstehende Benutzer automatisch bestätigen, während dieses Gerät entsperrt ist" + }, + "autoConfirmOnboardingCallout": { + "message": "Spare Zeit durch die automatische Benutzerbestätigung" + }, + "autoConfirmWarning": { + "message": "Dies könnte die Datensicherheit deiner Organisation beeinflussen. " + }, + "autoConfirmWarningLink": { + "message": "Erfahre mehr über die Risiken" + }, + "autoConfirmSetup": { + "message": "Neue Benutzer automatisch bestätigen" + }, + "autoConfirmSetupDesc": { + "message": "Neue Benutzer werden automatisch bestätigt, während dieses Gerät entsperrt ist." + }, + "autoConfirmSetupHint": { + "message": "Was sind die möglichen Sicherheitsrisiken?" + }, + "autoConfirmEnabled": { + "message": "Automatische Bestätigung aktiviert" + }, + "availableNow": { + "message": "Jetzt verfügbar" + }, "accountSecurity": { "message": "Kontosicherheit" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Anhang herunterladen" + }, "downloadBitwarden": { "message": "Bitwarden herunterladen" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Übereinstimmungs-Erkennung verstecken $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Übereinstimmungserkennung anzeigen" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Übereinstimmungserkennung ausblenden" }, "autoFillOnPageLoad": { "message": "Auto-Ausfüllen beim Laden einer Seite?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra breit" }, + "narrow": { + "message": "Schmal" + }, "sshKeyWrongPassword": { "message": "Dein eingegebenes Passwort ist falsch." }, @@ -5668,10 +5719,10 @@ "message": "Diese Zugangsdaten sind gefährdet und es fehlt eine Website. Füge eine Website hinzu und ändere das Passwort für mehr Sicherheit." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Gefährdetes Passwort." }, "changeNow": { - "message": "Change now" + "message": "Jetzt ändern" }, "missingWebsite": { "message": "Fehlende Website" @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Kartennummer" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Deine Organisation verwendet keine Master-Passwörter mehr, um sich bei Bitwarden anzumelden. Verifiziere die Organisation und Domain, um fortzufahren." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Warum wird mir das angezeigt?" }, + "items": { + "message": "Einträge" + }, + "searchResults": { + "message": "Suchergebnisse" + }, "resizeSideNavigation": { "message": "Größe der Seitennavigation ändern" + }, + "whoCanView": { + "message": "Wer kann das sehen" + }, + "specificPeople": { + "message": "Bestimmte Personen" + }, + "emailVerificationDesc": { + "message": "Nach dem Teilen dieses Send-Links müssen Personen ihre E-Mail-Adresse mit einem Code verifizieren, um dieses Send anzuzeigen." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Gib mehrere E-Mail-Adressen ein, indem du sie mit einem Komma trennst." + }, + "emailPlaceholder": { + "message": "benutzer@bitwarden.com, benutzer@acme.com" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "E-Mail-Adresse geschützt" + }, + "sendPasswordHelperText": { + "message": "Personen müssen das Passwort eingeben, um dieses Send anzusehen", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index e05cc5f4d6a..59f757008f2 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -28,11 +28,14 @@ "logInWithPasskey": { "message": "Σύνδεση με κλειδί πρόσβασης" }, + "unlockWithPasskey": { + "message": "Ξεκλείδωμα με passkey" + }, "useSingleSignOn": { "message": "Χρήση ενιαίας σύνδεσης" }, "yourOrganizationRequiresSingleSignOn": { - "message": "Your organization requires single sign-on." + "message": "Ο οργανισμός σας απαιτεί single sign-on (SSO)." }, "welcomeBack": { "message": "Καλώς ήρθατε" @@ -437,7 +440,7 @@ "message": "Συγχρονισμός" }, "syncNow": { - "message": "Sync now" + "message": "Συγχρονισμός τώρα" }, "lastSync": { "message": "Τελευταίος συγχρονισμός:" @@ -571,22 +574,31 @@ "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, "itemWasSentToArchive": { - "message": "Item was sent to archive" + "message": "Το στοιχείο στάλθηκε στην αρχειοθήκη" + }, + "itemWasUnarchived": { + "message": "Το στοιχείο επαναφέρθηκε από την αρχειοθήκη" }, "itemUnarchived": { - "message": "Item was unarchived" + "message": "Το στοιχείο επαναφέρθηκε από την αρχειοθήκη" }, "archiveItem": { - "message": "Archive item" + "message": "Αρχειοθέτηση στοιχείου" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Μόλις αρχειοθετηθεί, αυτό το στοιχείο θα εξαιρεθεί από τα αποτελέσματα αναζήτησης και τις προτάσεις αυτόματης συμπλήρωσης." + }, + "archived": { + "message": "Αρχειοθετημένο" + }, + "unarchiveAndSave": { + "message": "Κατάργηση αρχειοθέτησης και αποθήκευση" }, "upgradeToUseArchive": { - "message": "A premium membership is required to use Archive." + "message": "Για να χρησιμοποιήσετε την αρχειοθήκη απαιτείται η έκδοση Premium." }, "itemRestored": { - "message": "Item has been restored" + "message": "Το στοιχείο έχει αποκατασταθεί" }, "edit": { "message": "Επεξεργασία" @@ -595,13 +607,13 @@ "message": "Προβολή" }, "viewAll": { - "message": "View all" + "message": "Προβολή όλων" }, "showAll": { - "message": "Show all" + "message": "Εμφάνιση όλων" }, "viewLess": { - "message": "View less" + "message": "Προβολή λιγότερων" }, "viewLogin": { "message": "Προβολή σύνδεσης" @@ -749,7 +761,7 @@ "message": "Μη έγκυρος κύριος κωδικός πρόσβασης" }, "invalidMasterPasswordConfirmEmailAndHost": { - "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "message": "Μη έγκυρος κύριος κωδικός πρόσβασης. Επιβεβαιώστε ότι το email σας είναι σωστό και ότι ο λογαριασμός σας δημιουργήθηκε στο $HOST$.", "placeholders": { "host": { "content": "$1", @@ -806,10 +818,10 @@ "message": "Κατά το Κλείδωμα Συστήματος" }, "onIdle": { - "message": "On system idle" + "message": "Κατά την αδράνεια του συστήματος" }, "onSleep": { - "message": "On system sleep" + "message": "Κατά την αναμονή του συστήματος" }, "onRestart": { "message": "Κατά την Επανεκκίνηση του Browser" @@ -978,6 +990,12 @@ "no": { "message": "Όχι" }, + "noAuth": { + "message": "Οποιοσδήποτε/οποιαδήποτε έχει το σύνδεσμο" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Τοποθεσία" }, @@ -1050,10 +1068,10 @@ "message": "Το αντικείμενο αποθηκεύτηκε" }, "savedWebsite": { - "message": "Saved website" + "message": "Αποθηκευμένος ιστοχώρος" }, "savedWebsites": { - "message": "Saved websites ( $COUNT$ )", + "message": "Αποθηκευμένοι ιστοχώροι ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -1181,7 +1199,7 @@ "description": "Shown to user after item is updated." }, "selectItemAriaLabel": { - "message": "Select $ITEMTYPE$, $ITEMNAME$", + "message": "Επιλογή $ITEMTYPE$, $ITEMNAME$", "description": "Used by screen readers. $1 is the item type (like vault or folder), $2 is the selected item name.", "placeholders": { "itemType": { @@ -1250,14 +1268,14 @@ "description": "Error message shown when the system fails to save login details." }, "saveFailureDetails": { - "message": "Oh no! We couldn't save this. Try entering the details manually.", + "message": "Ωχ όχι! Δεν μπορέσαμε να το αποθηκεύσουμε. Προσπαθήστε να εισάγετε τις λεπτομέρειες χειροκίνητα.", "description": "Detailed error message shown when saving login details fails." }, "changePasswordWarning": { - "message": "After changing your password, you will need to log in with your new password. Active sessions on other devices will be logged out within one hour." + "message": "Αφού αλλάξετε τον κωδικό πρόσβασής σας, θα πρέπει να συνδεθείτε με το νέο σας κωδικό. Ενεργές συνεδρίες σε άλλες συσκευές θα αποσυνδεθούν εντός μίας ώρας." }, "accountRecoveryUpdateMasterPasswordSubtitle": { - "message": "Change your master password to complete account recovery." + "message": "Αλλάξτε τον κύριο κωδικό πρόσβασης για να ολοκληρώσετε την ανάκτηση του λογαριασμού." }, "enableChangedPasswordNotification": { "message": "Ζητήστε να ενημερώσετε την υπάρχουσα σύνδεση" @@ -1326,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": { @@ -1420,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)" @@ -1471,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": "Αρχείο" @@ -1489,7 +1507,7 @@ "message": "Επιλέξτε αρχείο" }, "itemsTransferred": { - "message": "Items transferred" + "message": "Μεταβιβασθέντα στοιχεία" }, "maxFileSize": { "message": "Το μέγιστο μέγεθος αρχείου είναι 500 MB." @@ -1498,7 +1516,7 @@ "message": "Μη διαθέσιμη λειτουργία" }, "legacyEncryptionUnsupported": { - "message": "Legacy encryption is no longer supported. Please contact support to recover your account." + "message": "Η παλαιού τύπου κρυπτογράφηση δεν υποστηρίζεται πλέον. Επικοινωνήστε με την υποστήριξη για να ανακτήσετε το λογαριασμό σας." }, "premiumMembership": { "message": "Συνδρομή Premium" @@ -1522,7 +1540,7 @@ "message": "1 GB κρυπτογραφημένο αποθηκευτικό χώρο για συνημμένα αρχεία." }, "premiumSignUpStorageV2": { - "message": "$SIZE$ encrypted storage for file attachments.", + "message": "$SIZE$ κρυπτογραφημένου αποθηκευτικού χώρου για συνημμένα αρχεία.", "placeholders": { "size": { "content": "$1", @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Πρόσθετες επιλογές σύνδεσης δύο βημάτων, όπως το YubiKey και το Duo." }, + "premiumSubscriptionEnded": { + "message": "Η Premium συνδρομή σας τελείωσε." + }, + "archivePremiumRestart": { + "message": "Για να ξανα-αποκτήσετε πρόσβαση στην αρχειοθήκη σας, επανεκκινήστε τη Premium συνδρομή σας. Αν επεξεργαστείτε λεπτομέρειες ενός αρχειοθετημένου στοιχείου πριν την επανεκκίνηση, αυτό θα μεταφερθεί πίσω στο θησαυροφυλάκιο σας." + }, + "restartPremium": { + "message": "Επανεκκίνηση Premium" + }, "ppremiumSignUpReports": { "message": "Ασφάλεια κωδικών, υγεία λογαριασμού και αναφορές παραβίασης δεδομένων για να διατηρήσετε ασφαλές το vault σας." }, @@ -1609,14 +1636,14 @@ } }, "dontAskAgainOnThisDeviceFor30Days": { - "message": "Don't ask again on this device for 30 days" + "message": "Να μην ερωτηθώ ξανά σε αυτήν τη συσκευή για 30 ημέρες" }, "selectAnotherMethod": { - "message": "Select another method", + "message": "Επιλέξτε μια άλλη μέθοδο", "description": "Select another two-step login method" }, "useYourRecoveryCode": { - "message": "Use your recovery code" + "message": "Χρησιμοποιήστε τον κωδικό ανάκτησης" }, "insertU2f": { "message": "Εισάγετε το κλειδί ασφαλείας στη θύρα USB του υπολογιστή σας. Αν έχει κουμπί, πατήστε το." @@ -1628,19 +1655,19 @@ "message": "Ταυτοποίηση WebAuthn" }, "readSecurityKey": { - "message": "Read security key" + "message": "Διάβασε το κλειδί ασφαλείας\n" }, "readingPasskeyLoading": { "message": "Ανάγνωση κλειδιού πρόσβασης..." }, "passkeyAuthenticationFailed": { - "message": "Passkey authentication failed" + "message": "Αποτυχία ταυτοποίησης passkey" }, "useADifferentLogInMethod": { - "message": "Use a different log in method" + "message": "Χρήση διαφορετικής μεθόδου σύνδεσης" }, "awaitingSecurityKeyInteraction": { - "message": "Awaiting security key interaction..." + "message": "Σε αναμονή αλληλεπίδρασης με κλειδί ασφαλείας" }, "loginUnavailable": { "message": "Μη διαθέσιμη σύνδεση" @@ -1655,7 +1682,7 @@ "message": "Επιλογές σύνδεσης δύο βημάτων" }, "selectTwoStepLoginMethod": { - "message": "Select two-step login method" + "message": "Επιλογή μεθόδου δύο παραγόντων για σύνδεση" }, "recoveryCodeTitle": { "message": "Κωδικός ανάκτησης" @@ -1706,7 +1733,7 @@ "message": "Πρέπει να προσθέσετε είτε το βασικό URL του διακομιστή ή τουλάχιστον ένα προσαρμοσμένο περιβάλλον." }, "selfHostedEnvMustUseHttps": { - "message": "URLs must use HTTPS." + "message": "Οι διευθύνσεις URL πρέπει να χρησιμοποιούν HTTPS." }, "customEnvironment": { "message": "Προσαρμοσμένο περιβάλλον" @@ -1747,10 +1774,10 @@ "message": "Easily find autofill suggestions" }, "autofillSpotlightDesc": { - "message": "Turn off your browser's autofill settings, so they don't conflict with Bitwarden." + "message": "Απενεργοποιήστε τις ρυθμίσεις αυτόματης συμπλήρωσης του προγράμματος περιήγησής σας, έτσι ώστε να μη συγκρούονται με αυτές του Bitwarden." }, "turnOffBrowserAutofill": { - "message": "Turn off $BROWSER$ autofill", + "message": "Απενεργοποίηση αυτόματης συμπλήρωσης του $BROWSER$", "placeholders": { "browser": { "content": "$1", @@ -1762,7 +1789,7 @@ "message": "Απενεργοποίηση αυτόματης συμπλήρωσης" }, "confirmAutofill": { - "message": "Confirm autofill" + "message": "Επιβεβαίωση αυτόματης συμπλήρωσης" }, "confirmAutofillDesc": { "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." @@ -1771,19 +1798,19 @@ "message": "Εμφάνιση μενού αυτόματης συμπλήρωσης στα πεδία της φόρμας" }, "howDoesBitwardenProtectFromPhishing": { - "message": "How does Bitwarden protect your data from phishing?" + "message": "Πώς προστατεύει το Bitwarden τα δεδομένα σας από phishing (ψάρεμα);" }, "currentWebsite": { - "message": "Current website" + "message": "Τρέχων ιστότοπος" }, "autofillAndAddWebsite": { - "message": "Autofill and add this website" + "message": "Αυτόματη συμπλήρωση και προσθήκη αυτού του ιστότοπου" }, "autofillWithoutAdding": { - "message": "Autofill without adding" + "message": "Αυτόματη συμπλήρωση χωρίς προσθήκη" }, "doNotAutofill": { - "message": "Do not autofill" + "message": "Χωρίς αυτόματη συμπλήρωση" }, "showInlineMenuIdentitiesLabel": { "message": "Εμφάνιση ταυτοτήτων ως προτάσεις" @@ -2027,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Τηλέφωνο" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Το αντικείμενο διαγράφηκε οριστικά" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Επαναφορά αντικειμένου" }, @@ -2615,7 +2648,7 @@ "message": "Ξεκινήστε την εφαρμογή Bitwarden Επιφάνεια εργασίας" }, "startDesktopDesc": { - "message": "Η εφαρμογή Bitwarden Desktop πρέπει να ξεκινήσει για να μπορεί να χρησιμοποιηθεί αυτή η λειτουργία." + "message": "Η εφαρμογή Bitwarden Desktop πρέπει να εκκινηθεί για να χρησιμοποιηθεί αυτή η λειτουργία." }, "errorEnableBiometricTitle": { "message": "Αδυναμία ενεργοποίησης βιομετρικών στοιχείων" @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Το Bitwarden δεν θα ζητήσει να αποθηκεύσετε τα στοιχεία σύνδεσης για αυτούς τους τομείς. Πρέπει να ανανεώσετε τη σελίδα για να τεθούν σε ισχύ οι αλλαγές." }, - "excludedDomainsDescAlt": { - "message": "Το Bitwarden δε θα ρωτήσει για να αποθηκεύσετε τα στοιχεία σύνδεσης για αυτούς τους τομείς, για όλους τους συνδεδεμένους λογαριασμούς. Πρέπει να ανανεώσετε τη σελίδα για να τεθούν σε ισχύ οι αλλαγές." - }, "blockedDomainsDesc": { "message": "Η αυτόματη συμπλήρωση και άλλες σχετικές λειτουργίες δεν θα προσφερθούν για αυτούς τους ιστότοπους. Πρέπει να ανανεώσετε τη σελίδα για να τεθούν σε ισχύ οι αλλαγές." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Προσαρμοσμένο" }, - "sendPasswordDescV3": { - "message": "Προσθέστε έναν προαιρετικό κωδικό πρόσβασης για τους παραλήπτες για πρόσβαση σε αυτήν την αποστολή.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Νέο Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Το Send αποθηκεύτηκε", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Άνοιγμα επέκτασης σε αναδυόμενο παράθυρο;", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Για να δημιουργήσετε ένα Send αρχείου, θα πρέπει να ανοίξετε την επέκταση σε νέο αναδυόμενο παράθυρο.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Για να επιλέξετε ένα αρχείο, ανοίξτε την επέκταση στην πλαϊνή μπάρα (αν είναι δυνατόν) ή βγείτε σε ένα νέο παράθυρο κάνοντας κλικ σε αυτή τη διαφήμιση." - }, - "sendFirefoxFileWarning": { - "message": "Για να επιλέξετε ένα αρχείο χρησιμοποιώντας τον Firefox, ανοίξτε την επέκταση στην πλαϊνή μπάρα ή βγείτε σε ένα νέο παράθυρο κάνοντας κλικ σε αυτή τη διαφήμιση." - }, - "sendSafariFileWarning": { - "message": "Για να επιλέξετε ένα αρχείο χρησιμοποιώντας το Safari, βγαίνετε σε ένα νέο παράθυρο κάνοντας κλικ σε αυτή τη διαφήμιση." - }, "popOut": { "message": "Άνοιγμα σε αναδυόμενο παράθυρο" }, - "sendFileCalloutHeader": { - "message": "Πριν ξεκινήσετε" - }, "expirationDateIsInvalid": { "message": "Η ημερομηνία λήξης που δόθηκε δεν είναι έγκυρη." }, @@ -3346,6 +3352,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": "Σφάλμα αποκρυπτογράφησης" }, @@ -3654,13 +3666,13 @@ "message": "Μια ειδοποίηση έχει σταλεί στη συσκευή σας." }, "notificationSentDevicePart1": { - "message": "Unlock Bitwarden on your device or on the" + "message": "Ξεκλειδώστε το Bitwarden στη συσκευή σας ή στην" }, "notificationSentDeviceAnchor": { - "message": "web app" + "message": "εφαρμογή web" }, "notificationSentDevicePart2": { - "message": "Make sure the Fingerprint phrase matches the one below before approving." + "message": "Πριν εγκρίνετε σιγουρευτείτε ότι η φράση δακτυλικού αποτυπώματος ταιριάζει με αυτήν που ακολουθεί." }, "aNotificationWasSentToYourDevice": { "message": "Μια ειδοποίηση στάλθηκε στη συσκευή σας" @@ -3675,7 +3687,7 @@ "message": "Η σύνδεση ξεκίνησε" }, "logInRequestSent": { - "message": "Request sent" + "message": "Το αίτημα στάλθηκε" }, "loginRequestApprovedForEmailOnDevice": { "message": "Login request approved for $EMAIL$ on $DEVICE$", @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Περισσότερες επιλογές - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Κονσόλα Διαχειριστή" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Ασφάλεια λογαριασμού" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Λήψη του Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Απόκρυψη ανιχνεύσεων αντιστοίχισης $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Αυτόματη συμπλήρωση κατά τη φόρτωση της σελίδας;" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Εξαιρετικά φαρδύ" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 29b39863bc6..7944904c44a 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Custom" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." - }, - "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." - }, - "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Before you start" - }, "expirationDateIsInvalid": { "message": "The expiration date provided is not valid." }, @@ -3346,6 +3352,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" }, @@ -4580,11 +4592,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" }, @@ -4592,7 +4604,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4779,7 +4794,7 @@ } } }, - "copyFieldCipherName": { + "copyFieldCipherName": { "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { @@ -4811,7 +4826,7 @@ "adminConsole": { "message": "Admin Console" }, - "admin" :{ + "admin": { "message": "Admin" }, "automaticUserConfirmation": { @@ -4820,7 +4835,7 @@ "automaticUserConfirmationHint": { "message": "Automatically confirm pending users while this device is unlocked" }, - "autoConfirmOnboardingCallout":{ + "autoConfirmOnboardingCallout": { "message": "Save time with automatic user confirmation" }, "autoConfirmWarning": { @@ -4829,6 +4844,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4953,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5092,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5637,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5742,7 +5775,7 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitleV2":{ + "phishingPageTitleV2": { "message": "Phishing attempt detected" }, "phishingPageSummary": { @@ -5762,7 +5795,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": { @@ -5930,7 +5963,10 @@ "cardNumberLabel": { "message": "Card number" }, - "removeMasterPasswordForOrgUserKeyConnector":{ + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, + "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, "continueWithLogIn": { @@ -5948,10 +5984,10 @@ "verifyYourOrganization": { "message": "Verify your organization to log in" }, - "organizationVerified":{ + "organizationVerified": { "message": "Organization verified" }, - "domainVerified":{ + "domainVerified": { "message": "Domain verified" }, "leaveOrganizationContent": { @@ -6067,7 +6103,39 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 1e22c5ffa34..e34e20844e6 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Custom" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." - }, - "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." - }, - "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Before you start" - }, "expirationDateIsInvalid": { "message": "The expiration date provided is not valid." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organisation’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organisation is no longer using master passwords to log into Bitwarden. To continue, verify the organisation and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index cbb2851f872..9fd388a80d3 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Permanently deleted item" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Custom" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Create New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Edited Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out te extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." - }, - "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." - }, - "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Before you start" - }, "expirationDateIsInvalid": { "message": "The expiration date provided is not valid." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organisation’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organisation is no longer using master passwords to log into Bitwarden. To continue, verify the organisation and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index 1160899a4d3..ab5fad7e3af 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": "Desbloquear con clave de acceso" + }, "useSingleSignOn": { "message": "Usar inicio de sesión único" }, @@ -437,7 +440,7 @@ "message": "Sincronizar" }, "syncNow": { - "message": "Sync now" + "message": "Sincronizar ahora" }, "lastSync": { "message": "Última sincronización:" @@ -551,42 +554,51 @@ "message": "Restablecer búsqueda" }, "archiveNoun": { - "message": "Archive", + "message": "Archivo", "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "Archivar", "description": "Verb" }, "unArchive": { - "message": "Unarchive" + "message": "Desarchivar" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Elementos archivados" }, "noItemsInArchive": { - "message": "No items in archive" + "message": "No hay elementos archivados" }, "noItemsInArchiveDesc": { - "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + "message": "Los elementos archivados aparecerán aquí y se excluirán de los resultados de búsqueda generales y de sugerencias de autocompletado." }, "itemWasSentToArchive": { - "message": "Item was sent to archive" + "message": "El elemento fue archivado" + }, + "itemWasUnarchived": { + "message": "El elemento fue desarchivado" }, "itemUnarchived": { - "message": "Item was unarchived" + "message": "El elemento fue desarchivado" }, "archiveItem": { - "message": "Archive item" + "message": "Archivar elemento" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Una vez archivado, este elemento se excluirá de los resultados de búsqueda y de sugerencias de autocompletado." + }, + "archived": { + "message": "Archivados" + }, + "unarchiveAndSave": { + "message": "Desarchivar y guardar" }, "upgradeToUseArchive": { - "message": "A premium membership is required to use Archive." + "message": "Se requiere una membresía premium para usar el Archivo." }, "itemRestored": { - "message": "Item has been restored" + "message": "El elemento se ha restaurado" }, "edit": { "message": "Editar" @@ -595,16 +607,16 @@ "message": "Ver" }, "viewAll": { - "message": "View all" + "message": "Ver todo" }, "showAll": { - "message": "Show all" + "message": "Mostrar todo" }, "viewLess": { - "message": "View less" + "message": "Ver menos" }, "viewLogin": { - "message": "View login" + "message": "Ver inicio de sesión" }, "noItemsInList": { "message": "No hay elementos que listar." @@ -978,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Cualquiera con el enlace" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Ubicación" }, @@ -1053,7 +1071,7 @@ "message": "Saved website" }, "savedWebsites": { - "message": "Saved websites ( $COUNT$ )", + "message": "Sitios web guardados ( $COUNT$ )", "placeholders": { "count": { "content": "$1", @@ -1254,10 +1272,10 @@ "description": "Detailed error message shown when saving login details fails." }, "changePasswordWarning": { - "message": "After changing your password, you will need to log in with your new password. Active sessions on other devices will be logged out within one hour." + "message": "Tras cambiar tu contraseña tendrás que iniciar sesión con tu nueva contraseña. Las sesiones activas en otros dispositivos se cerrarán en una hora." }, "accountRecoveryUpdateMasterPasswordSubtitle": { - "message": "Change your master password to complete account recovery." + "message": "Cambia tu contraseña maestra para completar la recuperación de la cuenta." }, "enableChangedPasswordNotification": { "message": "Solicitar la actualización de los datos de inicio de sesión existentes" @@ -1326,19 +1344,19 @@ "message": "Exportar desde" }, "exportVerb": { - "message": "Export", + "message": "Exportar", "description": "The verb form of the word Export" }, "exportNoun": { - "message": "Export", + "message": "Exportación", "description": "The noun form of the word Export" }, "importNoun": { - "message": "Import", + "message": "Importación", "description": "The noun form of the word Import" }, "importVerb": { - "message": "Import", + "message": "Importar", "description": "The verb form of the word Import" }, "fileFormat": { @@ -1420,25 +1438,25 @@ "message": "Más información" }, "migrationsFailed": { - "message": "An error occurred updating the encryption settings." + "message": "Se ha producido un error al actualizar la configuración de cifrado." }, "updateEncryptionSettingsTitle": { - "message": "Update your encryption settings" + "message": "Actualiza tu configuración de cifrado" }, "updateEncryptionSettingsDesc": { - "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + "message": "La nueva configuración recomendada de cifrado mejorará la seguridad de tu cuenta. Introduce tu contraseña maestra para actualizarla ahora." }, "confirmIdentityToContinue": { - "message": "Confirm your identity to continue" + "message": "Confirma tu identidad para continuar" }, "enterYourMasterPassword": { - "message": "Enter your master password" + "message": "Introduce tu contraseña maestra" }, "updateSettings": { - "message": "Update settings" + "message": "Actualizar ajustes" }, "later": { - "message": "Later" + "message": "Más tarde" }, "authenticatorKeyTotp": { "message": "Clave de autenticación (TOTP)" @@ -1471,13 +1489,13 @@ "message": "El adjunto se ha guardado." }, "fixEncryption": { - "message": "Fix encryption" + "message": "Corregir cifrado" }, "fixEncryptionTooltip": { - "message": "This file is using an outdated encryption method." + "message": "Este archivo está utilizando un método de cifrado obsoleto." }, "attachmentUpdated": { - "message": "Attachment updated" + "message": "Adjunto actualizado" }, "file": { "message": "Archivo" @@ -1489,7 +1507,7 @@ "message": "Selecciona un archivo." }, "itemsTransferred": { - "message": "Items transferred" + "message": "Elementos transferidos" }, "maxFileSize": { "message": "El tamaño máximo de archivo es de 500MB." @@ -1522,7 +1540,7 @@ "message": "1 GB de espacio cifrado en disco para adjuntos." }, "premiumSignUpStorageV2": { - "message": "$SIZE$ encrypted storage for file attachments.", + "message": "$SIZE$ de almacenamiento cifrado para archivos adjuntos.", "placeholders": { "size": { "content": "$1", @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Opciones de inicio de sesión con autenticación de dos pasos propietarios como YubiKey y Duo." }, + "premiumSubscriptionEnded": { + "message": "Tu suscripción Premium ha terminado" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Reiniciar Premium" + }, "ppremiumSignUpReports": { "message": "Higiene de contraseña, salud de la cuenta e informes de violaciones de datos para mantener su caja fuerte segura." }, @@ -1631,13 +1658,13 @@ "message": "Leer clave de seguridad" }, "readingPasskeyLoading": { - "message": "Reading passkey..." + "message": "Leyendo clave de acceso..." }, "passkeyAuthenticationFailed": { - "message": "Passkey authentication failed" + "message": "Autenticación con Passkey fallida" }, "useADifferentLogInMethod": { - "message": "Use a different log in method" + "message": "Utilizar otro método de inicio de sesión" }, "awaitingSecurityKeyInteraction": { "message": "Esperando interacción de la clave de seguridad..." @@ -1706,7 +1733,7 @@ "message": "Debes añadir la dirección URL del servidor base o al menos un entorno personalizado." }, "selfHostedEnvMustUseHttps": { - "message": "URLs must use HTTPS." + "message": "Las URLs deben usar HTTPS." }, "customEnvironment": { "message": "Entorno personalizado" @@ -1762,7 +1789,7 @@ "message": "Desactivar autocompletado" }, "confirmAutofill": { - "message": "Confirm autofill" + "message": "Confirmar autocompletado" }, "confirmAutofillDesc": { "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." @@ -1974,7 +2001,7 @@ "message": "Código de seguridad" }, "cardNumber": { - "message": "card number" + "message": "número de tarjeta" }, "ex": { "message": "ej." @@ -2027,6 +2054,9 @@ "email": { "message": "Correo electrónico" }, + "emails": { + "message": "Correos electrónicos" + }, "phone": { "message": "Teléfono" }, @@ -2079,79 +2109,79 @@ "message": "Nota" }, "newItemHeaderLogin": { - "message": "New Login", + "message": "Nuevo Inicio de Sesión", "description": "Header for new login item type" }, "newItemHeaderCard": { - "message": "New Card", + "message": "Nueva Tarjeta", "description": "Header for new card item type" }, "newItemHeaderIdentity": { - "message": "New Identity", + "message": "Nueva Identidad", "description": "Header for new identity item type" }, "newItemHeaderNote": { - "message": "New Note", + "message": "Nueva Nota", "description": "Header for new note item type" }, "newItemHeaderSshKey": { - "message": "New SSH key", + "message": "Nueva clave SSH", "description": "Header for new SSH key item type" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Nuevo Send de Texto", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Nuevo Send de Archivo", "description": "Header for new file send" }, "editItemHeaderLogin": { - "message": "Edit Login", + "message": "Editar Inicio de Sesión", "description": "Header for edit login item type" }, "editItemHeaderCard": { - "message": "Edit Card", + "message": "Editar Tarjeta", "description": "Header for edit card item type" }, "editItemHeaderIdentity": { - "message": "Edit Identity", + "message": "Editar Identidad", "description": "Header for edit identity item type" }, "editItemHeaderNote": { - "message": "Edit Note", + "message": "Editar Nota", "description": "Header for edit note item type" }, "editItemHeaderSshKey": { - "message": "Edit SSH key", + "message": "Editar clave SSH", "description": "Header for edit SSH key item type" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Editar Send de Texto", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Editar Send de Archivo", "description": "Header for edit file send" }, "viewItemHeaderLogin": { - "message": "View Login", + "message": "Ver Inicio de Sesión", "description": "Header for view login item type" }, "viewItemHeaderCard": { - "message": "View Card", + "message": "Ver Tarjeta", "description": "Header for view card item type" }, "viewItemHeaderIdentity": { - "message": "View Identity", + "message": "Ver Identidad", "description": "Header for view identity item type" }, "viewItemHeaderNote": { - "message": "View Note", + "message": "Ver Nota", "description": "Header for view note item type" }, "viewItemHeaderSshKey": { - "message": "View SSH key", + "message": "Ver clave SSH", "description": "Header for view SSH key item type" }, "passwordHistory": { @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Elemento eliminado de forma permanente" }, + "archivedItemRestored": { + "message": "Elemento archivado restaurado" + }, "restoreItem": { "message": "Restaurar elemento" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden no pedirá que se guarden los datos de acceso para estos dominios. Debe actualizar la página para que los cambios surtan efecto." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden no pedirá que se guarden los datos de acceso para estos dominios en todas las sesiones iniciadas. Debe actualizar la página para que los cambios surtan efecto." - }, "blockedDomainsDesc": { "message": "El autorrelleno y otras funcionalidades relacionadas no se ofrecerán para estos sitios web. Debe actualizar la página para que los cambios surtan efecto." }, @@ -2749,7 +2779,7 @@ "message": "Contraseñas de riesgo" }, "atRiskPasswordDescSingleOrg": { - "message": "$ORGANIZATION$ is requesting you change one password because it is at-risk.", + "message": "$ORGANIZATION$ te está pidiendo que cambies una contraseña porque está en riesgo.", "placeholders": { "organization": { "content": "$1", @@ -2758,7 +2788,7 @@ } }, "atRiskPasswordsDescSingleOrgPlural": { - "message": "$ORGANIZATION$ is requesting you change the $COUNT$ passwords because they are at-risk.", + "message": "$ORGANIZATION$ te está pidiendo que cambies las $COUNT$ contraseñas porque están en riesgo.", "placeholders": { "organization": { "content": "$1", @@ -3002,10 +3032,6 @@ "custom": { "message": "Personalizado" }, - "sendPasswordDescV3": { - "message": "Añade una contraseña opcional para que los destinatarios accedan a este Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Crear Envío nuevo", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Envío editado", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Para elegir un archivo, abra la extensión en la barra lateral (si es posible) o salga a una nueva ventana haciendo clic en este anouncio." - }, - "sendFirefoxFileWarning": { - "message": "Para elegir un archivo usando Firefox, abra la extensión en la barra lateral o salga a una nueva ventana haciendo clic en este anouncio." - }, - "sendSafariFileWarning": { - "message": "Para elegir un archivo usando Safari, salga a una nueva ventana haciendo clic en este anouncio." - }, "popOut": { "message": "Salir" }, - "sendFileCalloutHeader": { - "message": "Antes de empezar" - }, "expirationDateIsInvalid": { "message": "La fecha de caducidad proporcionada no es válida." }, @@ -3346,6 +3352,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" }, @@ -3697,7 +3709,7 @@ "message": "Dispositivo" }, "loginStatus": { - "message": "Login status" + "message": "Estado del inicio de sesión" }, "masterPasswordChanged": { "message": "Contraseña maestra guardada" @@ -3869,7 +3881,7 @@ "message": "Login request has already expired." }, "justNow": { - "message": "Just now" + "message": "Justo ahora" }, "requestedXMinutesAgo": { "message": "Requested $MINUTES$ minutes ago", @@ -4593,7 +4605,7 @@ "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "Opciones avanzadas", "description": "Advanced option placeholder for uri option component" }, "confirmContinueToBrowserSettingsTitle": { @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Más opciones - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Consola de administrador" }, + "admin": { + "message": "Administrador" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Disponible ahora" + }, "accountSecurity": { "message": "Seguridad de la cuenta" }, @@ -4818,7 +4866,7 @@ "message": "Phishing Blocker" }, "enablePhishingDetection": { - "message": "Phishing detection" + "message": "Detección de phishing" }, "enablePhishingDetectionDesc": { "message": "Display warning before accessing suspected phishing sites" @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Descargar Adjunto" + }, "downloadBitwarden": { "message": "Descargar Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "¿Autocompletar al cargar la página?" @@ -5317,10 +5365,10 @@ "message": "Ubicación del elemento" }, "fileSends": { - "message": "File Sends" + "message": "Sends de Archivo" }, "textSends": { - "message": "Text Sends" + "message": "Sends de Texto" }, "accountActions": { "message": "Acciones de cuenta" @@ -5380,7 +5428,7 @@ "message": "El tiempo de espera mínimo personalizado es de 1 minuto." }, "fileSavedToDevice": { - "message": "File saved to device. Manage from your device downloads." + "message": "Archivo guardado en el dispositivo. Administra desde las descargas de tu dispositivo." }, "showCharacterCount": { "message": "Mostrar número de caracteres" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extraancho" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "La contraseña introducida es incorrecta." }, @@ -5668,10 +5719,10 @@ "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Contraseña vulnerable." }, "changeNow": { - "message": "Change now" + "message": "Cambiar ahora" }, "missingWebsite": { "message": "Missing website" @@ -5728,27 +5779,27 @@ "message": "Phishing attempt detected" }, "phishingPageSummary": { - "message": "The site you are attempting to visit is a known malicious site and a security risk." + "message": "El sitio que intentas visitar es un sitio malicioso conocido y un riesgo de seguridad." }, "phishingPageCloseTabV2": { - "message": "Close this tab" + "message": "Cerrar esta pestaña" }, "phishingPageContinueV2": { - "message": "Continue to this site (not recommended)" + "message": "Continuar a este sitio (no recomendado)" }, "phishingPageExplanation1": { - "message": "This site was found in ", + "message": "Este sitio fue encontrado en ", "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." }, "phishingPageExplanation2": { - "message": ", an open-source list of known phishing sites used for stealing personal and sensitive information.", + "message": ", una lista de código abierto de sitios de phishing conocidos utilizados para robar información personal y sensible.", "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." }, "phishingPageLearnMore": { - "message": "Learn more about phishing detection" + "message": "Más información sobre la detección de phishing" }, "protectedBy": { - "message": "Protected by $PRODUCT$", + "message": "Protegido por $PRODUCT$", "placeholders": { "product": { "content": "$1", @@ -5757,19 +5808,19 @@ } }, "hasItemsVaultNudgeBodyOne": { - "message": "Autofill items for the current page" + "message": "Autocompletar elementos para la página actual" }, "hasItemsVaultNudgeBodyTwo": { - "message": "Favorite items for easy access" + "message": "Guarda en favoritos elementos para un fácil acceso" }, "hasItemsVaultNudgeBodyThree": { - "message": "Search your vault for something else" + "message": "Busca en tu caja fuerte alguna otra cosa" }, "newLoginNudgeTitle": { "message": "Ahorra tiempo con el autocompletado" }, "newLoginNudgeBodyOne": { - "message": "Include a", + "message": "Incluir un", "description": "This is in multiple parts to allow for bold text in the middle of the sentence.", "example": "Include a Website so this login appears as an autofill suggestion." }, @@ -5832,7 +5883,7 @@ "description": "Aria label for the body content of the generator nudge" }, "aboutThisSetting": { - "message": "About this setting" + "message": "Acerca de este ajuste" }, "permitCipherDetailsDescription": { "message": "Bitwarden will use saved login URIs to identify which icon or change password URL should be used to improve your experience. No information is collected or saved when you use this service." @@ -5845,13 +5896,13 @@ "description": "'WebAssembly' is a technical term and should not be translated." }, "showMore": { - "message": "Show more" + "message": "Mostrar más" }, "showLess": { - "message": "Show less" + "message": "Mostrar menos" }, "next": { - "message": "Next" + "message": "Siguiente" }, "moreBreadcrumbs": { "message": "More breadcrumbs", @@ -5864,83 +5915,86 @@ "message": "Great job securing your at-risk logins!" }, "upgradeNow": { - "message": "Upgrade now" + "message": "Actualizar ahora" }, "builtInAuthenticator": { - "message": "Built-in authenticator" + "message": "Auntenticador integrado" }, "secureFileStorage": { - "message": "Secure file storage" + "message": "Almacenamiento de archivo seguro" }, "emergencyAccess": { - "message": "Emergency access" + "message": "Acceso de emergencia" }, "breachMonitoring": { - "message": "Breach monitoring" + "message": "Monitoreo de brechas" }, "andMoreFeatures": { - "message": "And more!" + "message": "¡Y más!" }, "advancedOnlineSecurity": { - "message": "Advanced online security" + "message": "Seguridad en línea avanzada" }, "upgradeToPremium": { - "message": "Upgrade to Premium" + "message": "Actualizar a Premium" }, "unlockAdvancedSecurity": { - "message": "Unlock advanced security features" + "message": "Desbloquea características de seguridad avanzadas" }, "unlockAdvancedSecurityDesc": { - "message": "A Premium subscription gives you more tools to stay secure and in control" + "message": "Una suscripción Premium te ofrece más herramientas para mantenerte seguro y en control" }, "explorePremium": { - "message": "Explore Premium" + "message": "Explorar Premium" }, "loadingVault": { - "message": "Loading vault" + "message": "Cargando caja fuerte" }, "vaultLoaded": { - "message": "Vault loaded" + "message": "Caja fuerte cargada" }, "settingDisabledByPolicy": { - "message": "This setting is disabled by your organization's policy.", + "message": "Esta configuración está deshabilitada por la política de tu organización.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." }, "zipPostalCodeLabel": { - "message": "ZIP / Postal code" + "message": "ZIP / Código postal" }, "cardNumberLabel": { - "message": "Card number" + "message": "Número de tarjeta" + }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, "continueWithLogIn": { - "message": "Continue with log in" + "message": "Continuar con el inicio de sesión" }, "doNotContinue": { - "message": "Do not continue" + "message": "No continuar" }, "domain": { - "message": "Domain" + "message": "Dominio" }, "keyConnectorDomainTooltip": { "message": "This domain will store your account encryption keys, so make sure you trust it. If you're not sure, check with your admin." }, "verifyYourOrganization": { - "message": "Verify your organization to log in" + "message": "Verifica tu organización para iniciar sesión" }, "organizationVerified": { - "message": "Organization verified" + "message": "Organización verificada" }, "domainVerified": { - "message": "Domain verified" + "message": "Dominio verificado" }, "leaveOrganizationContent": { - "message": "If you don't verify your organization, your access to the organization will be revoked." + "message": "Si no verificas tu organización, tu acceso a la organización será revocado." }, "leaveNow": { - "message": "Leave now" + "message": "Salir ahora" }, "verifyYourDomainToLogin": { "message": "Verify your domain to log in" @@ -5955,7 +6009,7 @@ "message": "Timeout action" }, "sessionTimeoutSettingsManagedByOrganization": { - "message": "This setting is managed by your organization." + "message": "Esta configuración está administrada por tu organización." }, "sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes": { "message": "Your organization has set the maximum session timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).", @@ -5993,25 +6047,25 @@ } }, "sessionTimeoutOnRestart": { - "message": "On browser restart" + "message": "Al reiniciar el navegador" }, "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": { "message": "Set an unlock method to change your timeout action" }, "upgrade": { - "message": "Upgrade" + "message": "Actualizar" }, "leaveConfirmationDialogTitle": { - "message": "Are you sure you want to leave?" + "message": "¿Estás seguro de que quieres salir?" }, "leaveConfirmationDialogContentOne": { "message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features." }, "leaveConfirmationDialogContentTwo": { - "message": "Contact your admin to regain access." + "message": "Contacta con tu administrador para recuperar el acceso." }, "leaveConfirmationDialogConfirmButton": { - "message": "Leave $ORGANIZATION$", + "message": "Abandonar $ORGANIZATION$", "placeholders": { "organization": { "content": "$1", @@ -6023,7 +6077,7 @@ "message": "How do I manage my vault?" }, "transferItemsToOrganizationTitle": { - "message": "Transfer items to $ORGANIZATION$", + "message": "Transferir elementos a $ORGANIZATION$", "placeholders": { "organization": { "content": "$1", @@ -6041,15 +6095,46 @@ } }, "acceptTransfer": { - "message": "Accept transfer" + "message": "Aceptar transferencia" }, "declineAndLeave": { - "message": "Decline and leave" + "message": "Rechazar y salir" }, "whyAmISeeingThis": { - "message": "Why am I seeing this?" + "message": "¿Por qué veo esto?" + }, + "items": { + "message": "Elementos" + }, + "searchResults": { + "message": "Resultados de búsqueda" }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Quien puede ver" + }, + "specificPeople": { + "message": "Personas específicas" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Introduce varios correos electrónicos separándolos con una coma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Los individuos tendrán que introducir la contraseña para ver este Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 69fa7ef8bfc..e8efd12b1e2 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "Ei" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Parooli hügieen, konto seisukord ja andmelekete raportid aitavad hoidlat turvalisena hoida." }, @@ -2027,6 +2054,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefoninumber" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Kirje on jäädavalt kustutatud" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Taasta kirje" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Nendel domeenidel Bitwarden paroolide salvestamise valikut ei paku. Muudatuste jõustamiseks pead lehekülge värskendama." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Kohandatud" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Loo uus Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Muudetud", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Faili valimiseks ava rakendus külgribal (kui see on võimalik) või kasuta hüpikakent, klikkides sellel bänneril." - }, - "sendFirefoxFileWarning": { - "message": "Faili valimiseks läbi Firefoxi ava Bitwardeni rakendus Firefoxi külgribal või kasuta hüpikakent (klikkides sellel bänneril)." - }, - "sendSafariFileWarning": { - "message": "Faili valimiseks läbi Safari kasuta Bitwardeni hüpikakent (klikkides sellel bänneril)." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Enne alustamist" - }, "expirationDateIsInvalid": { "message": "Valitud aegumiskuupäev ei ole õige." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index d5aeb2ce295..e7fcd4998e0 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "Ez" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Pasahitzaren higienea, kontuaren egoera eta datu-bortxaketen txostenak, kutxa gotorra seguru mantentzeko." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Emaila" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefonoa" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Elementua betirako ezabatua" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Berreskuratu elementua" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwardenek ez du eskatuko domeinu horietarako saio-hasierako xehetasunak gordetzea. Orrialdea eguneratu behar duzu aldaketek eragina izan dezaten." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Pertsonalizatua" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Sortu Send berria", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send-a editatua", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Fitxategi bat aukeratzeko, ireki gehigarria alboko barran (ahal bada) edo atera leiho berri batera banner honetan klik eginez." - }, - "sendFirefoxFileWarning": { - "message": "Firefox erabiliz fitxategi bat aukeratzeko, ireki gehigarria alboko barratik edo ireki beste leiho bat banner hau sakatuz." - }, - "sendSafariFileWarning": { - "message": "Safari erabiliz fitxategi bat aukeratzeko, ireki beste leiho bat banner hau sakatuz." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Hasi aurretik" - }, "expirationDateIsInvalid": { "message": "Iraungitze data ez da baliozkoa." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 510b71de2ee..bca4ad20d52 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": "استفاده از ورود تک مرحله‌ای" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "خیر" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "موقعیت" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "گزینه‌های ورود اضافی دو مرحله‌ای مانند YubiKey و Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "گزارش‌های بهداشت کلمه عبور، سلامت حساب کاربری و نقض داده‌ها برای ایمن نگهداشتن گاوصندوق شما." }, @@ -2027,6 +2054,9 @@ "email": { "message": "ایمیل" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "تلفن" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "مورد برای همیشه حذف شد" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "بازیابی مورد" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden برای ذخیره جزئیات ورود به سیستم این دامنه‌ها سوال نمی‌کند. برای اینکه تغییرات اعمال شود باید صفحه را تازه کنید." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden برای هیچ یک از حساب‌های کاربری وارد شده، درخواست ذخیره اطلاعات ورود برای این دامنه‌ها را نخواهد داد. برای اعمال تغییرات باید صفحه را تازه‌سازی کنید." - }, "blockedDomainsDesc": { "message": "ویژگی‌های پر کردن خودکار و سایر قابلیت‌های مرتبط برای این وب‌سایت‌ها ارائه نخواهند شد. برای اعمال تغییرات باید صفحه را تازه‌سازی کنید." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "سفارشی" }, - "sendPasswordDescV3": { - "message": "یک کلمه عبور اختیاری برای دریافت‌کنندگان اضافه کنید تا بتوانند به این ارسال دسترسی داشته باشند.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "ارسال جدید", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "ارسال ذخیره شد", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "باز کردن پنجره جداگانه افزونه؟", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "برای ایجاد یک ارسال پرونده، باید افزونه را در پنجره‌ای جدید باز کنید.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "برای انتخاب پرونده، پسوند را در نوار کناری باز کنید (در صورت امکان) یا با کلیک بر روی این بنر پنجره جدیدی باز کنید." - }, - "sendFirefoxFileWarning": { - "message": "برای انتخاب یک پرونده با استفاده از فایرفاکس، افزونه را در نوار کناری باز کنید یا با کلیک بر روی این بنر پنجره جدیدی باز کنید." - }, - "sendSafariFileWarning": { - "message": "برای انتخاب پرونده‌ای با استفاده از سافاری، با کلیک روی این بنر پنجره جدیدی باز کنید." - }, "popOut": { "message": "باز کردن در پنجره جداگانه" }, - "sendFileCalloutHeader": { - "message": "قبل از اینکه شروع کنی" - }, "expirationDateIsInvalid": { "message": "تاریخ انقضاء ارائه شده معتبر نیست." }, @@ -3346,6 +3352,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": "خطای رمزگشایی" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "گزینه‌های بیشتر - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "کنسول مدیر" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "امنیت حساب کاربری" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "بارگیری Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "مخفی کردن شناسایی تطابق $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "پر کردن خودکار هنگام بارگذاری صفحه؟" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "خیلی عریض" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "کلمه عبور وارد شده اشتباه است." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 011c5fb9026..2997ed6c128 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Arkistoi kohde" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "En" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Sijainti" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Kaksivaiheisen kirjautumisen erikoisvaihtoehdot, kuten YubiKey ja Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Salasanahygienian, tilin terveyden ja tietovuotojen raportointitoiminnot pitävät holvisi turvassa." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Sähköposti" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Puhelinnumero" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Kohde poistettiin pysyvästi" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Palauta kohde" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden ei pyydä kirjautumistietojen tallennusta näille verkkotunnuksille. Päivitä sivu ottaaksesi muutokset käyttöön." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden ei pyydä kirjautumistietojen tallennusta näillä verkkotunnuksilla. Koskee kaikkia kirjautuneita tilejä. Ota muutokset käyttöön päivittämällä sivu." - }, "blockedDomainsDesc": { "message": "Näille sivustoille ei tarjota automaattista täyttöä eikä muita siihen liittyviä ominaisuuksia. Sinun on päivitettävä sivu, jotta muutokset tulevat voimaan." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Mukautettu" }, - "sendPasswordDescV3": { - "message": "Lisää valinnainen salasana vastaanottajille tähän Sendiin.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Uusi Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send tallennettiin", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Irrota laajennus?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Irrota laajennus uuteen ikkunaan luodaksesi tiedosto-Sendin.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Jotta voit valita tiedoston, avaa laajennus sivupalkkiin (jos mahdollista) tai erilliseen ikkunaan klikkaamalla tätä banneria." - }, - "sendFirefoxFileWarning": { - "message": "Jotta voit valita tiedoston käyttäen Firefoxia, avaa laajennus sivupalkkiin tai erilliseen ikkunaan klikkaamalla tätä banneria." - }, - "sendSafariFileWarning": { - "message": "Jotta voit valita tiedoston käyttäen Safaria, avaa laajennus erilliseen ikkunaan klikkaamalla tätä banneria." - }, "popOut": { "message": "Irrota" }, - "sendFileCalloutHeader": { - "message": "Ennen kuin aloitat" - }, "expirationDateIsInvalid": { "message": "Määritetty erääntymismisajankohta on virheellinen." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Lisää valintoja - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Hallintapaneelista" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Tilin suojaus" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Lataa Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Piilota vastaavuuden tunnistus $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Automaattitäytetäänkö sivun avautuessa?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Erittäin leveä" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Syöttämäsi salasana on virheellinen." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Kortin numero" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 260449921bd..11da450cc0f 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "Hindi" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Pagmamay-ari na dalawang hakbang na opsyon sa pag-log in gaya ng YubiKey at Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Pasahod higiyena, kalusugan ng account, at mga ulat sa data breach upang panatilihing ligtas ang iyong vault." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Mag-email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telepono" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanenteng tinanggal" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Ibalik ang item" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Hindi tatanungin ng Bitwarden na i-save ang mga detalye ng pag-login para sa mga domain na ito. Kailangan mo nang i-refresh ang page para maipatupad ang mga pagbabago." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Pasadyang" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Bagong Ipadala", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Ipadala na nai-save", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Upang pumili ng isang file, buksan ang extension sa sidebar (kung posible) o bumalik sa isang bagong window sa pamamagitan ng pag-click sa banner na ito." - }, - "sendFirefoxFileWarning": { - "message": "Upang pumili ng isang file gamit ang Firefox, buksan ang extension sa sidebar o bumalik sa isang bagong window sa pamamagitan ng pag-click sa banner na ito." - }, - "sendSafariFileWarning": { - "message": "Upang pumili ng isang file gamit ang Safari, bumalik sa isang bagong window sa pamamagitan ng pag-click sa banner na ito." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Bago ka magsimula" - }, "expirationDateIsInvalid": { "message": "Ang ibinigay na petsa ng pagpaso ay hindi wasto." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 0cd6abfee60..face33e0087 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "L'élément a été envoyé à l'archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "L'élément a été désarchivé" }, "archiveItem": { "message": "Archiver l'élément" }, - "archiveItemConfirmDesc": { - "message": "Les éléments archivés sont exclus des résultats de recherche généraux et des suggestions de remplissage automatique. Êtes-vous sûr de vouloir archiver cet élément ?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "Une adhésion premium est requise pour utiliser Archive." @@ -978,6 +990,12 @@ "no": { "message": "Non" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Emplacement" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Options de connexion propriétaires à deux facteurs telles que YubiKey et Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Hygiène du mot de passe, santé du compte et rapports sur les brèches de données pour assurer la sécurité de votre coffre." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Courriel" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Téléphone" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Élément définitivement supprimé" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restaurer l'élément" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden ne demandera pas d'enregistrer les détails de connexion pour ces domaines. Vous devez actualiser la page pour que les modifications prennent effet." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden ne demandera pas d'enregistrer les détails de connexion pour ces domaines pour tous les comptes connectés. Vous devez actualiser la page pour que les modifications prennent effet." - }, "blockedDomainsDesc": { "message": "La saisie automatique et d'autres fonctionnalités connexes ne seront pas proposées pour ces sites web. Vous devez actualiser la page pour que les modifications soient prises en compte." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Personnalisé" }, - "sendPasswordDescV3": { - "message": "Ajouter un mot de passe facultatif pour que les destinataires puissent accéder à ce Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Nouveau Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send sauvegardé", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Détacher l'extension ?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Pour créer un envoi de fichier Send, vous devez détacher l'extension dans une nouvelle fenêtre.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Pour choisir un fichier, ouvrez l'extension dans la barre latérale (si possible) ou ouvrez une nouvelle fenêtre en cliquant sur cette bannière." - }, - "sendFirefoxFileWarning": { - "message": "Afin de choisir un fichier en utilisant Firefox, ouvrez l'extension dans la barre latérale ou ouvrez une nouvelle fenêtre en cliquant sur cette bannière." - }, - "sendSafariFileWarning": { - "message": "Pour choisir un fichier avec Safari, ouvrez une nouvelle fenêtre en cliquant sur cette bannière." - }, "popOut": { "message": "Détacher" }, - "sendFileCalloutHeader": { - "message": "Avant de commencer" - }, "expirationDateIsInvalid": { "message": "La date d'expiration indiquée n'est pas valide." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Plus d'options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Console Admin" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Sécurité du compte" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Télécharger Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Masquer la détection de correspondance $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Saisir automatiquement lors du chargement de la page ?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Très large" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Le mot de passe saisi est incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Numéro de carte" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Votre organisation n'utilise plus les mots de passe principaux pour se connecter à Bitwarden. Pour continuer, vérifiez l'organisation et le domaine." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 642659da268..69ef54f78eb 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "Non" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Opcións de verificación en 2 pasos privadas tales coma YubiKey ou Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Limpeza de contrasinais, saúde de contas e informes de filtración de datos para manter a túa caixa forte segura." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Correo electrónico" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Teléfono" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Entrada eliminada permanente" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restaurar entrada" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden non ofrecerá gardar contas para estes dominios. Recarga a páxina para que os cambios fagan efecto." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden non ofrecerá gardar contas para estes dominios en ningunha das sesións iniciadas. Recarga a páxina para que os cambios fornezan efecto." - }, "blockedDomainsDesc": { "message": "O autoenchido e outras funcións relacionadas non estarán dispoñibles para estas webs. Debes recargar a páxina para que os cambios teñan efecto." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Personalizado" }, - "sendPasswordDescV3": { - "message": "Engade un contrasinal opcional para os destinatarios deste Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Novo Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send gardado", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Sacar a extensión?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Para crear un arquivo Send, necesitas sacar a extensión a unha nova ventá.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Para escoller un arquivo, abre a extensión na barra lateral (se é posible) ou sácaa a unha nova ventá premendo este botón." - }, - "sendFirefoxFileWarning": { - "message": "Para escoller un arquivo empregando Firefox, abre a extensión na barra lateral ou sácaa a unha nova ventá premendo este botón." - }, - "sendSafariFileWarning": { - "message": "Para escoller un arquivo empregando Safari, saca a extensión a unha nova ventá premendo este botón." - }, "popOut": { "message": "Sacar" }, - "sendFileCalloutHeader": { - "message": "Antes de comezar" - }, "expirationDateIsInvalid": { "message": "A data de vencemento non é válida." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Máis opcións - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Consola do administrador" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Seguridade da conta" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Agochar detección de coincidencia $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autoencher ó cargar a páxina?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Moi ancho" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index b59499f1d6d..22939259639 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": "השתמש בכניסה יחידה" }, @@ -196,11 +199,11 @@ "description": "Copy to clipboard" }, "fill": { - "message": "מילוי", + "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": { - "message": "מילוי אוטומטי" + "message": "השלמה אוטומטית" }, "autoFillLogin": { "message": "מילוי כניסה אוטומטי" @@ -288,7 +291,7 @@ "message": "קוד אימות" }, "confirmIdentity": { - "message": "יש לאשר את זהותך כדי להמשיך." + "message": "אשר את זהותך כדי להמשיך." }, "changeMasterPassword": { "message": "החלף סיסמה ראשית" @@ -437,7 +440,7 @@ "message": "סנכרן" }, "syncNow": { - "message": "Sync now" + "message": "סנכרון כעת" }, "lastSync": { "message": "סנכרון אחרון:" @@ -573,20 +576,29 @@ "itemWasSentToArchive": { "message": "הפריט נשלח לארכיון" }, + "itemWasUnarchived": { + "message": "הפריט שוחזר מהארכיב" + }, "itemUnarchived": { "message": "הפריט הוסר מהארכיון" }, "archiveItem": { "message": "העבר פריט לארכיון" }, - "archiveItemConfirmDesc": { - "message": "פריטים בארכיון מוחרגים מתוצאות חיפוש כללי והצעות למילוי אוטומטי. האם אתה בטוח שברצונך להעביר פריט זה לארכיון?" + "archiveItemDialogContent": { + "message": "עם ארכובו, יהיה הפריט מוחרג מתוצאות החיפוש ומהצעות המילוי האוטומטי." + }, + "archived": { + "message": "הועבר לארכיב" + }, + "unarchiveAndSave": { + "message": "שחזור מהארכיב ושמירה" }, "upgradeToUseArchive": { - "message": "A premium membership is required to use Archive." + "message": "נדרשת חברות פרמיום כדי להשתמש בארכיב." }, "itemRestored": { - "message": "Item has been restored" + "message": "הפריט שוחזר" }, "edit": { "message": "ערוך" @@ -598,7 +610,7 @@ "message": "הצג הכל" }, "showAll": { - "message": "Show all" + "message": "הצגת הכל" }, "viewLess": { "message": "הצג פחות" @@ -978,6 +990,12 @@ "no": { "message": "לא" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "מיקום" }, @@ -1326,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": { @@ -1420,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)" @@ -1471,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": "קובץ" @@ -1489,7 +1507,7 @@ "message": "בחר קובץ" }, "itemsTransferred": { - "message": "Items transferred" + "message": "הפריטים הועברו" }, "maxFileSize": { "message": "גודל הקובץ המרבי הוא 500MB." @@ -1522,7 +1540,7 @@ "message": "1 ג'יגה של מקום אחסון עבור קבצים מצורפים." }, "premiumSignUpStorageV2": { - "message": "$SIZE$ encrypted storage for file attachments.", + "message": "$SIZE$ של אחסון מוצפן עבור קבצים מצורפים.", "placeholders": { "size": { "content": "$1", @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "אפשרויות כניסה דו־שלבית קנייניות כגון YubiKey ו־Duo." }, + "premiumSubscriptionEnded": { + "message": "מנוי הפרמיום שלך הסתיים" + }, + "archivePremiumRestart": { + "message": "לשחזור הגישה לארכיב שלך יש לחדש את מנוי הפרמיום שלך. אם תבצעו עריכת פרטים של פריט בארכיב לפני חידוש המנוי, הפריט ישוחזר אל הכספת שלכם." + }, + "restartPremium": { + "message": "חידוש מנוי הפרמיום" + }, "ppremiumSignUpReports": { "message": "היגיינת סיסמאות, מצב בריאות החשבון, ודיווחים מעודכנים על פרצות חדשות בכדי לשמור על הכספת שלך בטוחה." }, @@ -1929,7 +1956,7 @@ "message": "שנת תפוגה" }, "monthly": { - "message": "month" + "message": "חודש" }, "expiration": { "message": "תוקף" @@ -2027,6 +2054,9 @@ "email": { "message": "אימייל" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "טלפון" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "הפריט נמחק לצמיתות" }, + "archivedItemRestored": { + "message": "פריט שוחזר מהארכיב" + }, "restoreItem": { "message": "שחזר פריט" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden לא יבקש לשמור פרטי כניסה עבור הדומיינים האלה. אתה מוכרח לרענן את העמוד כדי שהשינויים ייכנסו לתוקף." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden לא יבקש לשמור פרטי כניסה עבור הדומיינים האלה עבור כל החשבונות המחוברים. אתה מוכרח לרענן את העמוד כדי שהשינויים ייכנסו לתוקף." - }, "blockedDomainsDesc": { "message": "לא יוצעו מילוי אוטומטי ותכונות קשורות אחרות עבור האתרים האלה. אתה מוכרח לרענן את העמוד כדי שהשינויים ייכנסו לתוקף." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "מותאם אישית" }, - "sendPasswordDescV3": { - "message": "הוסף סיסמה אופציונלית עבור נמענים כדי לגשת לסֵנְד זה.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "סֵנְד חדש", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "סֵנְד נשמר", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "להקפיץ הרחבה?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "כדי ליצור קובץ סֵנְד, אתה צריך להקפיץ את ההרחבה לחלון חדש.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "כדי לבחור קובץ, פתח את ההרחבה בסרגל הצד (אם ניתן) או הקפץ לחלון חדש על ידי לחיצת באנר זה." - }, - "sendFirefoxFileWarning": { - "message": "כדי לבחור קובץ באמצעות Firefox, פתח את ההרחבה בסרגל הצד או הקפץ לחלון חדש על ידי לחיצת באנר זה." - }, - "sendSafariFileWarning": { - "message": "כדי לבחור קובץ באמצעות Safari, הקפץ לחלון חדש על ידי לחיצת באנר זה." - }, "popOut": { "message": "הקפץ" }, - "sendFileCalloutHeader": { - "message": "לפני שאתה מתחיל" - }, "expirationDateIsInvalid": { "message": "תאריך התפוגה שסופק אינו חוקי." }, @@ -3346,6 +3352,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": "שגיאת פענוח" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "עוד אפשרויות - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "מסוף מנהל" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "אבטחת החשבון" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "הורד את Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "הסתר זיהוי התאמה $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "למלא אוטומטית בעת טעינת עמוד?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "רחב במיוחד" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "הסיסמה שהזנת שגויה." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "מספר כרטיס" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 637c1943174..298f0312be7 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": "संस्करण" @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "नहीं" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "अपनी वॉल्ट को सुरक्षित रखने के लिए पासवर्ड स्वच्छता, खाता स्वास्थ्य और डेटा उल्लंघन रिपोर्ट।" }, @@ -2027,6 +2054,9 @@ "email": { "message": "ईमेल" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "फोन" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "स्थायी रूप से आइटम हटाएं" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "आइटम बहाल करें" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "बिटवर्डन इन डोमेन के लिए लॉगिन विवरण सहेजने के लिए नहीं कहेगा।परिवर्तनों को प्रभावी बनाने के लिए आपको पृष्ठ को ताज़ा करना होगा |" }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "कस्टम" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "नया सेंड बनाएं", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "सेंड एडिट किया गया", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "फ़ाइल चुनने के लिए, साइडबार (यदि संभव हो) में एक्सटेंशन खोलें या इस बैनर पर क्लिक करके एक नई विंडो को पॉप आउट करें।" - }, - "sendFirefoxFileWarning": { - "message": "फ़ायरफ़ॉक्स का उपयोग करके फ़ाइल चुनने के लिए, साइडबार में एक्सटेंशन खोलें या इस बैनर पर क्लिक करके एक नई विंडो को पॉप आउट करें।" - }, - "sendSafariFileWarning": { - "message": "सफारी का उपयोग करके फ़ाइल चुनने के लिए, इस बैनर पर क्लिक करके एक नई विंडो को पॉप आउट करें।" - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "शुरू करने से पहले" - }, "expirationDateIsInvalid": { "message": "प्रदान की गई समाप्ति तिथि मान्य नहीं है।" }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "अटैचमेंट डाउनलोड करें" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index bef42a97294..d7814a22da0 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)" }, @@ -437,7 +440,7 @@ "message": "Sinkronizacija" }, "syncNow": { - "message": "Sync now" + "message": "Sinkroniziraj" }, "lastSync": { "message": "Posljednja sinkronizacija:" @@ -573,20 +576,29 @@ "itemWasSentToArchive": { "message": "Stavka poslana u arhivu" }, + "itemWasUnarchived": { + "message": "Stavka vraćena iz arhive" + }, "itemUnarchived": { "message": "Stavka vraćena iz arhive" }, "archiveItem": { "message": "Arhiviraj stavku" }, - "archiveItemConfirmDesc": { - "message": "Arhivirane stavke biti će izuzete iz rezultata općih pretraga i preporuka auto-ispune. Sigurno želiš arhivirati?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Arhivirano" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, "itemRestored": { - "message": "Item has been restored" + "message": "Stavka je vraćena" }, "edit": { "message": "Uredi" @@ -598,7 +610,7 @@ "message": "Vidi sve" }, "showAll": { - "message": "Show all" + "message": "Prikaži sve" }, "viewLess": { "message": "Vidi manje" @@ -978,6 +990,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokacija" }, @@ -1326,19 +1344,19 @@ "message": "Izvezi iz" }, "exportVerb": { - "message": "Export", + "message": "Izvoz", "description": "The verb form of the word Export" }, "exportNoun": { - "message": "Export", + "message": "Izvoz", "description": "The noun form of the word Export" }, "importNoun": { - "message": "Import", + "message": "Uvoz", "description": "The noun form of the word Import" }, "importVerb": { - "message": "Import", + "message": "Uvoz", "description": "The verb form of the word Import" }, "fileFormat": { @@ -1420,25 +1438,25 @@ "message": "Saznaj više" }, "migrationsFailed": { - "message": "An error occurred updating the encryption settings." + "message": "Dogodila se greška pri ažuriranju postavki šifriranja." }, "updateEncryptionSettingsTitle": { - "message": "Update your encryption settings" + "message": "Ažuriraj svoje postakve šifriranja" }, "updateEncryptionSettingsDesc": { - "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + "message": "Nove preporučene postavke šifriranja poboljšat će sigurnost tvojeg računa. Za ažuriranje, unesi svoju glavnu lozinku." }, "confirmIdentityToContinue": { - "message": "Confirm your identity to continue" + "message": "Za nastavak, potvrdi svoj identitet" }, "enterYourMasterPassword": { - "message": "Enter your master password" + "message": "Unesi svoju glavnu lozinku" }, "updateSettings": { - "message": "Update settings" + "message": "Ažuriraj postavke" }, "later": { - "message": "Later" + "message": "Kasnije" }, "authenticatorKeyTotp": { "message": "Ključ autentifikatora (TOTP)" @@ -1477,7 +1495,7 @@ "message": "This file is using an outdated encryption method." }, "attachmentUpdated": { - "message": "Attachment updated" + "message": "Privitak ažuriran" }, "file": { "message": "Datoteka" @@ -1522,7 +1540,7 @@ "message": "1 GB šifriranog prostora za pohranu podataka." }, "premiumSignUpStorageV2": { - "message": "$SIZE$ encrypted storage for file attachments.", + "message": "$SIZE$ šifriranog prostora za privitke.", "placeholders": { "size": { "content": "$1", @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Mogućnosti za prijavu u dva koraka kao što su YubiKey i Duo." }, + "premiumSubscriptionEnded": { + "message": "Toja Premium pretplata je završila" + }, + "archivePremiumRestart": { + "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." + }, + "restartPremium": { + "message": "Ponovno Pokreni Premium" + }, "ppremiumSignUpReports": { "message": "Higijenu lozinki, zdravlje računa i izvještaje o krađi podatak radi zaštite svojeg trezora." }, @@ -1929,7 +1956,7 @@ "message": "Godina isteka" }, "monthly": { - "message": "month" + "message": "mjesečno" }, "expiration": { "message": "Istek" @@ -2027,6 +2054,9 @@ "email": { "message": "E-pošta" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Stavka trajno izbrisana" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Vrati stavku" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden neće pitati treba li spremiti prijavne podatke za ove domene. Za primjenu promjena, potrebno je osvježiti stranicu." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden neće nuditi spremanje podataka za prijavu za ove domene za sve prijavljene račune. Moraš osvježiti stranicu kako bi promjene stupile na snagu." - }, "blockedDomainsDesc": { "message": "Auto-ispuna i druge vezane značajke neće biti ponuđene za ova web mjesta. Potrebno je osvježiti stranicu zaprimjenu postavki." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Prilagođeno" }, - "sendPasswordDescV3": { - "message": "Dodaj opcionalnu lozinku za primatelje ovog Senda.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Stvori novi Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send spremljen", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Otvori proširenje?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Za stvaranje Senda, potrebno je otvoriti proširenje u novi prozor.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Za odabir datoteke, otvori proširenje u bočnoj traci (ako je moguće) ili u iskočnom prozoru klikom na ovu poruku." - }, - "sendFirefoxFileWarning": { - "message": "Za odabir datoteke u Firefoxu, otvori proširenje u bočnoj traci ili otvori iskočni prozor klikom na ovau poruku." - }, - "sendSafariFileWarning": { - "message": "Za odabir datoteke u Safariju, otvori iskočni prozor klikom na ovu poruku." - }, "popOut": { "message": "Otvori" }, - "sendFileCalloutHeader": { - "message": "Prije početka" - }, "expirationDateIsInvalid": { "message": "Navedeni rok isteka nije valjan." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Više mogućnosti - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Konzola administratora" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Dostupno sada" + }, "accountSecurity": { "message": "Sigurnost računa" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Preuzmi Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Sakrij otkrivanje podudaranja $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Auto-ispuna kod učitavanja?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Ekstra široko" }, + "narrow": { + "message": "Usko" + }, "sshKeyWrongPassword": { "message": "Unesena lozinka nije ispravna." }, @@ -5668,10 +5719,10 @@ "message": "Ova prijava je ugrožena i nedostaje joj web-stranica. Dodaj web-stranicu i promijeni lozinku za veću sigurnost." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Ranjiva lozinka." }, "changeNow": { - "message": "Change now" + "message": "Promijeni sada" }, "missingWebsite": { "message": "Nedostaje web-stranica" @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Broj kartice" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6041,15 +6095,46 @@ } }, "acceptTransfer": { - "message": "Accept transfer" + "message": "Prihvati prijenos" }, "declineAndLeave": { - "message": "Decline and leave" + "message": "Odbij i napusti" }, "whyAmISeeingThis": { - "message": "Why am I seeing this?" + "message": "Zašto ovo vidim?" + }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index 915f2241efd..ec4b2d405bf 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Az elem az archivumba került." }, + "itemWasUnarchived": { + "message": "Az elem visszavételre került az archivumból." + }, "itemUnarchived": { "message": "Az elemek visszavéelre kerültek az archivumból." }, "archiveItem": { "message": "Elem archiválása" }, - "archiveItemConfirmDesc": { - "message": "Az archivált elemek ki vannak zárva az általános keresési eredményekből és az automatikus kitöltési javaslatokból. Biztosan archiválni szeretnénk ezt az elemet?" + "archiveItemDialogContent": { + "message": "Az archiválás után ez az elem kizárásra kerül a keresési eredményekből és az automatikus kitöltési javaslatokból." + }, + "archived": { + "message": "Archiválva" + }, + "unarchiveAndSave": { + "message": "Archiválás visszavonása és mentés" }, "upgradeToUseArchive": { "message": "Az Archívum használatához prémium tagság szükséges." @@ -978,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" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Saját kétlépcsős bejelentkezési lehetőségek mint a YubiKey és a Duo." }, + "premiumSubscriptionEnded": { + "message": "A Prémium előfizetés véget ért." + }, + "archivePremiumRestart": { + "message": "Az archívumhoz hozzáférés visszaszerzéséhez indítsuk újra a Prémium előfizetést. Ha az újraindítás előtt szerkesztjük egy archivált elem adatait, akkor az visszakerül a széfbe." + }, + "restartPremium": { + "message": "Prémium előfizetés újraindítása" + }, "ppremiumSignUpReports": { "message": "Jelszó higiénia, fiók biztonság és adatszivárgási jelentések a széf biztonsága érdekében." }, @@ -2027,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Email címek" + }, "phone": { "message": "Telefonszám" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Véglegesen törölt elem" }, + "archivedItemRestored": { + "message": "Az archivált elem visszaállításra került." + }, "restoreItem": { "message": "Elem visszaállítása" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "A Bitwarden nem fogja kérni a domainek bejelentkezési adatainak mentését. A változások életbe lépéséhez frissíteni kell az oldalt." }, - "excludedDomainsDescAlt": { - "message": "A Bitwarden nem kéri a bejelentkezési adatok mentését ezeknél a tartományoknál az összes bejelentkezési fiókra vonatkozva. A változtatások életbe lépéséhez frissíteni kell az oldalt." - }, "blockedDomainsDesc": { "message": "Az automatikus kitöltés és az egyéb kapcsolódó funkciók ezeken a webhelyeken nincsenek a kínálatban. A változtatások életbe lépéséhez frissíteni kell az oldalt." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Egyedi" }, - "sendPasswordDescV3": { - "message": "Adjunk meg egy opcionális jelszót a címzetteknek a Send eléréséhez.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Új Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "A Send mentésre került.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Bővítmény átthelyezése?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "A Send fájl létrehozásához át kell helyezni a bővítményt egy új ablakba.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "A fájl kiválasztásához nyissuk meg a kiterjesztést az oldalsávon (ha lehetséges) vagy kattintsunk erre a sávra új ablak felbukkanásához." - }, - "sendFirefoxFileWarning": { - "message": "Firefox esetén nyissuk meg a bővítményt az oldalsávon vafy erre a hirdetőtáblára kattintva új felbukkanó ablak nyílik meg." - }, - "sendSafariFileWarning": { - "message": "A fájl kiválasztásához Safariban kattintsunk erre a hirdetőtáblára kattintva új ablak nyílik meg." - }, "popOut": { "message": "Áthelyezés" }, - "sendFileCalloutHeader": { - "message": "Mielőtt belevágnánk" - }, "expirationDateIsInvalid": { "message": "A megadott lejárati idő nem érvényes." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "További opciók" + }, "moreOptionsTitle": { "message": "További opciók - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Adminisztrátori konzol" }, + "admin": { + "message": "Adminisztrátor" + }, + "automaticUserConfirmation": { + "message": "Automatikus felhasználói megerősítés" + }, + "automaticUserConfirmationHint": { + "message": "A függőben lévő felhasználók automatikus megerősítése az eszköz zárolásának feloldásakor." + }, + "autoConfirmOnboardingCallout": { + "message": "Idő megtakarítás az automatikus felhasználói megerősítéssel" + }, + "autoConfirmWarning": { + "message": "Ez hatással lehet a szervezet adatbiztonságára." + }, + "autoConfirmWarningLink": { + "message": "További információ a kockázatokról" + }, + "autoConfirmSetup": { + "message": "Új felhasználók automatikus megerősítése" + }, + "autoConfirmSetupDesc": { + "message": "Az új felhasználók automatikusan megerősítésre kerülnek, amíg ez az eszköz fel van oldva." + }, + "autoConfirmSetupHint": { + "message": "Melyek a lehetséges biztonsági kockázatok?" + }, + "autoConfirmEnabled": { + "message": "Az automatikus megerősítés bekapcsolásra került." + }, + "availableNow": { + "message": "Elérhető most" + }, "accountSecurity": { "message": "Fiókbiztonság" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Melléklet letöltése" + }, "downloadBitwarden": { "message": "Bitwarden letöltése" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "$WEBSITE$ egyező érzékelés elrejtése", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Egyezés felismerés megjelenítése" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Egyezés felismerés elrejtése" }, "autoFillOnPageLoad": { "message": "Automatikus kitöltés oldalbetöltésnél?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra széles" }, + "narrow": { + "message": "Keskeny" + }, "sshKeyWrongPassword": { "message": "A megadott jelszó helytelen." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Kártya szám" }, + "errorCannotDecrypt": { + "message": "Hiba: nem fejthető vissza." + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "A szervezet már nem használ mesterjelszavakat a Bitwardenbe bejelentkezéshez. A folytatáshoz ellenőrizzük a szervezetet és a tartományt." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Miért látható ez?" }, + "items": { + "message": "Elemek" + }, + "searchResults": { + "message": "Keresési eredmények" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Bitwarden alkalmazások letöltése" + }, + "emailProtected": { + "message": "Védett email cím" + }, + "sendPasswordHelperText": { + "message": "A személyeknek meg kell adniuk a jelszót a Send elem megtekintéséhez.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 643b72125a2..f364b2f7540 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,36 +554,45 @@ "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" }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { - "message": "Archive item" + "message": "Arsipkan butir" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "Tidak" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokasi" }, @@ -1468,7 +1486,7 @@ "message": "Tidak ada lampiran." }, "attachmentSaved": { - "message": "Lampiran telah disimpan." + "message": "Lampiran disimpan" }, "fixEncryption": { "message": "Fix encryption" @@ -1486,7 +1504,7 @@ "message": "Berkas untuk dibagikan" }, "selectFile": { - "message": "Pilih berkas." + "message": "Pilih berkas" }, "itemsTransferred": { "message": "Items transferred" @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Pilihan masuk dua-langkah yang dipatenkan seperti YubiKey dan Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Kebersihan kata sandi, kesehatan akun, dan laporan kebocoran data untuk tetap menjaga keamanan brankas Anda." }, @@ -1625,7 +1652,7 @@ "message": "Buka dalam tab baru" }, "webAuthnAuthenticate": { - "message": "Autentikasi dengan WebAuthn." + "message": "Autentikasikan WebAuthn" }, "readSecurityKey": { "message": "Baca kunci keamanan" @@ -1734,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", @@ -1834,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." @@ -1864,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" @@ -1929,7 +1956,7 @@ "message": "Tahun Kedaluwarsa" }, "monthly": { - "message": "month" + "message": "bulan" }, "expiration": { "message": "Masa Berlaku" @@ -2027,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telepon" }, @@ -2079,51 +2109,51 @@ "message": "Catatan" }, "newItemHeaderLogin": { - "message": "New Login", + "message": "Log Masuk Baru", "description": "Header for new login item type" }, "newItemHeaderCard": { - "message": "New Card", + "message": "Kartu Baru", "description": "Header for new card item type" }, "newItemHeaderIdentity": { - "message": "New Identity", + "message": "Identitas Baru", "description": "Header for new identity item type" }, "newItemHeaderNote": { - "message": "New Note", + "message": "Catatan Baru", "description": "Header for new note item type" }, "newItemHeaderSshKey": { - "message": "New SSH key", + "message": "Kunci SSH Baru", "description": "Header for new SSH key item type" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Kirim Teks Baru", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Kirim Berkas Baru", "description": "Header for new file send" }, "editItemHeaderLogin": { - "message": "Edit Login", + "message": "Sunting Log Masuk", "description": "Header for edit login item type" }, "editItemHeaderCard": { - "message": "Edit Card", + "message": "Sunting Kartu", "description": "Header for edit card item type" }, "editItemHeaderIdentity": { - "message": "Edit Identity", + "message": "Sunting Identitas", "description": "Header for edit identity item type" }, "editItemHeaderNote": { - "message": "Edit Note", + "message": "Sunting Catatan", "description": "Header for edit note item type" }, "editItemHeaderSshKey": { - "message": "Edit SSH key", + "message": "Sunting kunci SSH", "description": "Header for edit SSH key item type" }, "editItemHeaderTextSend": { @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Hapus Item Secara Permanen" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Pulihkan Item" }, @@ -2480,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?" @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden tidak akan meminta untuk menyimpan detail login untuk domain ini. Anda harus menyegarkan halaman agar perubahan diterapkan." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden tidak akan meminta untuk menyimpan rincian login untuk domain tersebut. Anda harus menyegarkan halaman agar perubahan diterapkan." - }, "blockedDomainsDesc": { "message": "Isi otomatis dan fitur terkait lain tidak akan ditawarkan bagi situs-situs web ini. Anda mesti menyegarkan halaman agar perubahan berdampak." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Kustom" }, - "sendPasswordDescV3": { - "message": "Tambahkan kata sandi tidak wajib untuk penerima untuk mengakses Send ini.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Buat Send Baru", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send diedit", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Sembulkan ekstensi?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Untuk membuat sebuah berkas Send, Anda perlu menyembulkan ekstensi ke sebuah jendela baru.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Untuk memilih file ini, buka Extension di sidebar (jika memungkinkan) atau keluarkan menjadi window baru dengan menekan gambar ini." - }, - "sendFirefoxFileWarning": { - "message": "Untuk memilih file menggunakan Firefox, buka ekstensi di sidebar atau keluar ke jendela baru dengan mengklik banner ini." - }, - "sendSafariFileWarning": { - "message": "Untuk memilih file menggunakan Safari, keluar ke jendela baru dengan mengklik spanduk ini." - }, "popOut": { "message": "Sembulkan" }, - "sendFileCalloutHeader": { - "message": "Sebelum kamu memulai" - }, "expirationDateIsInvalid": { "message": "Tanggal kedaluwarsa yang diberikan tidak valid." }, @@ -3186,7 +3192,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", @@ -3278,7 +3284,7 @@ "message": "Hapus Kata Sandi Utama" }, "removedMasterPassword": { - "message": "Sandi utama dihapus." + "message": "Sandi utama dihapus" }, "leaveOrganizationConfirmation": { "message": "Apakah Anda yakin ingin meninggalkan organisasi ini?" @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Pilihan lainnya - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Konsol Admin" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Keamanan akun" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Unduh Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Sembunyikan deteksi kecocokan $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Isi otomatis ketika halaman dimuat?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Ekstra lebar" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Kata sandi yang Anda masukkan tidak benar." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -5943,19 +5997,19 @@ "message": "Leave now" }, "verifyYourDomainToLogin": { - "message": "Verify your domain to log in" + "message": "Verifikasikan domain Anda untuk log masuk" }, "verifyYourDomainDescription": { - "message": "To continue with log in, verify this domain." + "message": "Untuk melanjutkan log masuk, verifikasikan domain ini." }, "confirmKeyConnectorOrganizationUserDescription": { - "message": "To continue with log in, verify the organization and domain." + "message": "Untuk melanjutkan log masuk, verifikasikan organisasi dan domain." }, "sessionTimeoutSettingsAction": { - "message": "Timeout action" + "message": "Tindakan saat habis waktu" }, "sessionTimeoutSettingsManagedByOrganization": { - "message": "This setting is managed by your organization." + "message": "Pengaturan ini dikelola oleh organisasi Anda." }, "sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes": { "message": "Your organization has set the maximum session timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).", @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index a74b4aa4757..9c4ce6a0369 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": "Sblocca con passkey" + }, "useSingleSignOn": { "message": "Usa il Single Sign-On" }, @@ -573,20 +576,29 @@ "itemWasSentToArchive": { "message": "Elemento archiviato" }, + "itemWasUnarchived": { + "message": "Elemento rimosso dall'archivio" + }, "itemUnarchived": { "message": "Elemento rimosso dall'archivio" }, "archiveItem": { "message": "Archivia elemento" }, - "archiveItemConfirmDesc": { - "message": "Gli elementi archiviati sono esclusi dai risultati di ricerca e suggerimenti di autoriempimento. Vuoi davvero archiviare questo elemento?" + "archiveItemDialogContent": { + "message": "Una volta archiviato, questo elemento sarà escluso dai risultati di ricerca e dai suggerimenti del completamento automatico." + }, + "archived": { + "message": "Archiviato" + }, + "unarchiveAndSave": { + "message": "Togli dall'archivio e salva" }, "upgradeToUseArchive": { "message": "Per utilizzare Archivio è necessario un abbonamento premium." }, "itemRestored": { - "message": "Item has been restored" + "message": "L'elemento è stato ripristinato" }, "edit": { "message": "Modifica" @@ -978,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Chiunque abbia il link" + }, + "anyOneWithPassword": { + "message": "Chiunque abbia una password impostata da te" + }, "location": { "message": "Luogo" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Opzioni di verifica in due passaggi proprietarie come YubiKey e Duo." }, + "premiumSubscriptionEnded": { + "message": "Il tuo abbonamento Premium è terminato" + }, + "archivePremiumRestart": { + "message": "Per recuperare l'accesso al tuo archivio, riavvia il tuo abbonamento Premium. Se modifichi i dettagli di un elemento archiviato prima del riavvio, sarà spostato nella tua cassaforte." + }, + "restartPremium": { + "message": "Riavvia Premium" + }, "ppremiumSignUpReports": { "message": "Sicurezza delle password, integrità dell'account, e rapporti su violazioni di dati per mantenere sicura la tua cassaforte." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Indirizzi email" + }, "phone": { "message": "Telefono" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Elemento eliminato definitivamente" }, + "archivedItemRestored": { + "message": "Elemento estratto dall'archivio" + }, "restoreItem": { "message": "Ripristina elemento" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden non ti chiederà di aggiungere nuovi login per questi domini. Ricorda di ricaricare la pagina perché le modifiche abbiano effetto." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden non chiederà di salvare le credenziali di accesso per questi domini per tutti gli account sul dispositivo. Ricarica la pagina affinché le modifiche abbiano effetto." - }, "blockedDomainsDesc": { "message": "Per questi siti, riempimento automatico e funzionalità simili non saranno disponibili. Ricarica la pagina per applicare le modifiche." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Personalizzato" }, - "sendPasswordDescV3": { - "message": "Richiedi ai destinatari una password opzionale per aprire questo Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Nuovo Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send salvato", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Scollegare estensione?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Per creare un file Send, devi scollegare l'estensione in una nuova finestra.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Per scegliere un file, apri l'estensione nella barra laterale (se possibile) o apri una nuova finestra cliccando questo banner." - }, - "sendFirefoxFileWarning": { - "message": "Per scegliere un file usando Firefox, apri l'estensione nella barra laterale o apri una nuova finestra cliccando questo banner." - }, - "sendSafariFileWarning": { - "message": "Per scegliere un file usando Safari, apri una nuova finestra cliccando questo banner." - }, "popOut": { "message": "Scollega" }, - "sendFileCalloutHeader": { - "message": "Prima di iniziare" - }, "expirationDateIsInvalid": { "message": "La data di scadenza fornita non è valida." }, @@ -3346,6 +3352,12 @@ "error": { "message": "Errore" }, + "prfUnlockFailed": { + "message": "Impossibile sbloccare con passkey. Riprova o utilizza un altro metodo." + }, + "noPrfCredentialsAvailable": { + "message": "Non ci sono password abilitate con PRF per lo sblocco. Accedi prima con una passkey." + }, "decryptionError": { "message": "Errore di decifrazione" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Altre opzioni" + }, "moreOptionsTitle": { "message": "Più opzioni - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Console di amministrazione" }, + "admin": { + "message": "Amministratore" + }, + "automaticUserConfirmation": { + "message": "Conferma automatica degli utenti" + }, + "automaticUserConfirmationHint": { + "message": "Conferma automaticamente gli utenti in sospeso mentre il dispositivo è sbloccato" + }, + "autoConfirmOnboardingCallout": { + "message": "Risparmia tempo con la conferma automatica degli utenti" + }, + "autoConfirmWarning": { + "message": "Potrebbe influenzare la sicurezza dei dati della tua organizzazione. " + }, + "autoConfirmWarningLink": { + "message": "Scopri quali sono i rischi" + }, + "autoConfirmSetup": { + "message": "Conferma automaticamente i nuovi utenti" + }, + "autoConfirmSetupDesc": { + "message": "I nuovi utenti saranno automaticamente confermati mentre questo dispositivo è sbloccato." + }, + "autoConfirmSetupHint": { + "message": "Quali sono i rischi potenziali per la sicurezza?" + }, + "autoConfirmEnabled": { + "message": "Conferma automatica attivata" + }, + "availableNow": { + "message": "Disponibile ora" + }, "accountSecurity": { "message": "Sicurezza dell'account" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Scarica allegato" + }, "downloadBitwarden": { "message": "Scarica Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Nascondi corrispondenza $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Mostra il criterio di corrispondenza dell'URL" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Nascondi il criterio di corrispondenza dell'URL" }, "autoFillOnPageLoad": { "message": "Riempi automaticamente al caricamento della pagina?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Molto larga" }, + "narrow": { + "message": "Stretto" + }, "sshKeyWrongPassword": { "message": "La parola d'accesso inserita non è corretta." }, @@ -5668,10 +5719,10 @@ "message": "Questo login è a rischio e non contiene un sito web. Aggiungi un sito web e cambia la password per maggiore sicurezza." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Password vulnerabile." }, "changeNow": { - "message": "Change now" + "message": "Cambiala subito!" }, "missingWebsite": { "message": "Sito web mancante" @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Numero di carta" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "La tua organizzazione non utilizza più le password principali per accedere a Bitwarden. Per continuare, verifica l'organizzazione e il dominio." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Perché vedo questo avviso?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "resizeSideNavigation": { - "message": "Resize side navigation" + "message": "Ridimensiona la navigazione laterale" + }, + "whoCanView": { + "message": "Chi può visualizzare" + }, + "specificPeople": { + "message": "Persone specifiche" + }, + "emailVerificationDesc": { + "message": "I destinatari dovranno verificare il loro indirizzo email con un codice per poter visualizzare il Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Inserisci più indirizzi email separandoli con virgole." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 8233240d728..c8a963fc744 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": "シングルサインオンを使用する" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "アイテムはアーカイブに送信されました" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "アイテムはアーカイブから解除されました" }, "archiveItem": { "message": "アイテムをアーカイブ" }, - "archiveItemConfirmDesc": { - "message": "アーカイブされたアイテムはここに表示され、通常の検索結果および自動入力の候補から除外されます。このアイテムをアーカイブしますか?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "アーカイブを使用するにはプレミアムメンバーシップが必要です。" @@ -978,6 +990,12 @@ "no": { "message": "いいえ" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "場所" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "YubiKey、Duo などのプロプライエタリな2段階認証オプション。" }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "保管庫を安全に保つための、パスワードやアカウントの健全性、データ侵害に関するレポート" }, @@ -2027,6 +2054,9 @@ "email": { "message": "メールアドレス" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "電話番号" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "完全に削除されたアイテム" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "アイテムをリストア" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden はこれらのドメインのログイン情報を保存するよう尋ねません。変更を有効にするにはページを更新する必要があります。" }, - "excludedDomainsDescAlt": { - "message": "Bitwarden はログインしているすべてのアカウントで、これらのドメインのログイン情報を保存するよう要求しません。 変更を有効にするにはページを更新する必要があります。" - }, "blockedDomainsDesc": { "message": "自動入力やその他の関連機能はこれらのウェブサイトには提供されません。変更を反映するにはページを更新する必要があります。" }, @@ -3002,10 +3032,6 @@ "custom": { "message": "カスタム" }, - "sendPasswordDescV3": { - "message": "受信者がこの Send にアクセスするための任意のパスワードを追加します。", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "新しい Send を作成", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "編集済みの Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "拡張機能をポップアウトしますか?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "ファイル Send を作成するには、拡張機能を新しいウィンドウでポップアウト表示する必要があります。", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "ファイルを選択するには、可能な場合サイドバーで拡張子を開くか、このバナーをクリックして新しいウィンドウにポップアップしてください。" - }, - "sendFirefoxFileWarning": { - "message": "Firefox を使用してファイルを選択するには、サイドバーで開くか、このバナーをクリックして新しいウィンドウで開いてください。" - }, - "sendSafariFileWarning": { - "message": "Safari を使用してファイルを選択するには、このバナーをクリックして新しいウィンドウで開いてください。" - }, "popOut": { "message": "ポップアウト" }, - "sendFileCalloutHeader": { - "message": "はじめる前に" - }, "expirationDateIsInvalid": { "message": "入力された有効期限は正しくありません。" }, @@ -3346,6 +3352,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": "復号エラー" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "その他のオプション - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "管理コンソール" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "アカウントのセキュリティ" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Bitwarden をダウンロード" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "一致検出 $WEBSITE$を非表示", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "ページ読み込み時に自動入力する" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "エクストラワイド" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "入力されたパスワードが間違っています。" }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "カード番号" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index 7b79332d906..cb6129ed2bb 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "არა" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -2027,6 +2054,9 @@ "email": { "message": "ელ-ფოსტა" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ტელეფონი" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "განსხვავებული" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." - }, - "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." - }, - "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." - }, "popOut": { "message": "გატანა" }, - "sendFileCalloutHeader": { - "message": "Before you start" - }, "expirationDateIsInvalid": { "message": "The expiration date provided is not valid." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "ანგარიშის უსაფრთხოება" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index ea4f2b08a85..336e8783b75 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Custom" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." - }, - "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." - }, - "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Before you start" - }, "expirationDateIsInvalid": { "message": "The expiration date provided is not valid." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 6b5c8251bf1..e97ce2a95a4 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "ಇಲ್ಲ" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "ನಿಮ್ಮ ವಾಲ್ಟ್ ಅನ್ನು ಸುರಕ್ಷಿತವಾಗಿರಿಸಲು ಪಾಸ್ವರ್ಡ್ ನೈರ್ಮಲ್ಯ, ಖಾತೆ ಆರೋಗ್ಯ ಮತ್ತು ಡೇಟಾ ಉಲ್ಲಂಘನೆ ವರದಿಗಳು." }, @@ -2027,6 +2054,9 @@ "email": { "message": "ಇಮೇಲ್" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ಫೋನ್‌" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "ಶಾಶ್ವತವಾಗಿ ಅಳಿಸಲಾದ ಐಟಂ" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "ಐಟಂ ಅನ್ನು ಮರುಸ್ಥಾಪಿಸಿ" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "ಬಿಟ್ವಾರ್ಡ್ ಈ ಡೊಮೇನ್ಗಳಿಗಾಗಿ ಲಾಗಿನ್ ವಿವರಗಳನ್ನು ಉಳಿಸಲು ಕೇಳುವುದಿಲ್ಲ. ಬದಲಾವಣೆಗಳನ್ನು ಜಾರಿಗೆ ತರಲು ನೀವು ಪುಟವನ್ನು ರಿಫ್ರೆಶ್ ಮಾಡಬೇಕು." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "ಕಸ್ಟಮ್" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "ಹೊಸ ಕಳುಹಿಸುವಿಕೆಯನ್ನು ರಚಿಸಿ", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "ಕಳುಹಿಸಿದ ಸಂಪಾದನೆ", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "ಫೈಲ್ ಅನ್ನು ಆಯ್ಕೆ ಮಾಡಲು, ಸೈಡ್‌ಬಾರ್‌ನಲ್ಲಿ ವಿಸ್ತರಣೆಯನ್ನು ತೆರೆಯಿರಿ (ಸಾಧ್ಯವಾದರೆ) ಅಥವಾ ಈ ಬ್ಯಾನರ್ ಕ್ಲಿಕ್ ಮಾಡುವ ಮೂಲಕ ಹೊಸ ವಿಂಡೋಗೆ ಪಾಪ್ಔಟ್ ಮಾಡಿ." - }, - "sendFirefoxFileWarning": { - "message": "ಫೈರ್‌ಫಾಕ್ಸ್ ಬಳಸಿ ಫೈಲ್ ಆಯ್ಕೆ ಮಾಡಲು, ಸೈಡ್‌ಬಾರ್‌ನಲ್ಲಿ ವಿಸ್ತರಣೆಯನ್ನು ತೆರೆಯಿರಿ ಅಥವಾ ಈ ಬ್ಯಾನರ್ ಕ್ಲಿಕ್ ಮಾಡುವ ಮೂಲಕ ಹೊಸ ವಿಂಡೋಗೆ ಪಾಪ್ಔಟ್ ಮಾಡಿ." - }, - "sendSafariFileWarning": { - "message": "ಸಫಾರಿ ಬಳಸಿ ಫೈಲ್ ಆಯ್ಕೆ ಮಾಡಲು, ಈ ಬ್ಯಾನರ್ ಕ್ಲಿಕ್ ಮಾಡುವ ಮೂಲಕ ಹೊಸ ವಿಂಡೋಗೆ ಪಾಪ್ಔಟ್ ಮಾಡಿ." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "ನೀವು ಪ್ರಾರಂಭಿಸುವ ಮೊದಲು" - }, "expirationDateIsInvalid": { "message": "ಒದಗಿಸಿದ ಮುಕ್ತಾಯ ದಿನಾಂಕವು ಮಾನ್ಯವಾಗಿಲ್ಲ." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index 8841a307e5a..9f570d62abb 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -28,11 +28,14 @@ "logInWithPasskey": { "message": "패스키를 사용하여 로그인하기" }, + "unlockWithPasskey": { + "message": "패스키로 잠금 해제" + }, "useSingleSignOn": { "message": "통합인증(SSO) 사용하기" }, "yourOrganizationRequiresSingleSignOn": { - "message": "Your organization requires single sign-on." + "message": "귀하의 조직은 단일 로그인(SSO)이 필요합니다." }, "welcomeBack": { "message": "돌아온 것을 환영합니다." @@ -87,7 +90,7 @@ "message": "마스터 비밀번호 힌트 (선택)" }, "passwordStrengthScore": { - "message": "Password strength score $SCORE$", + "message": "비밀번호 강도 점수 $SCORE$", "placeholders": { "score": { "content": "$1", @@ -437,7 +440,7 @@ "message": "동기화" }, "syncNow": { - "message": "Sync now" + "message": "지금 동기화" }, "lastSync": { "message": "마지막 동기화:" @@ -573,20 +576,29 @@ "itemWasSentToArchive": { "message": "항목이 보관함으로 이동되었습니다" }, + "itemWasUnarchived": { + "message": "항목이 보관 해제되었습니다" + }, "itemUnarchived": { "message": "항목 보관 해제됨" }, "archiveItem": { "message": "항목 보관" }, - "archiveItemConfirmDesc": { - "message": "보관된 항목은 일반 검색 결과와 자동 완성 제안에서 제외됩니다. 이 항목을 보관하시겠습니까?" + "archiveItemDialogContent": { + "message": "보관하면 이 항목은 검색 결과 및 자동 완성 제안에서 제외됩니다." + }, + "archived": { + "message": "보관됨" + }, + "unarchiveAndSave": { + "message": "보관 해제하고 저장" }, "upgradeToUseArchive": { - "message": "A premium membership is required to use Archive." + "message": "보관 기능을 사용하려면 프리미엄 멤버십이 필요합니다." }, "itemRestored": { - "message": "Item has been restored" + "message": "항목이 복원되었습니다." }, "edit": { "message": "편집" @@ -595,13 +607,13 @@ "message": "보기" }, "viewAll": { - "message": "View all" + "message": "모두 보기" }, "showAll": { - "message": "Show all" + "message": "모두 보기" }, "viewLess": { - "message": "View less" + "message": "접기" }, "viewLogin": { "message": "로그인 보기" @@ -712,10 +724,10 @@ "message": "신원을 인증하세요" }, "weDontRecognizeThisDevice": { - "message": "We don't recognize this device. Enter the code sent to your email to verify your identity." + "message": "이 기기를 인식할 수 없습니다. 신원을 확인하려면 이메일로 전송된 코드를 입력하세요." }, "continueLoggingIn": { - "message": "Continue logging in" + "message": "로그인 계속하기" }, "yourVaultIsLocked": { "message": "보관함이 잠겨 있습니다. 마스터 비밀번호를 입력하여 계속하세요." @@ -749,7 +761,7 @@ "message": "잘못된 마스터 비밀번호" }, "invalidMasterPasswordConfirmEmailAndHost": { - "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "message": "마스터 비밀번호가 올바르지 않습니다. 이메일 주소가 정확한지, 그리고 계정이 $HOST$에서 생성되었는지 확인하세요.", "placeholders": { "host": { "content": "$1", @@ -806,10 +818,10 @@ "message": "시스템 잠금 시" }, "onIdle": { - "message": "On system idle" + "message": "시스템이 유휴 상태일 때" }, "onSleep": { - "message": "On system sleep" + "message": "시스템이 절전 모드로 전환될 때" }, "onRestart": { "message": "브라우저 재시작 시" @@ -940,22 +952,22 @@ "message": "Bitwarden에 로그인" }, "enterTheCodeSentToYourEmail": { - "message": "Enter the code sent to your email" + "message": "이메일로 전송된 코드를 입력하세요." }, "enterTheCodeFromYourAuthenticatorApp": { - "message": "Enter the code from your authenticator app" + "message": "인증 앱에 표시된 코드를 입력하세요." }, "pressYourYubiKeyToAuthenticate": { - "message": "Press your YubiKey to authenticate" + "message": "인증하려면 YubiKey를 누르세요." }, "duoTwoFactorRequiredPageSubtitle": { - "message": "Duo two-step login is required for your account. Follow the steps below to finish logging in." + "message": "계정 로그인을 위해 Duo 2단계 인증이 필요합니다. 아래 단계를 따라 로그인을 완료하세요." }, "followTheStepsBelowToFinishLoggingIn": { - "message": "Follow the steps below to finish logging in." + "message": "아래 단계를 따라 로그인을 완료하세요." }, "followTheStepsBelowToFinishLoggingInWithSecurityKey": { - "message": "Follow the steps below to finish logging in with your security key." + "message": "보안 키로 로그인을 완료하려면 아래 단계를 따라주세요." }, "restartRegistration": { "message": "등록 재시작" @@ -978,8 +990,14 @@ "no": { "message": "아니오" }, + "noAuth": { + "message": "링크가 있는 모든 사용자" + }, + "anyOneWithPassword": { + "message": "내가 설정한 비밀번호를 아는 모든 사용자" + }, "location": { - "message": "Location" + "message": "위치" }, "unexpectedError": { "message": "예기치 못한 오류가 발생했습니다." @@ -1050,10 +1068,10 @@ "message": "항목 편집함" }, "savedWebsite": { - "message": "Saved website" + "message": "저장된 웹사이트" }, "savedWebsites": { - "message": "Saved websites ( $COUNT$ )", + "message": "저장된 웹사이트 ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -1143,7 +1161,7 @@ "message": "예, 지금 저장하겠습니다." }, "notificationViewAria": { - "message": "View $ITEMNAME$, opens in new window", + "message": "$ITEMNAME$ 보기, 새 창에서 열림", "placeholders": { "itemName": { "content": "$1" @@ -1152,18 +1170,18 @@ "description": "Aria label for the view button in notification bar confirmation message" }, "notificationNewItemAria": { - "message": "New Item, opens in new window", + "message": "새 항목, 새 창에서 열림", "description": "Aria label for the new item button in notification bar confirmation message when error is prompted" }, "notificationEditTooltip": { - "message": "Edit before saving", + "message": "저장하기 전에 편집", "description": "Tooltip and Aria label for edit button on cipher item" }, "newNotification": { - "message": "New notification" + "message": "새 알림" }, "labelWithNotification": { - "message": "$LABEL$: New notification", + "message": "$LABEL$: 새 알림", "description": "Label for the notification with a new login suggestion.", "placeholders": { "label": { @@ -1173,15 +1191,15 @@ } }, "notificationLoginSaveConfirmation": { - "message": "saved to Bitwarden.", + "message": "Bitwarden에 저장되었습니다.", "description": "Shown to user after item is saved." }, "notificationLoginUpdatedConfirmation": { - "message": "updated in Bitwarden.", + "message": "Bitwarden에서 업데이트되었습니다.", "description": "Shown to user after item is updated." }, "selectItemAriaLabel": { - "message": "Select $ITEMTYPE$, $ITEMNAME$", + "message": "$ITEMTYPE$ 선택, $ITEMNAME$", "description": "Used by screen readers. $1 is the item type (like vault or folder), $2 is the selected item name.", "placeholders": { "itemType": { @@ -1193,35 +1211,35 @@ } }, "saveAsNewLoginAction": { - "message": "Save as new login", + "message": "새 로그인으로 저장", "description": "Button text for saving login details as a new entry." }, "updateLoginAction": { - "message": "Update login", + "message": "로그인 업데이트", "description": "Button text for updating an existing login entry." }, "unlockToSave": { - "message": "Unlock to save this login", + "message": "이 로그인을 저장하려면 잠금을 해제하세요", "description": "User prompt to take action in order to save the login they just entered." }, "saveLogin": { - "message": "Save login", + "message": "로그인 저장", "description": "Prompt asking the user if they want to save their login details." }, "updateLogin": { - "message": "Update existing login", + "message": "기존 로그인 업데이트", "description": "Prompt asking the user if they want to update an existing login entry." }, "loginSaveSuccess": { - "message": "Login saved", + "message": "로그인이 저장되었습니다", "description": "Message displayed when login details are successfully saved." }, "loginUpdateSuccess": { - "message": "Login updated", + "message": "로그인이 업데이트되었습니다", "description": "Message displayed when login details are successfully updated." }, "loginUpdateTaskSuccess": { - "message": "Great job! You took the steps to make you and $ORGANIZATION$ more secure.", + "message": "잘하셨어요! 보안을 강화하기 위한 단계를 완료하여 사용자와 $ORGANIZATION$의 보안이 더욱 강화되었습니다.", "placeholders": { "organization": { "content": "$1" @@ -1230,7 +1248,7 @@ "description": "Shown to user after login is updated." }, "loginUpdateTaskSuccessAdditional": { - "message": "Thank you for making $ORGANIZATION$ more secure. You have $TASK_COUNT$ more passwords to update.", + "message": "$ORGANIZATION$의 보안을 강화해 주셔서 감사합니다. 업데이트해야 할 비밀번호가 $TASK_COUNT$개 남아 있습니다.", "placeholders": { "organization": { "content": "$1" @@ -1242,15 +1260,15 @@ "description": "Shown to user after login is updated." }, "nextSecurityTaskAction": { - "message": "Change next password", + "message": "다음 비밀번호 변경", "description": "Message prompting user to undertake completion of another security task." }, "saveFailure": { - "message": "Error saving", + "message": "저장 중 오류가 발생했습니다", "description": "Error message shown when the system fails to save login details." }, "saveFailureDetails": { - "message": "Oh no! We couldn't save this. Try entering the details manually.", + "message": "앗! 저장하지 못했습니다. 세부 정보를 직접 입력해 보세요.", "description": "Detailed error message shown when saving login details fails." }, "changePasswordWarning": { @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "YubiKey나 Duo와 같은 독점적인 2단계 로그인 옵션" }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "보관함을 안전하게 유지하기 위한 암호 위생, 계정 상태, 데이터 유출 보고서" }, @@ -2027,6 +2054,9 @@ "email": { "message": "이메일" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "전화번호" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "영구적으로 삭제된 항목" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "항목 복원" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden은 이 도메인들에 대해 로그인 정보를 저장할 것인지 묻지 않습니다. 페이지를 새로고침해야 변경된 내용이 적용됩니다." }, - "excludedDomainsDescAlt": { - "message": "BItwarden은 로그인한 모든 계정에 대해 이러한 도메인에 대한 로그인 세부 정보를 저장하도록 요청하지 않습니다. 변경 사항을 적용하려면 페이지를 새로 고쳐야 합니다" - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "사용자 지정" }, - "sendPasswordDescV3": { - "message": "수신자가 이 Send에 액세스할 수 있도록 비밀번호 옵션를 추가합니다.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "새 Send 생성", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send 수정됨", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "확장자를 새 창에서 열까요?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "파일 Send를 만들려면, 새 창으로 확장자를 열어야 합니다.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "파일을 선택하려면 이 배너를 클릭하여 확장 프로그램을 사이드바에서 열거나, 불가능한 경우 새 창에서 여세요." - }, - "sendFirefoxFileWarning": { - "message": "Firefox에서 파일을 선택할 경우, 이 배너를 클릭하여 확장 프로그램을 사이드바 혹은 새 창에서 여세요." - }, - "sendSafariFileWarning": { - "message": "Safari에서 파일을 선택할 경우, 이 배너를 클릭하여 확장 프로그램을 새 창에서 여세요." - }, "popOut": { "message": "새 창에서 열기" }, - "sendFileCalloutHeader": { - "message": "시작하기 전에" - }, "expirationDateIsInvalid": { "message": "제공된 만료 날짜가 유효하지 않습니다." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "다른 옵션 - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "관리자 콘솔" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "계정 보안" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "$WEBSITE$ 일치 인식 숨기기", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "페이지 로드 시 자동 완성을 할까요?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "매우 넓게" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index bb95441b30a..6e105f044f3 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ą" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Patentuotos dviejų žingsnių prisijungimo parinktys, tokios kaip YubiKey ir Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Slaptažodžio higiena, prieigos sveikata ir duomenų nutekinimo ataskaitos, kad tavo saugyklas būtų saugus." }, @@ -2027,6 +2054,9 @@ "email": { "message": "El. paštas" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefonas" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Ištrintas visam laikui" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Atkurti elementą" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "„Bitwarden“ neprašys išsaugoti šių domenų prisijungimo duomenų. Turite atnaujinti puslapį, kad pokyčiai pradėtų galioti." }, - "excludedDomainsDescAlt": { - "message": "„Bitwarden“ neprašys išsaugoti prisijungimo detalių šiems domenams, visose prisijungusiose paskyrose. Turite atnaujinti puslapį, kad pokyčiai pradėtų galioti." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Pasirinktinis" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Naujas Siuntinys", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Siuntinys išsaugotas", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Norėdami pasirinkti failą, atidarykite plėtinį šoninėje juostoje (jei įmanoma) arba iššokkite į naują langą spustelėdami šią reklamjuostę." - }, - "sendFirefoxFileWarning": { - "message": "Norėdami pasirinkti failą, naudojantis FireFox, atidarykite plėtinį šoninėje juostoje (jei įmanoma) arba iššokkite į naują langą spustelėdami šią reklamjuostę." - }, - "sendSafariFileWarning": { - "message": "Norėdami pasirinkti failą naudodami „Safari“, iššokkite į naują langą spustelėdami šią reklamjuostę." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Prieš pradedant" - }, "expirationDateIsInvalid": { "message": "Nurodytas galiojimo laikas negalioja." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Administratoriaus konsolės" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Paskyros saugumas" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index 9ef89e918b1..8c86d7040fe 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Vienums tika ievietots arhīvā" }, + "itemWasUnarchived": { + "message": "Vienums tika izņemts no arhīva" + }, "itemUnarchived": { "message": "Vienums tika izņemts no arhīva" }, "archiveItem": { "message": "Arhivēt vienumu" }, - "archiveItemConfirmDesc": { - "message": "Arhivētie vienumi netiek iekļauti vispārējās meklēšanas iznākumos un automātiskās aizpildes ieteikumos. Vai tiešām ahrivēt šo vienumu?" + "archiveItemDialogContent": { + "message": "Pēc ievietošanas arhīvā šis vienums netiks iekļauts meklēšanas iznākumā un automātiskās aizpildes ieteikumos." + }, + "archived": { + "message": "Arhivēts" + }, + "unarchiveAndSave": { + "message": "Atcelt arhivēšanu un saglabāt" }, "upgradeToUseArchive": { "message": "Ir nepieciešama Premium dalība, lai izmantotu arhīvu." @@ -978,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" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Tādas slēgtā pirmavota divpakāpju pieteikšanās iespējas kā YubiKey un Duo." }, + "premiumSubscriptionEnded": { + "message": "Tavs Premium abonements beidzās" + }, + "archivePremiumRestart": { + "message": "Lai atgūtu piekļuvi savam arhīvam, jāatsāk Premium abonements. Ja labosi arhivēta vienuma informāciju pirms atsākšanas, tas tiks pārvietots atpakaļ Tavā glabātavā." + }, + "restartPremium": { + "message": "Atsākt Premium" + }, "ppremiumSignUpReports": { "message": "Paroļu higiēnas, konta veselības un datu noplūžu pārskati, lai uzturētu glabātavu drošu." }, @@ -2027,6 +2054,9 @@ "email": { "message": "E-pasts" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Tālrunis" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Vienums ir neatgriezeniski izdzēsts" }, + "archivedItemRestored": { + "message": "Arhīva vienums atjaunots" + }, "restoreItem": { "message": "Atjaunot vienumu" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden nevaicās saglabāt pieteikšanās datus šiem domēniem. Ir jāpārlādē lapa, lai izmaiņas iedarbotos." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden nevaicās saglabāt pieteikšanās datus visiem šī domēna kontiem, kuri ir pieteikušies. Ir jāpārlādē lapa, lai iedarbotos izmaiņas." - }, "blockedDomainsDesc": { "message": "Automātiskā aizpilde un citas saistītās iespējas šajās tīmekļvietnēs netiks piedāvātas. Ir jāatsvaidzina lapa, lai izmaiņas iedarbotos." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Pielāgots" }, - "sendPasswordDescV3": { - "message": "Pēc izvēles var pievienot paroli, lai saņēmēji varētu piekļūt šim Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Jauns Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send saglabāts", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Atvērt paplašinājumu atsevišķi?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Lai izveidotu datņu Send, nepieciešams atvērt paplašinājumu atsevišķā logā.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Lai izvēlētos datni, paplašinājums ir jāatver sānjoslā (ja iespējams) vai atsevišķā logā, klikšķinot uz šī paziņojuma." - }, - "sendFirefoxFileWarning": { - "message": "Lai izvēlētos datni, ja tiek izmantots Firefox, paplašinājums ir jāatver sānjoslā vai atsevišķā logā, klikšķinot uz šī paziņojuma." - }, - "sendSafariFileWarning": { - "message": "Lai izvēlētos datni, ja tiek izmantots Safari, paplašinājums ir jāatver jaunā logā, klikšķinot uz šī paziņojuma." - }, "popOut": { "message": "Atvērt atsevišķi" }, - "sendFileCalloutHeader": { - "message": "Pirms sākšanas" - }, "expirationDateIsInvalid": { "message": "Norādītais derīguma beigu datums nav derīgs." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Vairāk iespēju" + }, "moreOptionsTitle": { "message": "Vairāk iespēju - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "pārvaldības konsolē," }, + "admin": { + "message": "Pārvaldītājs" + }, + "automaticUserConfirmation": { + "message": "Automātiska lietotāju apstiprināšana" + }, + "automaticUserConfirmationHint": { + "message": "Automātiski apstiprināt ierindotos lietotājus, kamēr šī ierīce ir atslēgta" + }, + "autoConfirmOnboardingCallout": { + "message": "Laika ietaupīšana ar automātisku lietotāju apstiprināšanu" + }, + "autoConfirmWarning": { + "message": "Tas varētu ietekmēt apvienības datu drošību. " + }, + "autoConfirmWarningLink": { + "message": "Uzzināt par riskiem" + }, + "autoConfirmSetup": { + "message": "Automātiski apstiprināt jaunus lietotājus" + }, + "autoConfirmSetupDesc": { + "message": "Jauni lietotāji tiks automātiski apstiprināti, kamēr šī ierīce ir atslēgta." + }, + "autoConfirmSetupHint": { + "message": "Kādi ir iespējamie drošības riski?" + }, + "autoConfirmEnabled": { + "message": "Automātiska apstiprināšana ieslēgta" + }, + "availableNow": { + "message": "Pieejams tagad" + }, "accountSecurity": { "message": "Konta drošība" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Lejupielādēt pielikumu" + }, "downloadBitwarden": { "message": "Lejupielādē Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Paslēpt atbilstības noteikšanu $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Rādīt atbilstības noteikšanu" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Slēpt atbilstības noteikšanu" }, "autoFillOnPageLoad": { "message": "Automātiski aizpildīt lapas ielādes brīdī?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Ļoti plats" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Ievadītā parole ir nepareiza." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Kartes numurs" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Apvienība vairs neizmanto galvenās paroles, lai pieteiktos Bitwarden. Lai turpinātu, jāapliecina apvienība un domēns." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Vienumi" + }, + "searchResults": { + "message": "Meklēšanas iznākums" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Cilvēkiem būs jāievada parole, lai apskatītu šo Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index bb025788e18..61f69ffe22b 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "തെറ്റ്" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "നിങ്ങളുടെ വാൾട് സൂക്ഷിക്കുന്നതിന്. പാസ്‌വേഡ് ശുചിത്വം, അക്കൗണ്ട് ആരോഗ്യം, ഡാറ്റ ലംഘന റിപ്പോർട്ടുകൾ." }, @@ -2027,6 +2054,9 @@ "email": { "message": "ഇമെയിൽ" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ഫോൺ" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "ശാശ്വതമായി ഇല്ലാതാക്കിയ ഇനം" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "ഇനം വീണ്ടെടുക്കുക " }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Custom" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." - }, - "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." - }, - "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Before you start" - }, "expirationDateIsInvalid": { "message": "The expiration date provided is not valid." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index 8440297105c..5cc614c5df7 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Custom" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." - }, - "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." - }, - "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Before you start" - }, "expirationDateIsInvalid": { "message": "The expiration date provided is not valid." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index ea4f2b08a85..336e8783b75 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Custom" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." - }, - "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." - }, - "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Before you start" - }, "expirationDateIsInvalid": { "message": "The expiration date provided is not valid." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index fcf1a3f14d9..ce6c8d5a7d4 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "Nei" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Sted" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Passordhygiene, kontohelse, og databruddsrapporter som holder hvelvet ditt trygt." }, @@ -2027,6 +2054,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Slettet objektet permanent" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Gjenopprett objekt" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden vil ikke be om å lagre innloggingsdetaljer for disse domenene. Du må oppdatere siden for at endringene skal tre i kraft." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Egendefinert" }, - "sendPasswordDescV3": { - "message": "Legg til et valgfritt passord for at mottakerne skal få tilgang til denne Send-en.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Lag en ny Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Redigerte Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Vil du sprette ut utvidelsen?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "For å velge en fil, åpne utvidelsen i sidepanelet (hvis mulig) eller poppe ut til et nytt vindu ved å klikke på dette banneret." - }, - "sendFirefoxFileWarning": { - "message": "For å velge en fil med Firefox må du åpne utvidelsen i sidestolpen eller sprette ut til et nytt vindu ved å klikke på dette banneret." - }, - "sendSafariFileWarning": { - "message": "For å velge en fil med Safari, popp ut i et nytt vindu ved å klikke på dette banneret." - }, "popOut": { "message": "Sprett ut" }, - "sendFileCalloutHeader": { - "message": "Før du starter" - }, "expirationDateIsInvalid": { "message": "Utløpsdatoen angitt er ikke gyldig." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Flere innstillinger - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Administrasjonskonsoll" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Kontosikkerhet" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Last ned Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Vil du auto-utfylle ved sideinnlasting?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Ekstra bred" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Passordet du skrev inn er feil." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index ea4f2b08a85..336e8783b75 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Custom" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." - }, - "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." - }, - "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Before you start" - }, "expirationDateIsInvalid": { "message": "The expiration date provided is not valid." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index ac465690dcd..44522727429 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -28,11 +28,14 @@ "logInWithPasskey": { "message": "Inloggen met passkey" }, + "unlockWithPasskey": { + "message": "Ontgrendelen met passkey" + }, "useSingleSignOn": { "message": "Single sign-on gebruiken" }, "yourOrganizationRequiresSingleSignOn": { - "message": "Your organization requires single sign-on." + "message": "Je organisatie vereist single sign-on." }, "welcomeBack": { "message": "Welkom terug" @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item naar archief verzonden" }, + "itemWasUnarchived": { + "message": "Item uit het archief gehaald" + }, "itemUnarchived": { "message": "Item uit het archief gehaald" }, "archiveItem": { "message": "Item archiveren" }, - "archiveItemConfirmDesc": { - "message": "Gearchiveerde items worden uitgesloten van algemene zoekresultaten en automatische invulsuggesties. Weet je zeker dat je dit item wilt archiveren?" + "archiveItemDialogContent": { + "message": "Eenmaal gearchiveerd wordt dit item uitgesloten van zoekresultaten en suggesties voor automatisch invullen." + }, + "archived": { + "message": "Gearchiveerd" + }, + "unarchiveAndSave": { + "message": "Dearchiveren en opslaan" }, "upgradeToUseArchive": { "message": "Je hebt een Premium-abonnement nodig om te kunnen archiveren." @@ -978,6 +990,12 @@ "no": { "message": "Nee" }, + "noAuth": { + "message": "Iedereen met de link" + }, + "anyOneWithPassword": { + "message": "Iedereen met een door jou ingesteld wachtwoord" + }, "location": { "message": "Locatie" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Eigen opties voor tweestapsaanmelding zoals YubiKey en Duo." }, + "premiumSubscriptionEnded": { + "message": "Je Premium-abonnement is afgelopen" + }, + "archivePremiumRestart": { + "message": "Herstart je Premium-abonnement om toegang tot je archief te krijgen. Als je de details wijzigt voor een gearchiveerd item voor het opnieuw opstarten, zal het terug naar je kluis worden verplaatst." + }, + "restartPremium": { + "message": "Premium herstarten" + }, "ppremiumSignUpReports": { "message": "Wachtwoordhygiëne, gezondheid van je account en datalekken om je kluis veilig te houden." }, @@ -2027,6 +2054,9 @@ "email": { "message": "E-mailadres" }, + "emails": { + "message": "E-mails" + }, "phone": { "message": "Telefoonnummer" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Definitief verwijderd item" }, + "archivedItemRestored": { + "message": "Gearchiveerd item hersteld" + }, "restoreItem": { "message": "Item herstellen" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden zal voor deze domeinen niet vragen om inloggegevens op te slaan. Je moet de pagina vernieuwen om de wijzigingen toe te passen." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden zal voor deze domeinen niet vragen om de wachtwoorden op te slaan voor alle ingelogde accounts. Je moet de pagina verversen om de wijzigingen op te slaan." - }, "blockedDomainsDesc": { "message": "Autofill en andere gerelateerde functies worden niet aangeboden voor deze websites. Vernieuw de pagina om de wijzigingen toe te passen." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Aangepast" }, - "sendPasswordDescV3": { - "message": "Voeg een optioneel wachtwoord toe voor ontvangers om toegang te krijgen tot deze Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Nieuwe Send aanmaken", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send bewerkt", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Extensie uitklappen?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Om een bestand te versturen via Send, moet je de extensie naar een nieuw venster uitklappen.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Om een bestand te kiezen open je de extensie in de zijbalk (indien mogelijk) of pop-out naar een nieuw venster door op deze banner te klikken." - }, - "sendFirefoxFileWarning": { - "message": "Om een bestand te kiezen met Firefox, open je de extensie in de zijbalk of als pop-up in een nieuw venster door op deze banner te klikken." - }, - "sendSafariFileWarning": { - "message": "Om een bestand te kiezen met Safari, open je een nieuw pop-up-venster door op deze banner te klikken." - }, "popOut": { "message": "Uitklappen" }, - "sendFileCalloutHeader": { - "message": "Voor je begint" - }, "expirationDateIsInvalid": { "message": "De opgegeven vervaldatum is niet geldig." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Meer opties" + }, "moreOptionsTitle": { "message": "Meer opties - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Beheerconsole" }, + "admin": { + "message": "Beheerder" + }, + "automaticUserConfirmation": { + "message": "Automatische gebruikersbevestiging" + }, + "automaticUserConfirmationHint": { + "message": "Automatisch gebruikers in behandeling bevestigen wanneer dit apparaat is ontgrendeld" + }, + "autoConfirmOnboardingCallout": { + "message": "Bespaar tijd met automatische gebruikersbevestiging" + }, + "autoConfirmWarning": { + "message": "Dit kan van invloed zijn op de gegevensbeveiliging van je organisatie. " + }, + "autoConfirmWarningLink": { + "message": "Meer informatie over de risico's" + }, + "autoConfirmSetup": { + "message": "Automatisch nieuwe gebruikers bevestigen" + }, + "autoConfirmSetupDesc": { + "message": "Nieuwe gebruikers worden automatisch bevestigd wanneer dit apparaat is ontgrendeld." + }, + "autoConfirmSetupHint": { + "message": "Wat zijn de mogelijke veiligheidsrisico's?" + }, + "autoConfirmEnabled": { + "message": "Automatische bevestigen ingeschakeld" + }, + "availableNow": { + "message": "Nu beschikbaar" + }, "accountSecurity": { "message": "Accountbeveiliging" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Bijlage downloaden" + }, "downloadBitwarden": { "message": "Bitwarden downloaden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Overeenkomstdetectie verbergen $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Overeenkomstdetectie weergeven" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Overeenkomstdetectie verbergen" }, "autoFillOnPageLoad": { "message": "Automatisch invullen bij laden van pagina?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra breed" }, + "narrow": { + "message": "Smal" + }, "sshKeyWrongPassword": { "message": "Het wachtwoord dat je hebt ingevoerd is onjuist." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Kaartnummer" }, + "errorCannotDecrypt": { + "message": "Fout: Kan niet ontsleutelen" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Je organisatie maakt niet langer gebruik van hoofdwachtwoorden om in te loggen op Bitwarden. Controleer de organisatie en het domein om door te gaan." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Waarom zie ik dit?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Zoekresultaten" + }, "resizeSideNavigation": { "message": "Formaat zijnavigatie wijzigen" + }, + "whoCanView": { + "message": "Wie kan weergeven" + }, + "specificPeople": { + "message": "Specifieke mensen" + }, + "emailVerificationDesc": { + "message": "Na het delen van deze Send-link moeten individuen hun e-mailadres met een code verifiëren om deze Send te kunnen bekijken." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Voer meerdere e-mailadressen in door te scheiden met een komma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "downloadBitwardenApps": { + "message": "Bitwarden-apps downloaden" + }, + "emailProtected": { + "message": "E-mail beveiligd" + }, + "sendPasswordHelperText": { + "message": "Individuen moeten het wachtwoord invoeren om deze Send te bekijken", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index ea4f2b08a85..336e8783b75 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Custom" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." - }, - "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." - }, - "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Before you start" - }, "expirationDateIsInvalid": { "message": "The expiration date provided is not valid." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index ea4f2b08a85..336e8783b75 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Custom" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." - }, - "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." - }, - "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Before you start" - }, "expirationDateIsInvalid": { "message": "The expiration date provided is not valid." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 5162829669d..44c7d9e6d47 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": "Odblokuj za pomocą klucza dostępu" + }, "useSingleSignOn": { "message": "Użyj logowania jednokrotnego" }, @@ -573,20 +576,29 @@ "itemWasSentToArchive": { "message": "Element został przeniesiony do archiwum" }, + "itemWasUnarchived": { + "message": "Element został usunięty z archiwum" + }, "itemUnarchived": { "message": "Element został usunięty z archiwum" }, "archiveItem": { "message": "Archiwizuj element" }, - "archiveItemConfirmDesc": { - "message": "Zarchiwizowane elementy są wykluczone z wyników wyszukiwania i sugestii autouzupełniania. Czy na pewno chcesz archiwizować element?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, "itemRestored": { - "message": "Item has been restored" + "message": "Element został przywrócony" }, "edit": { "message": "Edytuj" @@ -978,6 +990,12 @@ "no": { "message": "Nie" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokalizacja" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Specjalne opcje logowania dwustopniowego, takie jak YubiKey i Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Raporty bezpieczeństwa haseł, konta i wycieków danych, aby Twoje dane były bezpieczne." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Adres e-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Numer telefonu" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Element został trwale usunięty" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Przywróć element" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden nie będzie proponował zapisywania danych logowania dla tych domen. Odśwież stronę, aby zastosowywać zmiany." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden nie będzie proponował zapisywania danych logowania dla tych domen dla wszystkich zalogowanych kont. Odśwież stronę, aby zastosowywać zmiany." - }, "blockedDomainsDesc": { "message": "Autouzupełnianie będzie zablokowane dla tych stron internetowych. Zmiany zaczną obowiązywać po odświeżeniu strony." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Niestandardowa" }, - "sendPasswordDescV3": { - "message": "Zabezpiecz wysyłkę opcjonalnym hasłem.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Nowa wysyłka", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Wysyłka została zapisana", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Otworzyć rozszerzenie w oknie?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Otwórz okno rozszerzenia, aby utworzyć wysyłkę pliku.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Aby wybrać plik, otwórz rozszerzenie na pasku bocznym lub w oknie." - }, - "sendFirefoxFileWarning": { - "message": "Aby wybrać plik, otwórz rozszerzenie na pasku bocznym lub w oknie." - }, - "sendSafariFileWarning": { - "message": "Aby wybrać plik, otwórz rozszerzenie w oknie." - }, "popOut": { "message": "Otwórz w nowym oknie" }, - "sendFileCalloutHeader": { - "message": "Zanim zaczniesz" - }, "expirationDateIsInvalid": { "message": "Data wygaśnięcia nie jest prawidłowa." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Więcej opcji" + }, "moreOptionsTitle": { "message": "Więcej opcji - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Konsola administratora" }, + "admin": { + "message": "Administrator" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Bezpieczeństwo konta" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Pobierz załącznik" + }, "downloadBitwarden": { "message": "Pobierz Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Ukryj wykrywanie dopasowania $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Pokaż wykrywanie dopasowania" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Ukryj wykrywanie dopasowania" }, "autoFillOnPageLoad": { "message": "Włączyć autouzupełnianie po załadowaniu strony?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Bardzo szeroka" }, + "narrow": { + "message": "Wąska" + }, "sshKeyWrongPassword": { "message": "Hasło jest nieprawidłowe." }, @@ -5671,7 +5722,7 @@ "message": "Vulnerable password." }, "changeNow": { - "message": "Change now" + "message": "Zmień teraz" }, "missingWebsite": { "message": "Brak strony internetowej" @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Numer karty" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Elementy" + }, + "searchResults": { + "message": "Wyniki wyszukiwania" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 2a35a6f0c64..5ad95b480db 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "O item foi enviado para o arquivo" }, + "itemWasUnarchived": { + "message": "O item foi desarquivado" + }, "itemUnarchived": { "message": "O item foi desarquivado" }, "archiveItem": { "message": "Arquivar item" }, - "archiveItemConfirmDesc": { - "message": "Itens arquivados são excluídos dos resultados gerais de busca e das sugestões de preenchimento automático. Tem certeza de que deseja arquivar este item?" + "archiveItemDialogContent": { + "message": "Ao arquivar, o item será excluído dos resultados de busca e sugestões de preenchimento automático." + }, + "archived": { + "message": "Arquivados" + }, + "unarchiveAndSave": { + "message": "Desarquivar e salvar" }, "upgradeToUseArchive": { "message": "Um plano Premium é necessário para usar o arquivamento." @@ -978,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" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Opções de autenticação em duas etapas proprietárias como YubiKey e Duo." }, + "premiumSubscriptionEnded": { + "message": "Sua assinatura Premium terminou" + }, + "archivePremiumRestart": { + "message": "Para recuperar o seu acesso ao seu arquivo, retoma sua assinatura Premium. Se editar detalhes de um item arquivado antes de retomar, ele será movido de volta para o seu cofre." + }, + "restartPremium": { + "message": "Retomar Premium" + }, "ppremiumSignUpReports": { "message": "Relatórios de higiene de senha, saúde da conta, e vazamentos de dados para manter o seu cofre seguro." }, @@ -2027,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefone" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item apagado para sempre" }, + "archivedItemRestored": { + "message": "Item arquivado restaurado" + }, "restoreItem": { "message": "Restaurar item" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "O Bitwarden não irá pedir para salvar os detalhes de credencial para estes domínios. Você deve atualizar a página para que as alterações entrem em vigor." }, - "excludedDomainsDescAlt": { - "message": "O Bitwarden não irá pedir para salvar os detalhes de credencial para estes domínios, em todas as contas. Você deve recarregar a página para que as alterações entrem em vigor." - }, "blockedDomainsDesc": { "message": "O preenchimento automático e outros recursos relacionados não serão oferecidos para estes sites. Recarregue a página para que as mudanças surtam efeito." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Personalizado" }, - "sendPasswordDescV3": { - "message": "Adicione uma senha opcional para que os destinatários acessem este Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Novo Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send salvo", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Criar janela da extensão?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Para criar um Send de arquivo, você precisa colocar a extensão em uma nova janela.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Para escolher um arquivo, abra a extensão na barra lateral (se possível), ou abra uma nova janela clicando neste banner." - }, - "sendFirefoxFileWarning": { - "message": "Para escolher um arquivo usando o Firefox, abra a extensão na barra lateral ou abra uma nova janela clicando neste banner." - }, - "sendSafariFileWarning": { - "message": "Para escolher um arquivo usando o Safari, abra uma nova janela clicando neste banner." - }, "popOut": { "message": "Mover para janela" }, - "sendFileCalloutHeader": { - "message": "Antes de começar" - }, "expirationDateIsInvalid": { "message": "A data de validade fornecida não é válida." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Mais opções" + }, "moreOptionsTitle": { "message": "Mais opções - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Painel de administração" }, + "admin": { + "message": "Administrador" + }, + "automaticUserConfirmation": { + "message": "Confirmação automática de usuários" + }, + "automaticUserConfirmationHint": { + "message": "Confirme automaticamente usuários pendentes quando este dispositivo for desbloqueado" + }, + "autoConfirmOnboardingCallout": { + "message": "Economize tempo com a confirmação automática de usuários" + }, + "autoConfirmWarning": { + "message": "Isso pode afetar a segurança dos dados da sua organização. " + }, + "autoConfirmWarningLink": { + "message": "Saiba mais sobre os riscos" + }, + "autoConfirmSetup": { + "message": "Confirmar automaticamente usuários novos" + }, + "autoConfirmSetupDesc": { + "message": "Usuários novos serão confirmados automaticamente quando este dispositivo for desbloqueado." + }, + "autoConfirmSetupHint": { + "message": "Quais são os possíveis problemas de segurança?" + }, + "autoConfirmEnabled": { + "message": "Ativou a confirmação automática" + }, + "availableNow": { + "message": "Disponível agora" + }, "accountSecurity": { "message": "Segurança da conta" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Baixar anexo" + }, "downloadBitwarden": { "message": "Baixar o Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Ocultar detecção de correspondência $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Mostrar detecção de correspondência" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Ocultar detecção de correspondência" }, "autoFillOnPageLoad": { "message": "Preencher ao carregar a página?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra larga" }, + "narrow": { + "message": "Estreita" + }, "sshKeyWrongPassword": { "message": "A senha que você digitou está incorreta." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Número do cartão" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "A sua organização não está mais usando senhas principais para se conectar ao Bitwarden. Para continuar, verifique a organização e o domínio." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Por que estou vendo isso?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index fdf3ba2d164..604bf054707 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "O item foi movido para o arquivo" }, + "itemWasUnarchived": { + "message": "O item foi desarquivado" + }, "itemUnarchived": { "message": "O item foi desarquivado" }, "archiveItem": { "message": "Arquivar item" }, - "archiveItemConfirmDesc": { - "message": "Os itens arquivados são excluídos dos resultados gerais da pesquisa e das sugestões de preenchimento automático. Tem a certeza de que pretende arquivar este item?" + "archiveItemDialogContent": { + "message": "Depois de arquivado, este item será excluído dos resultados de pesquisa e das sugestões de preenchimento automático." + }, + "archived": { + "message": "Arquivado" + }, + "unarchiveAndSave": { + "message": "Desarquivar e guardar" }, "upgradeToUseArchive": { "message": "É necessária uma subscrição Premium para utilizar o Arquivo." @@ -978,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" }, @@ -1062,7 +1080,7 @@ } }, "deleteItemConfirmation": { - "message": "Tem a certeza de que pretende eliminar este item?" + "message": "Pretende realmente mover este item para o lixo?" }, "deletedItem": { "message": "Item movido para o lixo" @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Opções proprietárias de verificação de dois passos, como YubiKey e Duo." }, + "premiumSubscriptionEnded": { + "message": "A sua subscrição Premium terminou" + }, + "archivePremiumRestart": { + "message": "Para recuperar o acesso ao seu arquivo, reinicie a sua subscrição Premium. Se editar os detalhes de um item arquivado antes de reiniciar, ele será movido de volta para o seu cofre." + }, + "restartPremium": { + "message": "Reiniciar 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." }, @@ -2027,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "E-mails" + }, "phone": { "message": "Telefone" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item eliminado permanentemente" }, + "archivedItemRestored": { + "message": "Item arquivado restaurado" + }, "restoreItem": { "message": "Restaurar item" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "O Bitwarden não pedirá para guardar os detalhes de início de sessão destes domínios. É necessário atualizar a página para que as alterações tenham efeito." }, - "excludedDomainsDescAlt": { - "message": "O Bitwarden não pedirá para guardar os detalhes de início de sessão destes domínios para todas as contas com sessão iniciada. É necessário atualizar a página para que as alterações tenham efeito." - }, "blockedDomainsDesc": { "message": "O preenchimento automático e outras funcionalidades relacionadas não serão disponibilizados para estes sites. É necessário atualizar a página para que as alterações tenham efeito." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Personalizado" }, - "sendPasswordDescV3": { - "message": "Adicione uma palavra-passe opcional para os destinatários acederem a este Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Novo Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send editado", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Abrir a extensão numa nova janela?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Para criar um ficheiro Send, precisa de abrir a extensão para uma nova janela.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Para escolher um ficheiro, abra a extensão na barra lateral (se possível) ou abra uma nova janela clicando neste banner." - }, - "sendFirefoxFileWarning": { - "message": "Para escolher um ficheiro utilizando o Firefox, abra a extensão na barra lateral ou abra uma nova janela clicando neste banner." - }, - "sendSafariFileWarning": { - "message": "Para escolher um ficheiro utilizando o Safari, abra uma nova janela clicando neste banner." - }, "popOut": { "message": "Abrir numa nova janela" }, - "sendFileCalloutHeader": { - "message": "Antes de começar" - }, "expirationDateIsInvalid": { "message": "O prazo de validade fornecido não é válido." }, @@ -3346,6 +3352,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" }, @@ -4037,7 +4049,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", @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Mais opções" + }, "moreOptionsTitle": { "message": "Mais opções - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Consola de administração" }, + "admin": { + "message": "Administrador" + }, + "automaticUserConfirmation": { + "message": "Confirmação automática do utilizador" + }, + "automaticUserConfirmationHint": { + "message": "Confirmar automaticamente os utilizadores pendentes enquanto este dispositivo estiver desbloqueado" + }, + "autoConfirmOnboardingCallout": { + "message": "Poupe tempo com a confirmação automática de utilizadores" + }, + "autoConfirmWarning": { + "message": "Isto pode afetar a segurança dos dados da sua organização. " + }, + "autoConfirmWarningLink": { + "message": "Saiba mais sobre os riscos" + }, + "autoConfirmSetup": { + "message": "Confirmar automaticamente novos utilizadores" + }, + "autoConfirmSetupDesc": { + "message": "Os novos utilizadores serão automaticamente confirmados enquanto este dispositivo estiver desbloqueado." + }, + "autoConfirmSetupHint": { + "message": "Quais são os riscos potenciais de segurança?" + }, + "autoConfirmEnabled": { + "message": "Confirmação automática ativada" + }, + "availableNow": { + "message": "Já disponível" + }, "accountSecurity": { "message": "Segurança da conta" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Transferir anexo" + }, "downloadBitwarden": { "message": "Descarregar o Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Ocultar deteção de correspondência para $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Mostrar deteção de correspondência" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Ocultar deteção de correspondência" }, "autoFillOnPageLoad": { "message": "Preencher automaticamente ao carregar a página?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Muito ampla" }, + "narrow": { + "message": "Estreito" + }, "sshKeyWrongPassword": { "message": "A palavra-passe que introduziu está incorreta." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Número do cartão" }, + "errorCannotDecrypt": { + "message": "Erro: Não é possível desencriptar" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "A sua organização já não utiliza palavras-passe mestras para iniciar sessão no Bitwarden. Para continuar, verifique a organização e o domínio." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Porque é que estou a ver isto?" }, + "items": { + "message": "Itens" + }, + "searchResults": { + "message": "Resultados da pesquisa" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Descarregar as apps Bitwarden" + }, + "emailProtected": { + "message": "E-mail protegido" + }, + "sendPasswordHelperText": { + "message": "Os indivíduos terão de introduzir a palavra-passe para ver este Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 44c4abba934..12706943e83 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ă" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "Nu" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Opțiuni brevetate de conectare cu doi factori, cum ar fi YubiKey și Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Rapoarte privind igiena parolelor, sănătatea contului și breșele de date pentru a vă păstra seiful în siguranță." }, @@ -2027,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Articol șters permanent" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restabilire articol" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden nu va cere să salveze detaliile de conectare pentru aceste domenii. Trebuie să reîmprospătați pagina pentru ca modificările să intre în vigoare." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Personalizat" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Nou Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send salvat", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Pentru a alege un fișier, deschideți extensia în bara laterală (dacă este posibil) sau deschideți-o într-o fereastră nouă, făcând clic pe acest banner." - }, - "sendFirefoxFileWarning": { - "message": "Pentru a alege un fișier folosind Firefox, deschideți extensia din bara laterală sau deschideți o fereastră nouă făcând clic pe acest banner." - }, - "sendSafariFileWarning": { - "message": "Pentru a alege un fișier folosind Safari, deschideți o fereastră nouă făcând clic pe acest banner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Înainte de a începe" - }, "expirationDateIsInvalid": { "message": "Data de expirare furnizată nu este validă." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 2b96b2038a5..dab9a22f03a 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": "Использовать единый вход" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Элемент был отправлен в архив" }, + "itemWasUnarchived": { + "message": "Элемент был разархивирован" + }, "itemUnarchived": { "message": "Элемент был разархивирован" }, "archiveItem": { "message": "Архивировать элемент" }, - "archiveItemConfirmDesc": { - "message": "Архивированные элементы исключены из общих результатов поиска и предложений автозаполнения. Вы уверены, что хотите архивировать этот элемент?" + "archiveItemDialogContent": { + "message": "После архивации этот элемент будет исключен из результатов поиска и предложений по автозаполнению." + }, + "archived": { + "message": "Архивирован" + }, + "unarchiveAndSave": { + "message": "Разархивировать и сохранить" }, "upgradeToUseArchive": { "message": "Для использования архива требуется премиум-статус." @@ -978,6 +990,12 @@ "no": { "message": "Нет" }, + "noAuth": { + "message": "Любой, у кого есть ссылка" + }, + "anyOneWithPassword": { + "message": "Любой, у кого есть установленный вами пароль" + }, "location": { "message": "Местоположение" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Проприетарные варианты двухэтапной аутентификации, такие как YubiKey или Duo." }, + "premiumSubscriptionEnded": { + "message": "Ваша подписка Премиум закончилась" + }, + "archivePremiumRestart": { + "message": "Чтобы восстановить доступ к своему архиву, подключите подписку Премиум повторно. Если вы измените сведения об архивированном элементе перед переподключением, он будет перемещен обратно в ваше хранилище." + }, + "restartPremium": { + "message": "Переподключить Премиум" + }, "ppremiumSignUpReports": { "message": "Гигиена паролей, здоровье аккаунта и отчеты об утечках данных для обеспечения безопасности вашего хранилища." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Телефон" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Элемент удален навсегда" }, + "archivedItemRestored": { + "message": "Архивированный элемент восстановлен" + }, "restoreItem": { "message": "Восстановить элемент" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden не будет предлагать сохранить логины для этих доменов. Для вступления изменений в силу необходимо обновить страницу." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden не будет предлагать сохранение логинов для этих доменов для всех авторизованных аккаунтов. Для вступления изменений в силу необходимо обновить страницу." - }, "blockedDomainsDesc": { "message": "Автозаполнение и другие связанные с ним функции не будут предлагаться для этих сайтов. Чтобы изменения вступили в силу, необходимо обновить страницу." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Пользовательский" }, - "sendPasswordDescV3": { - "message": "Добавьте опциональный пароль для доступа получателей к этой Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Новая Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send сохранена", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Открепить расширение?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Чтобы создать файл Send, необходимо открыть расширение в новом окне.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Для выбора файла откройте расширение в боковой панели (если возможно) или перейдите в новое окно, нажав на этот баннер." - }, - "sendFirefoxFileWarning": { - "message": "Для выбора файла с помощью Firefox, откройте расширение в боковой панели или перейдите в новое окно, нажав на этот баннер." - }, - "sendSafariFileWarning": { - "message": "Для выбора файла с помощью Safari, перейдите в новое окно, нажав на этот баннер." - }, "popOut": { "message": "Открепить" }, - "sendFileCalloutHeader": { - "message": "Перед тем, как начать" - }, "expirationDateIsInvalid": { "message": "Срок истечения указан некорректно." }, @@ -3346,6 +3352,12 @@ "error": { "message": "Ошибка" }, + "prfUnlockFailed": { + "message": "Не удалось разблокировать с помощью passkey. Пожалуйста, повторите попытку или используйте другой метод разблокировки." + }, + "noPrfCredentialsAvailable": { + "message": "Для разблокировки недоступны passkeys с поддержкой PRF. Пожалуйста, сначала авторизуйтесь, используя passkey." + }, "decryptionError": { "message": "Ошибка расшифровки" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Больше опций" + }, "moreOptionsTitle": { "message": "Больше опций - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "консоли администратора" }, + "admin": { + "message": "Администратор" + }, + "automaticUserConfirmation": { + "message": "Автоматическое подтверждение пользователя" + }, + "automaticUserConfirmationHint": { + "message": "Автоматически подтверждать ожидающих пользователей пока это устройство разблокировано" + }, + "autoConfirmOnboardingCallout": { + "message": "Экономьте время благодаря автоматическому подтверждению пользователей" + }, + "autoConfirmWarning": { + "message": "Это может повлиять на безопасность данных вашей организации. " + }, + "autoConfirmWarningLink": { + "message": "Узнайте о рисках" + }, + "autoConfirmSetup": { + "message": "Автоматически подтверждать новых пользователей" + }, + "autoConfirmSetupDesc": { + "message": "Новые пользователи будут автоматически подтверждены при разблокировке устройства." + }, + "autoConfirmSetupHint": { + "message": "Каковы потенциальные риски для безопасности?" + }, + "autoConfirmEnabled": { + "message": "Включено автоматическое подтверждение" + }, + "availableNow": { + "message": "Уже доступно" + }, "accountSecurity": { "message": "Безопасность аккаунта" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Скачать вложение" + }, "downloadBitwarden": { "message": "Скачать Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Скрыть обнаружение совпадений $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Показать обнаружение совпадений" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Скрыть обнаружение совпадений" }, "autoFillOnPageLoad": { "message": "Автозаполнение при загрузке страницы?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Очень широкое" }, + "narrow": { + "message": "Узкий" + }, "sshKeyWrongPassword": { "message": "Введенный пароль неверен." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Номер карты" }, + "errorCannotDecrypt": { + "message": "Ошибка: невозможно расшифровать" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Ваша организация больше не использует мастер-пароли для входа в Bitwarden. Чтобы продолжить, подтвердите организацию и домен." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Почему я это вижу?" }, + "items": { + "message": "Элементы" + }, + "searchResults": { + "message": "Результаты поиска" + }, "resizeSideNavigation": { "message": "Изменить размер боковой навигации" + }, + "whoCanView": { + "message": "Кто может просматривать" + }, + "specificPeople": { + "message": "Конкретные пользователи" + }, + "emailVerificationDesc": { + "message": "После того, как вы поделитесь ссылкой на Send, пользователю нужно будет подтвердить свой email кодом, чтобы просмотреть эту Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Введите несколько email, разделяя их запятой." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "downloadBitwardenApps": { + "message": "Скачать приложения Bitwarden" + }, + "emailProtected": { + "message": "Email защищен" + }, + "sendPasswordHelperText": { + "message": "Пользователям необходимо будет ввести пароль для просмотра этой Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index c06249e55cb..d228cdb512a 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "නැත" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "ඔබගේ සුරක්ෂිතාගාරය ආරක්ෂිතව තබා ගැනීම සඳහා මුරපදය සනීපාරක්ෂාව, ගිණුම් සෞඛ්යය සහ දත්ත උල්ලං ach නය වාර්තා කරයි." }, @@ -2027,6 +2054,9 @@ "email": { "message": "ඊ-තැපැල්" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "දුරකථන" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "ස්ථිරව මකා දැමූ අයිතමය" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "අයිතමය යළි පිහිටුවන්න" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "බිට්වර්ඩන් මෙම වසම් සඳහා පිවිසුම් තොරතුරු සුරැකීමට ඉල්ලා නොසිටිනු ඇත. බලාත්මක කිරීම සඳහා වෙනස්කම් සඳහා ඔබ පිටුව නැවුම් කළ යුතුය." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "අභිරුචි" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "නව යවන්න නිර්මාණය", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "සංස්කරණය යවන්න", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "ගොනුවක් තෝරා ගැනීම සඳහා, පැති තීරුවේ දිගුව විවෘත කරන්න (හැකි නම්) හෝ මෙම බැනරය ක්ලික් කිරීමෙන් නව කවුළුවකට පොප් කරන්න." - }, - "sendFirefoxFileWarning": { - "message": "ෆයර්ෆොක්ස් භාවිතයෙන් ගොනුවක් තෝරා ගැනීම සඳහා, පැති තීරුවේ දිගුව විවෘත කරන්න හෝ මෙම බැනරය ක්ලික් කිරීමෙන් නව කවුළුවකට පොප් කරන්න." - }, - "sendSafariFileWarning": { - "message": "සෆාරි භාවිතා ගොනුවක් තෝරා ගැනීම සඳහා, මෙම බැනරය ක්ලික් කිරීමෙන් නව කවුළුවකට දිස්වේ." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "ඔබ ආරම්භ කිරීමට පෙර" - }, "expirationDateIsInvalid": { "message": "ලබා දී ඇති කල් ඉකුත්වන දිනය වලංගු නොවේ." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 9c7cdec8c8f..db7efcd8b9f 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Položka bola archivovaná" }, + "itemWasUnarchived": { + "message": "Položka bola odobraná z archívu" + }, "itemUnarchived": { "message": "Položka bola odobraná z archívu" }, "archiveItem": { "message": "Archivovať položku" }, - "archiveItemConfirmDesc": { - "message": "Archivované položky sú vylúčené zo všeobecného vyhľadávania a z návrhov automatického vypĺňania. Naozaj chcete archivovať túto položku?" + "archiveItemDialogContent": { + "message": "Po archivácii bude táto položka vylúčená z výsledkov vyhľadávania a návrhov automatického vypĺňania." + }, + "archived": { + "message": "Archivované" + }, + "unarchiveAndSave": { + "message": "Zrušiť archiváciu a uložiť" }, "upgradeToUseArchive": { "message": "Na použitie archívu je potrebné prémiové členstvo." @@ -978,6 +990,12 @@ "no": { "message": "Nie" }, + "noAuth": { + "message": "Ktokoľvek s odkazom" + }, + "anyOneWithPassword": { + "message": "Ktokoľvek s heslom od vás" + }, "location": { "message": "Poloha" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietárne možnosti dvojstupňového prihlásenia ako napríklad YubiKey a Duo." }, + "premiumSubscriptionEnded": { + "message": "Vaše predplatné Prémium skončilo" + }, + "archivePremiumRestart": { + "message": "Ak chcete obnoviť prístup k svojmu archívu, reštartujte predplatné Prémium. Ak pred reštartom upravíte podrobnosti archivovanej položky, bude presunutá späť do trezoru." + }, + "restartPremium": { + "message": "Reštartovať Prémium" + }, "ppremiumSignUpReports": { "message": "Správy o sile hesla, zabezpečení účtov a únikoch dát ktoré vám pomôžu udržať vaše kontá v bezpečí." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "E-maily" + }, "phone": { "message": "Telefón" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Položka bola natrvalo odstránená" }, + "archivedItemRestored": { + "message": "Archivovaná položka bola obnovená" + }, "restoreItem": { "message": "Obnoviť položku" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden nebude požadovať ukladanie prihlasovacích údajov pre tieto domény. Aby sa zmeny prejavili, musíte stránku obnoviť." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden nebude požadovať ukladanie prihlasovacích údajov pre tieto domény pre všetky prihlásené účty. Aby sa zmeny prejavili, musíte stránku obnoviť." - }, "blockedDomainsDesc": { "message": "Automatické vypĺňanie a ďalšie súvisiace funkcie sa na týchto webových stránkach nebudú ponúkať. Aby sa zmeny prejavili, musíte stránku obnoviť." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Vlastné" }, - "sendPasswordDescV3": { - "message": "Pridajte voliteľné heslo pre príjemcov na prístup k tomuto Sendu.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Nový Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send bol upravený", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Zobraziť rozšírenie v novom okne?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Na vytvorenie Sendu so súborom musíte zobraziť rozšírenie v novom okne.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Ak chcete zvoliť súbor, otvorte rozšírenie v bočnom paneli (ak je to možné) alebo kliknite do tohto okna kliknutím na tento banner." - }, - "sendFirefoxFileWarning": { - "message": "Ak chcete zvoliť súbor pomocou prehliadača Firefox, otvorte rozšírenie v bočnom paneli alebo kliknite do tohto okna kliknutím na tento banner." - }, - "sendSafariFileWarning": { - "message": "Ak chcete zvoliť súbor pomocou Safari, kliknite na tento banner a otvorte nové okno." - }, "popOut": { "message": "Zobraziť v novom okne" }, - "sendFileCalloutHeader": { - "message": "Skôr než začnete" - }, "expirationDateIsInvalid": { "message": "Uvedený dátum exspirácie nie je platný." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Ďalšie možnosti" + }, "moreOptionsTitle": { "message": "Ďalšie možnosti - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Správcovská konzola" }, + "admin": { + "message": "Správca" + }, + "automaticUserConfirmation": { + "message": "Automatické potvrdenie používateľa" + }, + "automaticUserConfirmationHint": { + "message": "Automaticky potvrdzovať čakajúcich používateľov, keď je toto zariadenie odomknuté" + }, + "autoConfirmOnboardingCallout": { + "message": "Šetrite čas automatickým potvrdzovaním používateľa" + }, + "autoConfirmWarning": { + "message": "Môže mať vplyv na bezpečnosť údajov vašej organizácie. " + }, + "autoConfirmWarningLink": { + "message": "Dozvedieť sa viac o rizikách" + }, + "autoConfirmSetup": { + "message": "Automaticky potvrdzovať nových používateľov" + }, + "autoConfirmSetupDesc": { + "message": "Noví používatelia budú automaticky potvrdení, keď je toto zariadenie odomknuté." + }, + "autoConfirmSetupHint": { + "message": "Aké sú potenciálne bezpečnostné riziká?" + }, + "autoConfirmEnabled": { + "message": "Zapnuté automatické potvrdzovanie" + }, + "availableNow": { + "message": "Teraz dostupné" + }, "accountSecurity": { "message": "Zabezpečenie účtu" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Stiahnuť prílohu" + }, "downloadBitwarden": { "message": "Stiahnuť Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Skryť spôsob zisťovania zhody $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Zobraziť spôsob mapovania" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Skryť spôsob mapovania" }, "autoFillOnPageLoad": { "message": "Automaticky vyplniť pri načítaní stránky?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra široké" }, + "narrow": { + "message": "Úzke" + }, "sshKeyWrongPassword": { "message": "Zadané heslo je nesprávne." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Číslo karty" }, + "errorCannotDecrypt": { + "message": "Chyba: Nedá sa dešifrovať" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Vaša organizácia už nepoužíva hlavné heslá na prihlásenie do Bitwardenu. Ak chcete pokračovať, overte organizáciu a doménu." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Prečo to vidím?" }, + "items": { + "message": "Položky" + }, + "searchResults": { + "message": "Výsledky vyhľadávania" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Chránené e-mailom" + }, + "sendPasswordHelperText": { + "message": "Jednotlivci budú musieť zadať heslo, aby mohli zobraziť tento Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index 7d9eef643a3..07ee84ab810 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Higiena gesel, zdravje računa in poročila o kraji podatkov, ki vam pomagajo ohraniti varnost vašega trezorja." }, @@ -2027,6 +2054,9 @@ "email": { "message": "E-pošta" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Element trajno izbrisan" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Obnovi element" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Za te domene Bitwarden ne bo predlagal shranjevanja prijavnih podatkov. Sprememba nastavitev stopi v veljavo šele, ko osvežite stran." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Po meri" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Nova pošiljka", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Pošiljka shranjena", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." - }, - "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." - }, - "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Preden pričnete" - }, "expirationDateIsInvalid": { "message": "Datum poteka ni veljaven." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4881,7 +4929,7 @@ "message": "Organization is deactivated" }, "owner": { - "message": "Owner" + "message": "Lastnik" }, "selfOwnershipLabel": { "message": "You", @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 223d5909d41..0ad71788514 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": "Употребити једнократну пријаву" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Ставка је послата у архиву" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Ставка враћена из архиве" }, "archiveItem": { "message": "Архивирај ставку" }, - "archiveItemConfirmDesc": { - "message": "Архивиране ставке су искључене из општих резултата претраге и предлога за ауто попуњавање. Јесте ли сигурни да желите да архивирате ову ставку?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "Премијум чланство је неопходно за употребу Архиве." @@ -978,6 +990,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Локација" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Приоритарне опције пријаве у два корака као што су YubiKey и Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Извештаји о хигијени лозинки, здравственом стању налога и кршењу података да бисте заштитили сеф." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Имејл" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Телефон" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Трајно избрисана ставка" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Врати ставку" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden неће тражити да сачува податке за пријављивање за ове домене. Морате освежити страницу да би промене ступиле на снагу." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden неће тражити да сачува податке за пријављивање за ове домене за све пријављене налоге. Морате освежити страницу да би промене ступиле на снагу." - }, "blockedDomainsDesc": { "message": "Аутоматско попуњавање и сродне функције неће бити понуђене за ове веб сајтове. Морате освежити страницу да би се измене примениле." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Друго" }, - "sendPasswordDescV3": { - "message": "Додајте опционалну лозинку за примаоце да приступе овом Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Креирај нови „Send“", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Измењено слање", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Искачући додатак?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Да бисте креирали датотеку Send, потребно је да искочите екстензију у нови прозор.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Да бисте изабрали датотеку, отворите екстензију на бочној траци (ако је могуће) или отворите у нови прозор кликом на овај банер." - }, - "sendFirefoxFileWarning": { - "message": "Да бисте изабрали датотеку са Firefox-ом, отворите екстензију на бочној траци или отворите у нови прозор кликом на овај банер." - }, - "sendSafariFileWarning": { - "message": "Да бисте изабрали датотеку са Safari-ом, отворите у нови прозор кликом на овај банер." - }, "popOut": { "message": "Искочити" }, - "sendFileCalloutHeader": { - "message": "Пре него што почнеш" - }, "expirationDateIsInvalid": { "message": "Наведени датум истека није исправан." }, @@ -3346,6 +3352,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": "Грешка при декрипцији" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Више опција - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Администраторска конзола" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Безбедност налога" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Преузети Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Сакриј откривање подударања $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Ауто-попуњавање при учитавању странице?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Врло широко" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Лозинка коју сте унели није тачна." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Број картице" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Ваша организација више не користи главне лозинке за пријаву на Bitwarden. Да бисте наставили, верификујте организацију и домен." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Зашто видите ово?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index 4a9fc27dd84..08cec673d27 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" }, @@ -573,20 +576,29 @@ "itemWasSentToArchive": { "message": "Objektet skickades till arkivet" }, + "itemWasUnarchived": { + "message": "Objektet har avarkiverats" + }, "itemUnarchived": { "message": "Objektet har avarkiverats" }, "archiveItem": { "message": "Arkivera objekt" }, - "archiveItemConfirmDesc": { - "message": "Arkiverade objekt är exkluderade från allmänna sökresultat och förslag för autofyll. Är du säker på att du vill arkivera detta objekt?" + "archiveItemDialogContent": { + "message": "När du har arkiverat kommer detta objekt att uteslutas från sökresultat och förslag till autofyll." + }, + "archived": { + "message": "Arkiverade" + }, + "unarchiveAndSave": { + "message": "Avarkivera och spara" }, "upgradeToUseArchive": { "message": "Ett premium-medlemskap krävs för att använda Arkiv." }, "itemRestored": { - "message": "Item has been restored" + "message": "Objektet har återställts" }, "edit": { "message": "Redigera" @@ -978,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" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Premium-alternativ för tvåstegsverifiering, såsom YubiKey och Duo." }, + "premiumSubscriptionEnded": { + "message": "Ditt Premium-abonnemang avslutades" + }, + "archivePremiumRestart": { + "message": "För att återfå åtkomst till ditt arkiv, starta om Premium-abonnemanget. Om du redigerar detaljer för ett arkiverat objekt innan du startar om kommer det att flyttas tillbaka till ditt valv." + }, + "restartPremium": { + "message": "Starta om Premium" + }, "ppremiumSignUpReports": { "message": "Lösenordshygien, kontohälsa och dataintrångsrapporter för att hålla ditt valv säkert." }, @@ -2027,6 +2054,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "E-post" + }, "phone": { "message": "Telefon" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Raderade objekt permanent" }, + "archivedItemRestored": { + "message": "Arkiverat objekt återställt" + }, "restoreItem": { "message": "Återställ objekt" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden kommer inte att fråga om att få spara inloggningsuppgifter för dessa domäner. Du måste uppdatera sidan för att ändringarna ska träda i kraft." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden kommer inte att be om att få spara inloggningsuppgifter för dessa domäner för alla inloggade konton. Du måste uppdatera sidan för att ändringarna ska träda i kraft." - }, "blockedDomainsDesc": { "message": "Autofyll och andra relaterade funktioner kommer inte att erbjudas för dessa webbplatser. Du måste uppdatera sidan för att ändringarna ska träda i kraft." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Anpassad" }, - "sendPasswordDescV3": { - "message": "Lägg till ett valfritt lösenord för att mottagarna ska få åtkomst till detta meddelande.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Ny Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send har sparats", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out-förlängning?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "För att skapa en fil Skicka, måste du popa ut förlängningen till ett nytt fönster.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "För att välja en fil, öppna tillägget i sidofältet (om möjligt) eller skapa ett nytt fönster genom att klicka på denna banner." - }, - "sendFirefoxFileWarning": { - "message": "För att välja en fil med Firefox, öppna tillägget i sidofältet eller öppna ett nytt fönster genom att klicka på denna banner." - }, - "sendSafariFileWarning": { - "message": "För att välja en fil med Safari, öppna ett nytt fönster genom att klicka på denna banner." - }, "popOut": { "message": "Popa ut" }, - "sendFileCalloutHeader": { - "message": "Innan du börjar" - }, "expirationDateIsInvalid": { "message": "Det angivna utgångsdatumet är inte giltigt." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Fler alternativ" + }, "moreOptionsTitle": { "message": "Fler alternativ - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Adminkonsol" }, + "admin": { + "message": "Administratör" + }, + "automaticUserConfirmation": { + "message": "Automatisk bekräftelse av användare" + }, + "automaticUserConfirmationHint": { + "message": "Bekräfta automatiskt väntande användare medan enheten är olåst" + }, + "autoConfirmOnboardingCallout": { + "message": "Spara tid med automatisk användarbekräftelse" + }, + "autoConfirmWarning": { + "message": "Detta kan påverka din organisations datasäkerhet. " + }, + "autoConfirmWarningLink": { + "message": "Läs mer om riskerna" + }, + "autoConfirmSetup": { + "message": "Bekräfta nya användare automatiskt" + }, + "autoConfirmSetupDesc": { + "message": "Nya användare kommer automatiskt att bekräftas när denna enhet är upplåst." + }, + "autoConfirmSetupHint": { + "message": "Vilka är de potentiella säkerhetsriskerna?" + }, + "autoConfirmEnabled": { + "message": "Aktiverade automatisk bekräftelse" + }, + "availableNow": { + "message": "Tillgänglig nu" + }, "accountSecurity": { "message": "Kontosäkerhet" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Ladda ned bilaga" + }, "downloadBitwarden": { "message": "Ladda ner Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Detektering av dold matchning $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Visa matchningsdetektering" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Dölj matchdetektering" }, "autoFillOnPageLoad": { "message": "Autofyll vid sidladdning?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra bred" }, + "narrow": { + "message": "Smal" + }, "sshKeyWrongPassword": { "message": "Lösenordet du har angett är felaktigt." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Kortnummer" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Din organisation använder inte längre huvudlösenord för att logga in på Bitwarden. För att fortsätta, verifiera organisationen och domänen." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Varför ser jag det här?" }, + "items": { + "message": "Objekt" + }, + "searchResults": { + "message": "Sökresultat" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individer måste ange lösenordet för att visa denna Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index d11b2329b3f..374c0968d2c 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": "ஒற்றை உள்நுழைவைப் பயன்படுத்தவும்" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "ஆவணம் காப்பகத்திற்கு அனுப்பப்பட்டது" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "காப்பகம் மீட்டெடுக்கப்பட்டது" }, "archiveItem": { "message": "உருப்படியைக் காப்பகப்படுத்து" }, - "archiveItemConfirmDesc": { - "message": "காப்பகப்படுத்தப்பட்ட உருப்படிகள் பொதுவான தேடல் முடிவுகள் மற்றும் தானியங்குநிரப்பு பரிந்துரைகளிலிருந்து விலக்கப்பட்டுள்ளன. இந்த உருப்படியை காப்பகப்படுத்த விரும்புகிறீர்களா?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "காப்பகத்தைப் பயன்படுத்த பிரீமியம் உறுப்பினர் தேவை." @@ -978,6 +990,12 @@ "no": { "message": "இல்லை" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "இருப்பிடம்" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "YubiKey மற்றும் Duo போன்ற பிரத்யேக டூ-ஸ்டெப் உள்நுழைவு விருப்பங்கள்." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "உங்கள் வால்ட்டைப் பாதுகாப்பாக வைத்திருக்க கடவுச்சொல் சுகாதாரம், கணக்கின் ஆரோக்கியம் மற்றும் டேட்டா மீறல் அறிக்கைகள்." }, @@ -2027,6 +2054,9 @@ "email": { "message": "மின்னஞ்சல்" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "தொலைபேசி" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "பொருள் நிரந்தரமாக நீக்கப்பட்டது" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "பொருளை மீட்டமை" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "இந்த டொமைன்களுக்கான உள்நுழைவு விவரங்களைச் சேமிக்க Bitwarden கேட்காது. மாற்றங்கள் நடைமுறைக்கு வர பக்கத்தை மீண்டும் மீட்டமைக்க வேண்டும்." }, - "excludedDomainsDescAlt": { - "message": "உள்நுழைந்த அனைத்து கணக்குகளுக்கும் இந்த டொமைன்களுக்கான உள்நுழைவு விவரங்களைச் சேமிக்க Bitwarden கேட்காது. மாற்றங்கள் நடைமுறைக்கு வர பக்கத்தை மீண்டும் மீட்டமைக்க வேண்டும்." - }, "blockedDomainsDesc": { "message": "இந்த இணையதளங்களுக்கு தானாக நிரப்புதல் மற்றும் பிற தொடர்புடைய அம்சங்கள் வழங்கப்படாது. மாற்றங்கள் நடைமுறைக்கு வர பக்கத்தை மீண்டும் மீட்டமைக்க வேண்டும்." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "தனிப்பயன்" }, - "sendPasswordDescV3": { - "message": "பெறுநர்கள் இந்த அனுப்புதலை அணுக ஒரு விருப்ப கடவுச்சொல்லைச் சேர்க்கவும்.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "புதிய அனுப்பு", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "அனுப்பு சேமிக்கப்பட்டது", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "நீட்டிப்பை வெளியேற்றவா?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "ஒரு கோப்பு அனுப்புதலை உருவாக்க, நீங்கள் நீட்டிப்பை ஒரு புதிய சாளரத்திற்கு வெளியேற்ற வேண்டும்.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "ஒரு கோப்பைத் தேர்வு செய்ய, நீட்டிப்பை பக்கவாட்டில் (முடிந்தால்) திறக்கவும் அல்லது இந்த பதாகையைக் கிளிக் செய்வதன் மூலம் புதிய சாளரத்திற்கு வெளியேற்றவும்." - }, - "sendFirefoxFileWarning": { - "message": "Firefox ஐப் பயன்படுத்தி ஒரு கோப்பைத் தேர்வு செய்ய, நீட்டிப்பை பக்கவாட்டில் திறக்கவும் அல்லது இந்த பதாகையைக் கிளிக் செய்வதன் மூலம் புதிய சாளரத்திற்கு வெளியேற்றவும்." - }, - "sendSafariFileWarning": { - "message": "Safari ஐப் பயன்படுத்தி ஒரு கோப்பைத் தேர்வு செய்ய, இந்த பதாகையைக் கிளிக் செய்வதன் மூலம் புதிய சாளரத்திற்கு வெளியேற்றவும்." - }, "popOut": { "message": "வெளியேற்றவும்" }, - "sendFileCalloutHeader": { - "message": "நீங்கள் தொடங்குவதற்கு முன்" - }, "expirationDateIsInvalid": { "message": "வழங்கப்பட்ட காலாவதி தேதி செல்லாது." }, @@ -3346,6 +3352,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": "குறியாக்கம் நீக்கப் பிழை" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "மேலும் விருப்பத்தேர்வுகள் - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "நிர்வாகக் கன்சோல்" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "கணக்கு பாதுகாப்பு" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Bitwarden-ஐப் பதிவிறக்கு" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "பொருத்தமான கண்டறிதலை மறை $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "பக்கத்தை ஏற்றும்போது தானாக நிரப்ப வேண்டுமா?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "அதிக அகலமானது" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "நீங்கள் உள்ளிடப்பட்ட கடவுச்சொல் தவறானது." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "அட்டை எண்" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index ea4f2b08a85..336e8783b75 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -978,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Custom" }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Pop out extension?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "To create a file Send, you need to pop out the extension to a new window.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." - }, - "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." - }, - "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." - }, "popOut": { "message": "Pop out" }, - "sendFileCalloutHeader": { - "message": "Before you start" - }, "expirationDateIsInvalid": { "message": "The expiration date provided is not valid." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Admin Console" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index f12bed9ea18..5af1c742f45 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "ย้ายรายการไปที่จัดเก็บถาวรแล้ว" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "เลิกจัดเก็บถาวรรายการแล้ว" }, "archiveItem": { "message": "จัดเก็บรายการถาวร" }, - "archiveItemConfirmDesc": { - "message": "รายการที่จัดเก็บถาวรจะไม่ถูกรวมในผลการค้นหาทั่วไปและคำแนะนำการป้อนอัตโนมัติ ยืนยันที่จะจัดเก็บรายการนี้ถาวรหรือไม่" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "ต้องเป็นสมาชิกพรีเมียมจึงจะใช้งานฟีเจอร์จัดเก็บถาวรได้" @@ -978,6 +990,12 @@ "no": { "message": "ไม่" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "ตำแหน่งที่ตั้ง" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "ตัวเลือกการเข้าสู่ระบบ 2 ขั้นตอนแบบพิเศษ เช่น YubiKey และ Duo" }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "รายงานความปลอดภัยของรหัสผ่าน สุขภาพบัญชี และข้อมูลรั่วไหล เพื่อรักษาตู้นิรภัยให้ปลอดภัย" }, @@ -2027,6 +2054,9 @@ "email": { "message": "อีเมล" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "โทรศัพท์" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "ลบรายการถาวรแล้ว" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "กู้คืนรายการ" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden จะไม่ถามให้บันทึกรายละเอียดการเข้าสู่ระบบสำหรับโดเมนเหล่านี้ คุณต้องรีเฟรชหน้าเว็บเพื่อให้การเปลี่ยนแปลงมีผล" }, - "excludedDomainsDescAlt": { - "message": "Bitwarden จะไม่ถามให้บันทึกรายละเอียดการเข้าสู่ระบบสำหรับโดเมนเหล่านี้สำหรับทุกบัญชีที่เข้าสู่ระบบ คุณต้องรีเฟรชหน้าเว็บเพื่อให้การเปลี่ยนแปลงมีผล" - }, "blockedDomainsDesc": { "message": "การป้อนอัตโนมัติและฟีเจอร์อื่น ๆ ที่เกี่ยวข้องจะไม่พร้อมใช้งานสำหรับเว็บไซต์เหล่านี้ คุณต้องรีเฟรชหน้าเว็บเพื่อให้การเปลี่ยนแปลงมีผล" }, @@ -3002,10 +3032,6 @@ "custom": { "message": "กำหนดเอง" }, - "sendPasswordDescV3": { - "message": "เพิ่มรหัสผ่าน (ไม่บังคับ) เพื่อให้ผู้รับใช้เข้าถึง Send นี้", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Send ใหม่", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "บันทึก Send แล้ว", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "แยกหน้าต่างส่วนขยายหรือไม่", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "หากต้องการสร้างไฟล์ Send คุณต้องแยกส่วนขยายออกมาเป็นหน้าต่างใหม่", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "หากต้องการเลือกไฟล์ ให้เปิดส่วนขยายในแถบด้านข้าง (ถ้าทำได้) หรือแยกเป็นหน้าต่างใหม่โดยคลิกที่แบนเนอร์นี้" - }, - "sendFirefoxFileWarning": { - "message": "หากต้องการเลือกไฟล์โดยใช้ Firefox ให้เปิดส่วนขยายในแถบด้านข้างหรือแยกเป็นหน้าต่างใหม่โดยคลิกที่แบนเนอร์นี้" - }, - "sendSafariFileWarning": { - "message": "หากต้องการเลือกไฟล์โดยใช้ Safari ให้แยกเป็นหน้าต่างใหม่โดยคลิกที่แบนเนอร์นี้" - }, "popOut": { "message": "แยกหน้าต่าง" }, - "sendFileCalloutHeader": { - "message": "ก่อนที่คุณจะเริ่ม" - }, "expirationDateIsInvalid": { "message": "วันที่หมดอายุที่ระบุไม่ถูกต้อง" }, @@ -3346,6 +3352,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": "ข้อผิดพลาดในการถอดรหัส" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "ตัวเลือกเพิ่มเติม - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "คอนโซลผู้ดูแลระบบ" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "ความปลอดภัยของบัญชี" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "ดาวน์โหลด Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "ซ่อนการตรวจสอบการจับคู่ $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "ป้อนอัตโนมัติเมื่อโหลดหน้าเว็บหรือไม่" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "กว้างพิเศษ" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "รหัสผ่านที่คุณป้อนไม่ถูกต้อง" }, @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "หมายเลขบัตร" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "องค์กรของคุณไม่ใช้รหัสผ่านหลักในการเข้าสู่ระบบ Bitwarden อีกต่อไป หากต้องการดำเนินการต่อ ให้ยืนยันองค์กรและโดเมน" }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "ทำไมฉันจึงเห็นสิ่งนี้" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index 84b240c2397..33f600fb7a7 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" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "Kayıt arşive gönderildi" }, + "itemWasUnarchived": { + "message": "Kayıt arşivden çıkarıldı" + }, "itemUnarchived": { "message": "Kayıt arşivden çıkarıldı" }, "archiveItem": { "message": "Kaydı arşivle" }, - "archiveItemConfirmDesc": { - "message": "Arşivlenmiş kayıtlar genel arama sonuçları ve otomatik doldurma önerilerinden hariç tutulur. Bu kaydı arşivlemek istediğine emin misin?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Arşivlendi" + }, + "unarchiveAndSave": { + "message": "Arşivden çıkar ve kaydet" }, "upgradeToUseArchive": { "message": "Arşivi kullanmak için premium üyelik gereklidir." @@ -978,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" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "YubiKey ve Duo gibi marka bazlı iki aşamalı giriş seçenekleri." }, + "premiumSubscriptionEnded": { + "message": "Premium aboneliğiniz sona erdi" + }, + "archivePremiumRestart": { + "message": "Arşivinize yeniden erişebilmek için Premium aboneliğinizi yeniden başlatın. Yeniden başlatmadan önce arşivlenmiş bir kaydın ayrıntılarını düzenlerseniz kayıt tekrar kasanıza taşınır." + }, + "restartPremium": { + "message": "Premium’u yeniden başlat" + }, "ppremiumSignUpReports": { "message": "Kasanızı güvende tutmak için parola hijyeni, hesap sağlığı ve veri ihlali raporları." }, @@ -2027,6 +2054,9 @@ "email": { "message": "E-posta" }, + "emails": { + "message": "E-postalar" + }, "phone": { "message": "Telefon" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Kayıt kalıcı olarak silindi" }, + "archivedItemRestored": { + "message": "Arşivlenmiş kayıt geri getirildi" + }, "restoreItem": { "message": "Kaydı geri yükle" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden bu alan adlarında hesaplarınızı kaydetmeyi sormayacaktır. Değişikliklerin etkili olması için sayfayı yenilemelisiniz." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden, oturum açmış tüm hesaplar için bu alan adlarının hesap bilgilerini kaydetmeyi sormayacaktır. Değişikliklerin etkili olması için sayfayı yenilemeniz gerekir." - }, "blockedDomainsDesc": { "message": "Bu siteler için otomatik doldurma ve diğer ilgili özellikler önerilmeyecektir. Değişikliklerin devreye girmesi için sayfayı yenilemelisiniz." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Özel" }, - "sendPasswordDescV3": { - "message": "Alıcıların bu Send'e erişmesi için isterseniz parola ekleyebilirsiniz.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Yeni Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Send kaydedildi", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Uzantı dışarı alınsın mı?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Dosya Send'i oluşturmak için uzantıyı yeni bir pencere halinde dışarı almalısınız.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Dosya seçmek için eklentiyi kenar çubuğunda açın (mümkünse) veya bu banner'a tıklayarak yeni bir pencerede açın." - }, - "sendFirefoxFileWarning": { - "message": "Firefox ile dosya seçmek için eklentiyi kenar çubuğunda açın veya bu banner'a tıklayarak yeni bir pencerede açın." - }, - "sendSafariFileWarning": { - "message": "Safari ile dosya seçmek için bu banner'a tıklayarak eklentiyi yeni bir pencerede açın." - }, "popOut": { "message": "Dışarı al" }, - "sendFileCalloutHeader": { - "message": "Başlamadan önce" - }, "expirationDateIsInvalid": { "message": "Belirtilen son kullanma tarihi geçersiz." }, @@ -3346,6 +3352,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" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Diğer seçenekler" + }, "moreOptionsTitle": { "message": "Diğer seçenekler - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "Yönetici Konsolu" }, + "admin": { + "message": "Yönetici" + }, + "automaticUserConfirmation": { + "message": "Otomatik kullanıcı onayı" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Yeni kullanıcıları otomatik onayla" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Hesap güvenliği" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Ek dosyayı indir" + }, "downloadBitwarden": { "message": "Bitwarden’ı indirin" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "$WEBSITE$ eşleşme tespitini gizle", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Eşleşme tespitini göster" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Eşleşme tespitini gizle" }, "autoFillOnPageLoad": { "message": "Sayfa yüklenince otomatik doldur" @@ -5219,7 +5267,7 @@ "message": "Web sitesi URI'sini yeniden sıralayın. Kayıtı yukarı veya aşağı taşımak için ok tuşunu kullanın." }, "reorderFieldUp": { - "message": "$LABEL$ yukarı taşındı, konum: $LENGTH$'in $INDEX$'i", + "message": "$LABEL$ yukarı taşındı. Konum: $LENGTH$/$INDEX$", "placeholders": { "label": { "content": "$1", @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Ekstra geniş" }, + "narrow": { + "message": "Dar" + }, "sshKeyWrongPassword": { "message": "Girdiğiniz parola yanlış." }, @@ -5671,7 +5722,7 @@ "message": "Vulnerable password." }, "changeNow": { - "message": "Change now" + "message": "Şimdi değiştir" }, "missingWebsite": { "message": "Web sitesi eksik" @@ -5748,7 +5799,7 @@ "message": "Oltalama tespiti hakkında daha fazla bilgi edinin" }, "protectedBy": { - "message": "$PRODUCT$ tarafından korunuyor", + "message": "$PRODUCT$ ile korunuyor", "placeholders": { "product": { "content": "$1", @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Kart numarası" }, + "errorCannotDecrypt": { + "message": "Hata: Deşifre edilemiyor" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Bunu neden görüyorum?" }, + "items": { + "message": "Kayıtlar" + }, + "searchResults": { + "message": "Arama sonuçları" + }, "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" + }, + "downloadBitwardenApps": { + "message": "Bitwarden uygulamalarını indir" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Bu Send'i görmek isteyen kişilerin parola girmesi gerekecektir", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 2688995d6a7..b703cfeefce 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": "Використати єдиний вхід" }, @@ -573,20 +576,29 @@ "itemWasSentToArchive": { "message": "Запис архівовано" }, + "itemWasUnarchived": { + "message": "Запис розархівовано" + }, "itemUnarchived": { "message": "Запис розархівовано" }, "archiveItem": { "message": "Архівувати запис" }, - "archiveItemConfirmDesc": { - "message": "Архівовані записи виключаються з результатів звичайного пошуку та пропозицій автозаповнення. Ви дійсно хочете архівувати цей запис?" + "archiveItemDialogContent": { + "message": "Після архівації цей запис буде виключено з результатів пошуку і пропозицій автозаповнення." + }, + "archived": { + "message": "Архівовано" + }, + "unarchiveAndSave": { + "message": "Розархівувати й зберегти" }, "upgradeToUseArchive": { "message": "Для використання архіву необхідна передплата Premium." }, "itemRestored": { - "message": "Item has been restored" + "message": "Запис відновлено" }, "edit": { "message": "Змінити" @@ -978,6 +990,12 @@ "no": { "message": "Ні" }, + "noAuth": { + "message": "Будь-хто з посиланням" + }, + "anyOneWithPassword": { + "message": "Будь-хто зі встановленим вами паролем" + }, "location": { "message": "Розташування" }, @@ -1326,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": { @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Додаткові можливості двоетапної авторизації, як-от YubiKey та Duo." }, + "premiumSubscriptionEnded": { + "message": "Ваша передплата Premium завершилась" + }, + "archivePremiumRestart": { + "message": "Щоб відновити доступ до архіву, поновіть передплату Premium. Якщо ви редагуєте архівований запис перед поновленням, його буде повернуто назад у ваше сховище." + }, + "restartPremium": { + "message": "Поновити Premium" + }, "ppremiumSignUpReports": { "message": "Гігієна паролів, здоров'я облікового запису, а також звіти про вразливості даних, щоб зберігати ваше сховище в безпеці." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Е-пошта" }, + "emails": { + "message": "Е-пошти" + }, "phone": { "message": "Телефон" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Запис остаточно видалено" }, + "archivedItemRestored": { + "message": "Архівований запис відновлено" + }, "restoreItem": { "message": "Відновити запис" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden не запитуватиме про збереження даних входу для цих доменів. Потрібно оновити сторінку для застосування змін." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden не запитуватиме про збереження даних входу для цих доменів для всіх облікових записів, до яких виконано вхід. Потрібно оновити сторінку для застосування змін." - }, "blockedDomainsDesc": { "message": "Автозаповнення та інші пов'язані функції не пропонуватимуться для цих вебсайтів. Вам слід оновити сторінку для застосування змін." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Власний" }, - "sendPasswordDescV3": { - "message": "За бажання додайте пароль для отримувачів цього відправлення.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Нове відправлення", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Відправлення збережено", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Відкріпити розширення?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Щоб створити відправлення файлу, необхідно відкріпити розширення в окреме вікно.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Щоб вибрати файл, відкрийте розширення в бічній панелі (якщо можливо) або в новому вікні, натиснувши цей банер." - }, - "sendFirefoxFileWarning": { - "message": "Щоб вибрати файл з використанням Firefox, відкрийте розширення в бічній панелі або в новому вікні, натиснувши цей банер." - }, - "sendSafariFileWarning": { - "message": "Щоб вибрати файл з використанням Safari, відкрийте розширення в новому вікні, натиснувши на цей банер." - }, "popOut": { "message": "Відкріпити" }, - "sendFileCalloutHeader": { - "message": "Перед початком" - }, "expirationDateIsInvalid": { "message": "Вказано недійсний термін дії." }, @@ -3346,6 +3352,12 @@ "error": { "message": "Помилка" }, + "prfUnlockFailed": { + "message": "Не вдалося розблокувати за допомогою ключа доступу. Повторіть спробу або скористайтеся іншим способом розблокування." + }, + "noPrfCredentialsAvailable": { + "message": "Немає ключів доступу з підтримкою PRF, доступних для розблокування. Спочатку увійдіть з ключем доступу." + }, "decryptionError": { "message": "Помилка розшифрування" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Більше опцій" + }, "moreOptionsTitle": { "message": "Інші можливості – $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "консолі адміністратора," }, + "admin": { + "message": "Адміністратор" + }, + "automaticUserConfirmation": { + "message": "Автоматичне підтвердження користувачів" + }, + "automaticUserConfirmationHint": { + "message": "Автоматично підтверджувати користувачів, які перебувають у черзі, поки цей пристрій розблокований" + }, + "autoConfirmOnboardingCallout": { + "message": "Заощаджуйте час завдяки автоматичному підтвердженню користувачів" + }, + "autoConfirmWarning": { + "message": "Це може вплинути на безпеку даних вашої організації. " + }, + "autoConfirmWarningLink": { + "message": "Дізнатися про ризики" + }, + "autoConfirmSetup": { + "message": "Автоматично підтверджувати нових користувачів" + }, + "autoConfirmSetupDesc": { + "message": "Нові користувачі будуть автоматично підтверджені, якщо пристрій розблоковано." + }, + "autoConfirmSetupHint": { + "message": "Які потенційні ризики безпеки?" + }, + "autoConfirmEnabled": { + "message": "Автоматичне підтвердження увімкнено" + }, + "availableNow": { + "message": "Доступно зараз" + }, "accountSecurity": { "message": "Безпека облікового запису" }, @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Завантажити вкладення" + }, "downloadBitwarden": { "message": "Завантажити Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Приховати виявлення збігів $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Показати виявлення збігів" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Приховати виявлення збігів" }, "autoFillOnPageLoad": { "message": "Автоматично заповнювати під час завантаження сторінки?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Дуже широке" }, + "narrow": { + "message": "Вузький" + }, "sshKeyWrongPassword": { "message": "Ви ввели неправильний пароль." }, @@ -5668,10 +5719,10 @@ "message": "Цей запис ризикований, і не має адреси вебсайту. Додайте адресу вебсайту і змініть пароль для вдосконалення безпеки." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Вразливий пароль." }, "changeNow": { - "message": "Change now" + "message": "Змінити зараз" }, "missingWebsite": { "message": "Немає вебсайту" @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "Номер картки" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Ваша організація більше не використовує головні паролі для входу в Bitwarden. Щоб продовжити, підтвердіть організацію та домен." }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Чому я це бачу?" }, + "items": { + "message": "Записи" + }, + "searchResults": { + "message": "Результати пошуку" + }, "resizeSideNavigation": { - "message": "Resize side navigation" + "message": "Змінити розмір бічної панелі" + }, + "whoCanView": { + "message": "Хто може переглядати" + }, + "specificPeople": { + "message": "Певні люди" + }, + "emailVerificationDesc": { + "message": "Після того, як ви поділитеся цим посиланням на відправлення, особам необхідно буде підтвердити свої е-пошти за допомогою коду, щоб переглянути це відправлення." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Введіть декілька адрес е-пошти, розділяючи їх комою." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Е-пошту захищено" + }, + "sendPasswordHelperText": { + "message": "Особам необхідно ввести пароль для перегляду цього відправлення", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index e00aae84e50..0082ee1ece7 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" }, @@ -437,7 +440,7 @@ "message": "Đồng bộ" }, "syncNow": { - "message": "Sync now" + "message": "Đồng bộ ngay" }, "lastSync": { "message": "Đồng bộ lần cuối:" @@ -573,20 +576,29 @@ "itemWasSentToArchive": { "message": "Mục đã được chuyển vào kho lưu trữ" }, + "itemWasUnarchived": { + "message": "Mục đã được bỏ lưu trữ" + }, "itemUnarchived": { "message": "Mục đã được bỏ lưu trữ" }, "archiveItem": { "message": "Lưu trữ mục" }, - "archiveItemConfirmDesc": { - "message": "Các mục đã lưu trữ sẽ bị loại khỏi kết quả tìm kiếm chung và gợi ý tự động điền. Bạn có chắc chắn muốn lưu trữ mục này không?" + "archiveItemDialogContent": { + "message": "Khi đã lưu trữ, mục này sẽ bị loại khỏi kết quả tìm kiếm và gợi ý tự động điền." + }, + "archived": { + "message": "Đã lưu trữ" + }, + "unarchiveAndSave": { + "message": "Bỏ lưu trữ và lưu" }, "upgradeToUseArchive": { "message": "Cần là thành viên cao cấp để sử dụng tính năng Lưu trữ." }, "itemRestored": { - "message": "Item has been restored" + "message": "Mục đã được khôi phục" }, "edit": { "message": "Sửa" @@ -978,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í" }, @@ -1326,19 +1344,19 @@ "message": "Xuất từ" }, "exportVerb": { - "message": "Export", + "message": "Xuất", "description": "The verb form of the word Export" }, "exportNoun": { - "message": "Export", + "message": "Xuất", "description": "The noun form of the word Export" }, "importNoun": { - "message": "Import", + "message": "Nhập", "description": "The noun form of the word Import" }, "importVerb": { - "message": "Import", + "message": "Nhập", "description": "The verb form of the word Import" }, "fileFormat": { @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Các tùy chọn xác minh hai bước như YubiKey và Duo." }, + "premiumSubscriptionEnded": { + "message": "Gói đăng ký Cao cấp của bạn đã kết thúc" + }, + "archivePremiumRestart": { + "message": "Để lấy lại quyền truy cập vào lưu trữ của bạn, hãy khởi động lại gói đăng ký Cao cấp. Nếu bạn chỉnh sửa chi tiết cho một mục đã lưu trữ trước khi khởi động lại, mục đó sẽ được chuyển trở lại kho của bạn." + }, + "restartPremium": { + "message": "Khởi động lại gói Cao cấp" + }, "ppremiumSignUpReports": { "message": "Thanh lọc mật khẩu, kiểm tra an toàn tài khoản và các báo cáo rò rỉ dữ liệu để bảo vệ kho dữ liệu của bạn." }, @@ -2027,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Số điện thoại" }, @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Đã xóa vĩnh viễn mục" }, + "archivedItemRestored": { + "message": "Mục lưu trữ đã được khôi phục" + }, "restoreItem": { "message": "Khôi phục mục" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden sẽ không yêu cầu lưu thông tin đăng nhập cho các miền này. Bạn phải làm mới trang để các thay đổi có hiệu lực." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden sẽ không yêu cầu lưu thông tin đăng nhập cho các tên miền này đối với tất cả tài khoản đã đăng nhập. Bạn phải làm mới trang để các thay đổi có hiệu lực." - }, "blockedDomainsDesc": { "message": "Tự động điền và các tính năng liên quan khác sẽ không được cung cấp cho các trang web này. Bạn phải làm mới trang để các thay đổi có hiệu lực." }, @@ -3002,10 +3032,6 @@ "custom": { "message": "Tùy chỉnh" }, - "sendPasswordDescV3": { - "message": "Thêm mật khẩu tùy chọn cho người nhận để có thể truy cập vào Send này.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Tạo Send mới", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3065,29 +3091,9 @@ "message": "Đã lưu Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "Mở rộng tiện ích ra cửa sổ mới?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "Để tạo Send tập tin, bạn cần mở phần mở rộng trong cửa sổ mới.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "Để chọn tập tin, mở tiện ích mở rộng trong thanh bên (nếu có thể) hoặc mở cửa sổ mới bằng cách nhấp vào biểu ngữ này." - }, - "sendFirefoxFileWarning": { - "message": "Để chọn tập tin bằng Firefox, mở tiện ích mở rộng trong thanh bên hoặc mở cửa sổ mới bằng cách nhấp vào biểu ngữ này." - }, - "sendSafariFileWarning": { - "message": "Để chọn tập tin bằng Safari, mở cửa sổ mới bằng cách nhấp vào biểu ngữ này." - }, "popOut": { "message": "Mở rộng" }, - "sendFileCalloutHeader": { - "message": "Trước khi bạn bắt đầu" - }, "expirationDateIsInvalid": { "message": "Ngày hết hạn bạn nhập không hợp lệ." }, @@ -3346,6 +3352,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ã" }, @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Thêm tuỳ chọn" + }, "moreOptionsTitle": { "message": "Thêm tùy chọn - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,17 +4826,50 @@ "adminConsole": { "message": "Bảng điều khiển dành cho quản trị viên" }, + "admin": { + "message": "Quản trị" + }, + "automaticUserConfirmation": { + "message": "Tự động xác nhận người dùng" + }, + "automaticUserConfirmationHint": { + "message": "Tự động xác nhận người dùng đang chờ trong khi thiết bị này được mở khóa" + }, + "autoConfirmOnboardingCallout": { + "message": "Tiết kiệm thời gian với xác nhận người dùng tự động" + }, + "autoConfirmWarning": { + "message": "Điều này có thể ảnh hưởng đến bảo mật dữ liệu của tổ chức bạn. " + }, + "autoConfirmWarningLink": { + "message": "Tìm hiểu về các rủi ro" + }, + "autoConfirmSetup": { + "message": "Tự động xác nhận người dùng mới" + }, + "autoConfirmSetupDesc": { + "message": "Người dùng mới sẽ được tự động xác nhận trong khi thiết bị này được mở khóa." + }, + "autoConfirmSetupHint": { + "message": "Những rủi ro bảo mật tiềm ẩn là gì?" + }, + "autoConfirmEnabled": { + "message": "Đã bật xác nhận tự động" + }, + "availableNow": { + "message": "Khả dụng bây giờ" + }, "accountSecurity": { "message": "Bảo mật tài khoản" }, "phishingBlocker": { - "message": "Phishing Blocker" + "message": "Trình chặn lừa đảo" }, "enablePhishingDetection": { - "message": "Phishing detection" + "message": "Phát hiện lừa đảo" }, "enablePhishingDetectionDesc": { - "message": "Display warning before accessing suspected phishing sites" + "message": "Hiển thị cảnh báo trước khi truy cập các trang web nghi ngờ lừa đảo" }, "notifications": { "message": "Thông báo" @@ -4935,11 +4983,14 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Tải xuống Bitwarden" }, "downloadBitwardenOnAllDevices": { - "message": "Tải xuống Bitwarden trên tất cả các thiết bị" + "message": "Tải về Bitwarden trên mọi thiết bị" }, "getTheMobileApp": { "message": "Tải ứng dụng di động" @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "Ẩn phát hiện trùng khớp $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Hiển thị phát hiện trùng khớp" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Ẩn phát hiện trùng khớp" }, "autoFillOnPageLoad": { "message": "Tự động điền khi tải trang?" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "Rộng hơn" }, + "narrow": { + "message": "Hẹp" + }, "sshKeyWrongPassword": { "message": "Mật khẩu bạn đã nhập không đúng." }, @@ -5668,10 +5719,10 @@ "message": "Thông tin đăng nhập này có rủi ro và thiếu một trang web. Hãy thêm trang web và đổi mật khẩu để tăng cường bảo mật." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Mật khẩu dễ bị tấn công." }, "changeNow": { - "message": "Change now" + "message": "Thay đổi ngay" }, "missingWebsite": { "message": "Thiếu trang web" @@ -5912,44 +5963,47 @@ "cardNumberLabel": { "message": "Số thẻ" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { - "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." + "message": "Tổ chức của bạn không còn sử dụng mật khẩu chính để đăng nhập vào Bitwarden. Để tiếp tục, hãy xác minh tổ chức và tên miền." }, "continueWithLogIn": { - "message": "Continue with log in" + "message": "Tiếp tục đăng nhập" }, "doNotContinue": { - "message": "Do not continue" + "message": "Không tiếp tục" }, "domain": { - "message": "Domain" + "message": "Tên miền" }, "keyConnectorDomainTooltip": { - "message": "This domain will store your account encryption keys, so make sure you trust it. If you're not sure, check with your admin." + "message": "Tên miền này sẽ lưu trữ các khóa mã hóa tài khoản của bạn, vì vậy hãy đảm bảo bạn tin tưởng nó. Nếu bạn không chắc chắn, hãy kiểm tra với quản trị viên của bạn." }, "verifyYourOrganization": { - "message": "Verify your organization to log in" + "message": "Xác minh tổ chức của bạn để đăng nhập" }, "organizationVerified": { - "message": "Organization verified" + "message": "Tổ chức đã được xác minh" }, "domainVerified": { - "message": "Domain verified" + "message": "Tên miền đã được xác minh" }, "leaveOrganizationContent": { - "message": "If you don't verify your organization, your access to the organization will be revoked." + "message": "Nếu bạn không xác minh tổ chức của mình, quyền truy cập vào tổ chức sẽ bị thu hồi." }, "leaveNow": { - "message": "Leave now" + "message": "Rời khỏi ngay" }, "verifyYourDomainToLogin": { - "message": "Verify your domain to log in" + "message": "Xác minh tên miền của bạn để đăng nhập" }, "verifyYourDomainDescription": { - "message": "To continue with log in, verify this domain." + "message": "Để tiếp tục đăng nhập, hãy xác minh tên miền này." }, "confirmKeyConnectorOrganizationUserDescription": { - "message": "To continue with log in, verify the organization and domain." + "message": "Để tiếp tục đăng nhập, hãy xác minh tổ chức và tên miền." }, "sessionTimeoutSettingsAction": { "message": "Hành động sau khi đóng kho" @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "Tại sao tôi thấy điều này?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "resizeSideNavigation": { - "message": "Resize side navigation" + "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" + }, + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, + "emailProtected": { + "message": "Email protected" + }, + "sendPasswordHelperText": { + "message": "Individuals will need to enter the password to view this Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index ef2ac258078..860a8c09f27 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": "使用单点登录" }, @@ -573,14 +576,23 @@ "itemWasSentToArchive": { "message": "项目已发送到归档" }, + "itemWasUnarchived": { + "message": "项目已取消归档" + }, "itemUnarchived": { "message": "项目已取消归档" }, "archiveItem": { "message": "归档项目" }, - "archiveItemConfirmDesc": { - "message": "已归档的项目将被排除在一般搜索结果和自动填充建议之外。确定要归档此项目吗?" + "archiveItemDialogContent": { + "message": "归档后,此项目将被排除在一般搜索结果和自动填充建议之外。" + }, + "archived": { + "message": "已归档" + }, + "unarchiveAndSave": { + "message": "取消归档并保存" }, "upgradeToUseArchive": { "message": "需要高级会员才能使用归档。" @@ -978,6 +990,12 @@ "no": { "message": "否" }, + "noAuth": { + "message": "拥有此链接的任何人" + }, + "anyOneWithPassword": { + "message": "拥有您设置的密码的任何人" + }, "location": { "message": "位置" }, @@ -1536,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "专有的两步登录选项,如 YubiKey 和 Duo。" }, + "premiumSubscriptionEnded": { + "message": "您的高级版订阅已结束" + }, + "archivePremiumRestart": { + "message": "要重新获取归档内容的访问权限,请重启您的高级版订阅。如果您在重启前编辑了某个已归档项目的详细信息,它将被移回您的密码库中。" + }, + "restartPremium": { + "message": "重启高级版" + }, "ppremiumSignUpReports": { "message": "密码健康、账户体检以及数据泄露报告,保障您的密码库安全。" }, @@ -1825,7 +1852,7 @@ "message": "网页加载时如果检测到登录表单,则执行自动填充。" }, "experimentalFeature": { - "message": "不完整或不信任的网站可以利用页面加载时的自动填充功能。" + "message": "被攻破或不受信任的网站可能会利用页面加载时的自动填充功能。" }, "learnMoreAboutAutofillOnPageLoadLinkText": { "message": "进一步了解风险" @@ -2027,6 +2054,9 @@ "email": { "message": "电子邮箱" }, + "emails": { + "message": "电子邮箱" + }, "phone": { "message": "电话" }, @@ -2222,7 +2252,7 @@ } }, "passwordSafe": { - "message": "没有在已知的数据泄露中发现此密码,它暂时比较安全。" + "message": "在任何已知的数据泄露中均未发现此密码。它暂时比较安全。" }, "baseDomain": { "message": "基础域名", @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "项目已永久删除" }, + "archivedItemRestored": { + "message": "归档项目已恢复" + }, "restoreItem": { "message": "恢复项目" }, @@ -2714,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden 将不会提示保存这些域名的登录信息。您必须刷新页面才能使更改生效。" }, - "excludedDomainsDescAlt": { - "message": "Bitwarden 将不会提示为所有已登录账户保存这些域名的登录信息。您必须刷新页面才能使更改生效。" - }, "blockedDomainsDesc": { "message": "将不会为这些网站提供自动填充和其他相关功能。您必须刷新页面才能使更改生效。" }, @@ -2812,7 +2842,7 @@ } }, "changeAtRiskPasswordsFaster": { - "message": "尽快更改有风险的密码" + "message": "尽快更改存在风险的密码" }, "changeAtRiskPasswordsFasterDesc": { "message": "更新您的设置,以便您可以快速自动填充密码并生成新的密码" @@ -2881,7 +2911,7 @@ "message": "排除域名更改已保存" }, "limitSendViews": { - "message": "查看次数限制" + "message": "限制查看次数" }, "limitSendViewsHint": { "message": "达到限额后,任何人无法查看此 Send。", @@ -2970,7 +3000,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "deleteSendPermanentConfirmation": { - "message": "确定要永久删除这个 Send 吗?", + "message": "确定要永久删除此 Send 吗?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editSend": { @@ -3002,10 +3032,6 @@ "custom": { "message": "自定义" }, - "sendPasswordDescV3": { - "message": "添加一个用于接收者访问此 Send 的可选密码。", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "创建 Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3030,11 +3056,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": { @@ -3044,11 +3070,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": { @@ -3065,29 +3091,9 @@ "message": "Send 已保存", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "弹出扩展吗?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "要创建文件 Send,您需要弹出扩展到一个新窗口。", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "要选择文件,请在侧边栏中打开扩展(如果可以),或者点击此横幅来弹出一个新窗口。" - }, - "sendFirefoxFileWarning": { - "message": "要在 Firefox 中选择文件,请在侧边栏中打开本扩展,或者点击此横幅来弹出一个新窗口。" - }, - "sendSafariFileWarning": { - "message": "要在 Safari 中选择文件,请点击此横幅来弹出一个新窗口。" - }, "popOut": { "message": "弹出" }, - "sendFileCalloutHeader": { - "message": "在开始之前" - }, "expirationDateIsInvalid": { "message": "所提供的过期日期无效。" }, @@ -3134,10 +3140,10 @@ "message": "更新主密码" }, "updateMasterPasswordWarning": { - "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,您必须立即更新它。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,您必须立即更新它。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "updateWeakMasterPasswordWarning": { - "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "tdeDisabledMasterPasswordRequired": { "message": "您的组织禁用了信任设备加密。要访问您的密码库,请设置一个主密码。" @@ -3225,7 +3231,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", + "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为「$ACTION$」。", "placeholders": { "hours": { "content": "$1", @@ -3346,6 +3352,12 @@ "error": { "message": "错误" }, + "prfUnlockFailed": { + "message": "使用通行密钥解锁失败。请重试或使用其他解锁方式。" + }, + "noPrfCredentialsAvailable": { + "message": "没有可用于解锁的 PRF 通行密钥。请先使用通行密钥登录。" + }, "decryptionError": { "message": "解密错误" }, @@ -3360,7 +3372,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": { @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "更多选项" + }, "moreOptionsTitle": { "message": "更多选项 - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "管理控制台" }, + "admin": { + "message": "管理员" + }, + "automaticUserConfirmation": { + "message": "自动用户确认" + }, + "automaticUserConfirmationHint": { + "message": "当此设备已解锁时,自动确认待处理的用户" + }, + "autoConfirmOnboardingCallout": { + "message": "通过自动用户确认节省时间" + }, + "autoConfirmWarning": { + "message": "这可能会影响您组织的数据安全。" + }, + "autoConfirmWarningLink": { + "message": "了解此风险" + }, + "autoConfirmSetup": { + "message": "自动确认新用户" + }, + "autoConfirmSetupDesc": { + "message": "当此设备已解锁时,新用户将被自动确认。" + }, + "autoConfirmSetupHint": { + "message": "潜在的安全风险有哪些?" + }, + "autoConfirmEnabled": { + "message": "启用了自动确认" + }, + "availableNow": { + "message": "目前可用" + }, "accountSecurity": { "message": "账户安全" }, @@ -4891,7 +4939,7 @@ "message": "无法访问已停用组织中的项目。请联系您的组织所有者寻求帮助。" }, "additionalInformation": { - "message": "更多信息" + "message": "附加信息" }, "itemHistory": { "message": "项目历史记录" @@ -4935,6 +4983,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "下载附件" + }, "downloadBitwarden": { "message": "下载 Bitwarden" }, @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "隐藏匹配检测 $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "显示匹配检测" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "隐藏匹配检测" }, "autoFillOnPageLoad": { "message": "页面加载时自动填充吗?" @@ -5171,7 +5219,7 @@ "message": "如果您想自动勾选表单复选框(例如记住电子邮箱),请使用复选框型字段" }, "linkedHelpText": { - "message": "当您处理特定网站的自动填充问题时,请使用链接型字段" + "message": "当您遇到特定网站的自动填充问题时,请使用链接型字段。" }, "linkedLabelHelpText": { "message": "输入字段的 html id、名称、aria-label 或占位符。" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "超宽" }, + "narrow": { + "message": "窄" + }, "sshKeyWrongPassword": { "message": "您输入的密码不正确。" }, @@ -5662,7 +5713,7 @@ "message": "要使用生物识别解锁,请更新您的桌面应用程序,或在桌面设置中禁用指纹解锁。" }, "changeAtRiskPassword": { - "message": "更改有风险的密码" + "message": "更改存在风险的密码" }, "changeAtRiskPasswordAndAddWebsite": { "message": "此登录存在风险且缺少网站。请添加网站并更改密码以增强安全性。" @@ -5912,6 +5963,9 @@ "cardNumberLabel": { "message": "卡号" }, + "errorCannotDecrypt": { + "message": "错误:无法解密" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "您的组织已不再使用主密码登录 Bitwarden。要继续,请验证组织和域名。" }, @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "为什么我会看到这个?" }, + "items": { + "message": "项目" + }, + "searchResults": { + "message": "搜索结果" + }, "resizeSideNavigation": { "message": "调整侧边导航栏大小" + }, + "whoCanView": { + "message": "谁可以查看" + }, + "specificPeople": { + "message": "指定人员" + }, + "emailVerificationDesc": { + "message": "分享此 Send 链接后,个人需要使用验证码验证他们的电子邮箱才能查看此 Send。" + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "输入多个电子邮箱(使用逗号分隔)。" + }, + "emailPlaceholder": { + "message": "user@bitwarden.com, user@acme.com" + }, + "downloadBitwardenApps": { + "message": "下载 Bitwarden App" + }, + "emailProtected": { + "message": "电子邮箱保护" + }, + "sendPasswordHelperText": { + "message": "个人需要输入密码才能查看此 Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index b43739639c5..3f387d935d4 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -6,15 +6,15 @@ "message": "Bitwarden logo" }, "extName": { - "message": "Bitwarden 密碼管理器", + "message": "Bitwarden 密碼管理工具", "description": "Extension name, MUST be less than 40 characters (Safari restriction)" }, "extDesc": { - "message": "Bitwarden 是一款安全、免費、跨平台的密碼管理工具。", + "message": "無論在家、在工作場所或外出時,Bitwarden 都能輕鬆保護您的所有密碼、密碼金鑰及敏感資訊。", "description": "Extension description, MUST be less than 112 characters (Safari restriction)" }, "loginOrCreateNewAccount": { - "message": "登入或建立帳戶以存取您的安全密碼庫。" + "message": "登入或建立新帳戶以存取您的安全密碼庫。" }, "inviteAccepted": { "message": "邀請已接受" @@ -26,25 +26,28 @@ "message": "第一次使用 Bitwarden?" }, "logInWithPasskey": { - "message": "使用密碼金鑰登入" + "message": "使用通行金鑰登入" + }, + "unlockWithPasskey": { + "message": "使用密碼金鑰解鎖" }, "useSingleSignOn": { - "message": "使用單一登入" + "message": "使用單一登入(SSO)" }, "yourOrganizationRequiresSingleSignOn": { - "message": "您的組織需要單一登入。" + "message": "您的組織要求使用單一登入。" }, "welcomeBack": { "message": "歡迎回來" }, "setAStrongPassword": { - "message": "設定一個強密碼" + "message": "設定一組高強度密碼" }, "finishCreatingYourAccountBySettingAPassword": { - "message": "設定密碼以完成建立您的帳號" + "message": "請設定密碼以完成帳戶建立" }, "enterpriseSingleSignOn": { - "message": "企業單一登入" + "message": "企業單一登入(SSO)" }, "cancel": { "message": "取消" @@ -62,13 +65,13 @@ "message": "主密碼" }, "masterPassDesc": { - "message": "主密碼是用於存取密碼庫的密碼。它非常重要,請您不要忘記它。若您忘記了主密碼,沒有任何方法能將其復原。" + "message": "主密碼是用來存取您的密碼庫的重要密碼,請務必妥善記住。一旦忘記,將無法復原。" }, "masterPassHintDesc": { - "message": "主密碼提示可以在您忘記主密碼時幫助您回憶主密碼。" + "message": "主密碼提示可在您忘記時幫助您回想主密碼。" }, "masterPassHintText": { - "message": "如果您忘記了密碼,可以傳送密碼提示到您的電子郵件。$CURRENT$ / 最多 $MAXIMUM$ 個字元", + "message": "若您忘記密碼,可將密碼提示傳送至您的電子郵件。\n$CURRENT$/$MAXIMUM$ 個字元上限。", "placeholders": { "current": { "content": "$1", @@ -84,7 +87,7 @@ "message": "再次輸入主密碼" }, "masterPassHint": { - "message": "主密碼提示(選用)" + "message": "主密碼提示(選填)" }, "passwordStrengthScore": { "message": "密碼強度分數 $SCORE$", @@ -108,7 +111,7 @@ } }, "finishJoiningThisOrganizationBySettingAMasterPassword": { - "message": "設定主密碼以完成加入這個組織" + "message": "設定主密碼以完成加入此組織。" }, "tab": { "message": "分頁" @@ -150,7 +153,7 @@ "message": "複製號碼" }, "copySecurityCode": { - "message": "複製安全代碼" + "message": "複製安全碼" }, "copyName": { "message": "複製名稱" @@ -159,19 +162,19 @@ "message": "複製公司名稱" }, "copySSN": { - "message": "複製社會保險號碼" + "message": "複製社會安全號碼" }, "copyPassportNumber": { "message": "複製護照號碼" }, "copyLicenseNumber": { - "message": "複製駕照號碼" + "message": "複製證照號碼" }, "copyPrivateKey": { - "message": "複製私密金鑰" + "message": "複製私鑰" }, "copyPublicKey": { - "message": "複製公開金鑰" + "message": "複製公鑰" }, "copyFingerprint": { "message": "複製指紋" @@ -203,13 +206,13 @@ "message": "自動填入" }, "autoFillLogin": { - "message": "自動填入登入資訊" + "message": "自動填入登入資料" }, "autoFillCard": { - "message": "自動填入支付卡" + "message": "自動填入付款卡" }, "autoFillIdentity": { - "message": "自動填入身分資訊" + "message": "自動填入身分資料" }, "fillVerificationCode": { "message": "填入驗證碼" @@ -219,28 +222,28 @@ "description": "Aria label for the heading displayed the inline menu for totp code autofill" }, "generatePasswordCopied": { - "message": "產生及複製密碼" + "message": "已產生並複製密碼" }, "copyElementIdentifier": { "message": "複製自訂欄位名稱" }, "noMatchingLogins": { - "message": "無符合的登入資料" + "message": "沒有符合的登入資料" }, "noCards": { - "message": "無支付卡" + "message": "沒有付款卡" }, "noIdentities": { - "message": "無身分資訊" + "message": "沒有身分資料" }, "addLoginMenu": { - "message": "新增登入資訊" + "message": "新增登入資料" }, "addCardMenu": { - "message": "新增支付卡" + "message": "新增付款卡" }, "addIdentityMenu": { - "message": "新增身分資訊" + "message": "新增身分資料" }, "unlockVaultMenu": { "message": "解鎖您的密碼庫" @@ -258,13 +261,13 @@ "message": "新增項目" }, "accountEmail": { - "message": "帳號電子郵件" + "message": "帳戶電子郵件" }, "requestHint": { - "message": "請求提示" + "message": "取得提示" }, "requestPasswordHint": { - "message": "請求密碼提示" + "message": "取得密碼提示" }, "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou": { "message": "輸入您帳號的電子郵件,您的密碼提示會傳送給您" @@ -276,7 +279,7 @@ "message": "繼續" }, "sendVerificationCode": { - "message": "傳送驗證碼至您的電子郵件信箱" + "message": "傳送驗證碼至您的信箱" }, "sendCode": { "message": "傳送驗證碼" @@ -288,7 +291,7 @@ "message": "驗證碼" }, "confirmIdentity": { - "message": "請先確認身分後再繼續。" + "message": "請確認您的身分以繼續。" }, "changeMasterPassword": { "message": "變更主密碼" @@ -319,7 +322,7 @@ "description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing." }, "yourAccountsFingerprint": { - "message": "您帳戶的指紋短語", + "message": "您的帳戶指紋短語", "description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing." }, "twoStepLogin": { @@ -419,10 +422,10 @@ "message": "資料夾" }, "noFolders": { - "message": "沒有可列出的資料夾。" + "message": "目前沒有可列出的資料夾。" }, "helpFeedback": { - "message": "協助與意見反應" + "message": "說明與意見回饋" }, "helpCenter": { "message": "Bitwarden 說明中心" @@ -431,7 +434,7 @@ "message": "瀏覽 Bitwarden 社群論壇" }, "contactSupport": { - "message": "連絡 Bitwarden 客戶支援" + "message": "聯絡 Bitwarden 技術支援" }, "sync": { "message": "同步" @@ -440,7 +443,7 @@ "message": "立即同步" }, "lastSync": { - "message": "上次同步於:" + "message": "上次同步:" }, "passGen": { "message": "密碼產生器" @@ -450,7 +453,7 @@ "description": "Short for 'credential generator'." }, "passGenInfo": { - "message": "自動產生安全、唯一的登入密碼。" + "message": "為您的登入資料自動產生高強度且唯一的密碼。" }, "bitWebVaultApp": { "message": "Bitwarden 網頁應用程式" @@ -524,17 +527,17 @@ "message": "單字分隔字元" }, "capitalize": { - "message": "大寫", + "message": "首字母大寫", "description": "Make the first letter of a work uppercase." }, "includeNumber": { "message": "包含數字" }, "minNumbers": { - "message": "最少數字位數" + "message": "最少數字數量" }, "minSpecial": { - "message": "最少符號位數" + "message": "最少特殊字元數量" }, "avoidAmbiguous": { "message": "避免易混淆的字元", @@ -573,20 +576,29 @@ "itemWasSentToArchive": { "message": "項目已移至封存" }, + "itemWasUnarchived": { + "message": "已取消封存項目" + }, "itemUnarchived": { "message": "項目取消封存" }, "archiveItem": { "message": "封存項目" }, - "archiveItemConfirmDesc": { - "message": "封存的項目將不會出現在一般搜尋結果或自動填入建議中。確定要封存此項目嗎?" + "archiveItemDialogContent": { + "message": "封存後,此項目將不會顯示在搜尋結果與自動填入建議中。" + }, + "archived": { + "message": "已封存" + }, + "unarchiveAndSave": { + "message": "取消封存並儲存" }, "upgradeToUseArchive": { "message": "需要進階版會員才能使用封存功能。" }, "itemRestored": { - "message": "Item has been restored" + "message": "已還原項目" }, "edit": { "message": "編輯" @@ -607,7 +619,7 @@ "message": "檢視登入" }, "noItemsInList": { - "message": "沒有可列出的項目。" + "message": "目前沒有可列出的項目。" }, "itemInformation": { "message": "項目資訊" @@ -676,7 +688,7 @@ "message": "網站" }, "toggleVisibility": { - "message": "切換可見性" + "message": "切換顯示狀態" }, "manage": { "message": "管理" @@ -703,10 +715,10 @@ "message": "其它選項" }, "rateExtension": { - "message": "為本套件評分" + "message": "評分此擴充功能" }, "browserNotSupportClipboard": { - "message": "您的瀏覽器不支援剪貼簿簡單複製,請手動複製。" + "message": "您的瀏覽器不支援剪貼簿複製功能,請手動複製。" }, "verifyYourIdentity": { "message": "驗證您的身份" @@ -733,7 +745,7 @@ "message": "解鎖" }, "loggedInAsOn": { - "message": "已在 $HOSTNAME$ 以 $EMAIL$ 身份登入。", + "message": "已在 $HOSTNAME$ 上以 $EMAIL$ 登入。", "placeholders": { "email": { "content": "$1", @@ -746,7 +758,7 @@ } }, "invalidMasterPassword": { - "message": "無效的主密碼" + "message": "主密碼不正確" }, "invalidMasterPasswordConfirmEmailAndHost": { "message": "主密碼無效。請確認你的電子郵件正確,且帳號是於 $HOST$ 建立的。", @@ -803,7 +815,7 @@ "message": "4 小時" }, "onLocked": { - "message": "於系統鎖定時" + "message": "系統鎖定時" }, "onIdle": { "message": "系統閒置時" @@ -812,7 +824,7 @@ "message": "系統睡眠時" }, "onRestart": { - "message": "於瀏覽器重新啟動時" + "message": "瀏覽器重新啟動時" }, "never": { "message": "永不" @@ -836,10 +848,10 @@ "message": "發生錯誤" }, "emailRequired": { - "message": "必須填入電子郵件地址 。" + "message": "必須填入電子郵件地址。" }, "invalidEmail": { - "message": "無效的電子郵件地址。" + "message": "電子郵件地址無效。" }, "masterPasswordRequired": { "message": "必須填入主密碼。" @@ -885,7 +897,7 @@ "message": "驗證已被取消或時間超過。請再試一次。" }, "invalidVerificationCode": { - "message": "無效的驗證碼" + "message": "驗證碼無效" }, "valueCopied": { "message": "$VALUE$ 已複製", @@ -931,7 +943,7 @@ "message": "您已經登出了您的帳號。" }, "loginExpired": { - "message": "您的登入階段已過期。" + "message": "您的登入工作階段已逾時。" }, "logIn": { "message": "登入" @@ -978,6 +990,12 @@ "no": { "message": "否" }, + "noAuth": { + "message": "任何持有連結的人" + }, + "anyOneWithPassword": { + "message": "任何持有您設定之密碼的人" + }, "location": { "message": "位置" }, @@ -991,7 +1009,7 @@ "message": "資料夾已新增" }, "twoStepLoginConfirmation": { - "message": "兩步驟登入需要您從其他裝置(例如安全鑰匙、驗證器程式、SMS、手機或電子郵件)來驗證您的登入,這使您的帳戶更加安全。兩步驟登入可以在 bitwarden.com 網頁版密碼庫啟用。現在要前往嗎?" + "message": "兩步驟登入會要求您透過另一個裝置驗證登入,例如安全金鑰、驗證器應用程式、簡訊、電話或電子郵件,藉此提升帳戶安全性。您可以在 bitwarden.com 的網頁密碼庫中設定此功能。是否要現在前往?" }, "twoStepLoginConfirmationContent": { "message": "在 Bitwarden 網頁應用程式中設定兩步驟登入,讓您的帳號更加安全。" @@ -1012,7 +1030,7 @@ "message": "新手教學" }, "gettingStartedTutorialVideo": { - "message": "觀看我們的新手教學,了解如何充分利用瀏覽器擴充套件。" + "message": "觀看新手教學,快速掌握瀏覽器擴充套件的完整用法。" }, "syncingComplete": { "message": "同步完成" @@ -1021,7 +1039,7 @@ "message": "同步失敗" }, "passwordCopied": { - "message": "已複製密碼" + "message": "密碼已複製" }, "uri": { "message": "URI" @@ -1062,7 +1080,7 @@ } }, "deleteItemConfirmation": { - "message": "確定要刪除此項目嗎?" + "message": "確定要移至垃圾桶嗎?" }, "deletedItem": { "message": "項目已移至垃圾桶" @@ -1089,41 +1107,41 @@ "message": "搜尋類型" }, "noneFolder": { - "message": "預設資料夾", + "message": "不指定資料夾", "description": "This is the folder for uncategorized items" }, "enableAddLoginNotification": { - "message": "詢問新增登入資料" + "message": "詢問是否要新增登入資料" }, "vaultSaveOptionsTitle": { "message": "儲存至密碼庫選項" }, "addLoginNotificationDesc": { - "message": "在密碼庫中找不到相符的項目時詢問是否新增項目。" + "message": "若在密碼庫中找不到相符項目,則詢問是否新增項目。" }, "addLoginNotificationDescAlt": { "message": "如果在您的密碼庫中找不到項目,則詢問是否新增項目。適用於所有已登入的帳戶。" }, "showCardsInVaultViewV2": { - "message": "一律在密碼庫介面中顯示支付卡自動填入建議" + "message": "一律在密碼庫介面中顯示付款卡自動填入建議" }, "showCardsCurrentTab": { - "message": "於分頁頁面顯示支付卡" + "message": "於分頁頁面顯示卡片" }, "showCardsCurrentTabDesc": { - "message": "於分頁頁面顯示信用卡以便於自動填入。" + "message": "於分頁頁面顯示付款卡以便於自動填入。" }, "showIdentitiesInVaultViewV2": { "message": "一律在密碼庫介面中顯示身分自動填入建議" }, "showIdentitiesCurrentTab": { - "message": "於分頁頁面顯示身分" + "message": "於分頁頁面顯示身分資料" }, "showIdentitiesCurrentTabDesc": { "message": "於分頁頁面顯示身分以便於自動填入。" }, "clickToAutofillOnVault": { - "message": "在密碼庫檢視中點選項目來自動填入" + "message": "在密碼庫檢視中點選項目即可自動填入" }, "clickToAutofill": { "message": "點選自動填入建議中的項目進行填入" @@ -1133,11 +1151,11 @@ "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "clearClipboardDesc": { - "message": "自動清除剪貼簿中複製的值。", + "message": "自動清除剪貼簿中已複製的值。", "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "notificationAddDesc": { - "message": "希望 Bitwarden 幫您儲存這個密碼嗎?" + "message": "是否要讓 Bitwarden 記住此密碼?" }, "notificationAddSave": { "message": "儲存" @@ -1260,10 +1278,10 @@ "message": "變更你的主密碼以完成帳號復原。" }, "enableChangedPasswordNotification": { - "message": "詢問更新現有的登入資料" + "message": "詢問是否更新現有的登入資料" }, "changedPasswordNotificationDesc": { - "message": "偵測到網站密碼變更時,詢問是否更新登入資料密碼。" + "message": "偵測到網站密碼變更時,詢問是否更新密碼。" }, "changedPasswordNotificationDescAlt": { "message": "當偵測到網站上的變更時,詢問是否更新登入的密碼。適用於所有已登入的帳戶。" @@ -1299,7 +1317,7 @@ "message": "使用右鍵點選來存取密碼產生和網站的符合登入資訊。適用於所有已登入的帳戶。" }, "defaultUriMatchDetection": { - "message": "預設的 URI 一致性偵測", + "message": "預設 URI 比對方式", "description": "Default URI match detection for autofill." }, "defaultUriMatchDetectionDesc": { @@ -1309,7 +1327,7 @@ "message": "主題" }, "themeDesc": { - "message": "變更應用程式的主題色彩。" + "message": "變更應用程式的色彩主題。" }, "themeDescAlt": { "message": "變更應用程式的主題色彩。適用於所有已登入的帳戶。" @@ -1380,16 +1398,16 @@ "message": "確認匯出密碼庫" }, "exportWarningDesc": { - "message": "此次匯出的密碼庫資料為未加密格式。您不應將它存放或經由不安全的方式(例如電子郵件)傳送。用完後請立即將它刪除。" + "message": "此匯出檔包含未加密的密碼庫資料。請勿透過不安全的管道(如電子郵件)儲存或傳送此檔案。使用完畢後,請務必立即刪除。" }, "encExportKeyWarningDesc": { - "message": "將使用您帳戶的加密金鑰來加密匯出的資料,若您更新了帳戶的加密金鑰,請重新匯出,否則將無法解密匯出的檔案。" + "message": "此匯出檔會使用您帳戶的加密金鑰進行加密。若您日後更換了帳戶的加密金鑰,請務必重新匯出,否則將無法解密此檔案。" }, "encExportAccountWarningDesc": { - "message": "每個 Bitwarden 使用者帳戶的帳戶加密金鑰都不相同,因此無法將已加密匯出的檔案匯入至不同帳戶中。" + "message": "帳戶加密金鑰專屬於每個 Bitwarden 使用者帳戶,因此您無法將加密匯出檔匯入至其他帳戶。" }, "exportMasterPassword": { - "message": "輸入您的主密碼以匯出密碼庫資料。" + "message": "請輸入主密碼以匯出密碼庫資料。" }, "shared": { "message": "已共用" @@ -1401,7 +1419,7 @@ "message": "移動至組織 " }, "movedItemToOrg": { - "message": "已將 $ITEMNAME$ 移動至 $ORGNAME$", + "message": "已將 $ITEMNAME$ 移至 $ORGNAME$", "placeholders": { "itemname": { "content": "$1", @@ -1414,7 +1432,7 @@ } }, "moveToOrgDesc": { - "message": "選擇您希望將這個項目移動至哪個組織。項目的擁有權將會轉移至該組織。轉移之後,您將不再是此項目的直接擁有者。" + "message": "選擇要將此項目移至的組織。將項目移至組織後,該項目的所有權將轉移至該組織。完成移動後,您將不再是此項目的直接擁有者。" }, "learnMore": { "message": "深入了解" @@ -1441,10 +1459,10 @@ "message": "以後再說" }, "authenticatorKeyTotp": { - "message": "驗證器金鑰 (TOTP)" + "message": "驗證器金鑰(TOTP)" }, "verificationCodeTotp": { - "message": "驗證碼 (TOTP)" + "message": "驗證碼(TOTP)" }, "copyVerificationCode": { "message": "複製驗證碼" @@ -1492,10 +1510,10 @@ "message": "項目已轉移" }, "maxFileSize": { - "message": "檔案最大為 500MB。" + "message": "檔案大小上限為 500 MB。" }, "featureUnavailable": { - "message": "功能不可用" + "message": "功能無法使用" }, "legacyEncryptionUnsupported": { "message": "不再支援舊版加密。請聯繫支援團隊以恢復您的帳號。" @@ -1507,19 +1525,19 @@ "message": "管理會員資格" }, "premiumManageAlert": { - "message": "您可以在 bitwarden.com 網頁版密碼庫管理您的會員資格。現在要前往嗎?" + "message": "您可以在 bitwarden.com 的網頁版密碼庫中管理會員資格。是否要現在前往?" }, "premiumRefresh": { - "message": "更新會員資格狀態" + "message": "重新整理會員資格" }, "premiumNotCurrentMember": { - "message": "您尚未成為進階會員。" + "message": "您目前不是進階會員。" }, "premiumSignUpAndGet": { - "message": "註冊成為進階會員將獲得:" + "message": "升級為進階會員即可獲得:" }, "ppremiumSignUpStorage": { - "message": "用於檔案附件的 1 GB 加密儲存空間。" + "message": "1 GB 的加密附件儲存空間。" }, "premiumSignUpStorageV2": { "message": "用於檔案附件的 $SIZE$ 加密儲存空間。", @@ -1536,11 +1554,20 @@ "premiumSignUpTwoStepOptions": { "message": "專有的兩步驟登入選項,例如 YubiKey 和 Duo。" }, + "premiumSubscriptionEnded": { + "message": "您的進階版訂閱已到期" + }, + "archivePremiumRestart": { + "message": "若要重新存取您的封存項目,請重新啟用進階版訂閱。若您在重新啟用前編輯封存項目的詳細資料,它將會被移回您的密碼庫。" + }, + "restartPremium": { + "message": "重新啟用進階版" + }, "ppremiumSignUpReports": { - "message": "密碼健康度檢查、提供帳戶體檢以及資料外洩報告,以保障您的密碼庫安全。" + "message": "提供密碼健全性、帳戶健康狀態及資料外洩報告,確保您的密碼庫安全。" }, "ppremiumSignUpTotp": { - "message": "用於您的密碼庫中登入項目的 TOTP 驗證碼 (2FA) 產生器。" + "message": "為密碼庫中的登入資料產生 TOTP 驗證碼(2FA)。" }, "ppremiumSignUpSupport": { "message": "優先客戶支援。" @@ -1582,7 +1609,7 @@ } }, "refreshComplete": { - "message": "狀態更新完成" + "message": "重新整理完成" }, "enableAutoTotpCopy": { "message": "自動複製 TOTP" @@ -1591,7 +1618,7 @@ "message": "若登入資訊已包含驗證器金鑰,在自動填入登入資訊時,也會同步為您複製 TOTP 驗證碼。" }, "enableAutoBiometricsPrompt": { - "message": "啟動時要求生物特徵辨識" + "message": "啟動時要求進行生物辨識" }, "authenticationTimeout": { "message": "驗證逾時" @@ -1619,7 +1646,7 @@ "message": "使用您的復原碼" }, "insertU2f": { - "message": "將您的安全鑰匙插入電腦的 USB 連接埠,然後觸摸其按鈕(如有的話)。" + "message": "請將安全金鑰插入電腦的 USB 連接埠。若金鑰上有按鈕,請輕觸一下。" }, "openInNewTab": { "message": "在新分頁中開啟" @@ -1646,10 +1673,10 @@ "message": "登入無法使用" }, "noTwoStepProviders": { - "message": "此帳戶已設定兩步驟登入,但是本瀏覽器不支援已設定的任一個兩步驟提供程式。" + "message": "此帳戶已設定兩步驟登入,但本瀏覽器不支援任何已設定的兩步驟驗證方式。" }, "noTwoStepProviders2": { - "message": "請使用受支援的瀏覽器(例如 Chrome),及/或新增可以更好地支援跨瀏覽器的提供程式(例如驗證器應用程式)。" + "message": "請使用受支援的網頁瀏覽器(例如 Chrome),或新增跨瀏覽器支援度較佳的驗證方式(例如驗證器應用程式)。" }, "twoStepOptions": { "message": "兩步驟登入選項" @@ -1658,7 +1685,7 @@ "message": "選擇兩步驟登入方法" }, "recoveryCodeTitle": { - "message": "復原代碼" + "message": "復原碼" }, "authenticatorAppTitle": { "message": "驗證器應用程式" @@ -1671,21 +1698,21 @@ "message": "YubiKey OTP 安全金鑰" }, "yubiKeyDesc": { - "message": "使用 YubiKey 存取您的帳戶。支援 YubiKey 4、4 Nano、4C、以及 NEO 裝置。" + "message": "使用 YubiKey 存取您的帳戶。支援 YubiKey 4、4 Nano、4C、及 NEO 裝置。" }, "duoDescV2": { "message": "輸入 Duo 應用程式產生的驗證碼。", "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "duoOrganizationDesc": { - "message": "為您的組織使用 Duo Security 的 Duo Mobile 程式、SMS、撥打電話或 U2F 安全鑰匙進行驗證。", + "message": "使用 Duo Security 為您的組織進行驗證,可透過 Duo Mobile 應用程式、簡訊、電話通話或 U2F 安全金鑰。", "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "webAuthnTitle": { "message": "FIDO2 WebAuthn" }, "webAuthnDesc": { - "message": "使用任何具有 WebAuthn 功能的安全鑰匙來存取您的帳戶。" + "message": "使用任何相容於 WebAuthn 的安全金鑰存取您的帳戶。" }, "emailTitle": { "message": "電子郵件" @@ -1697,13 +1724,13 @@ "message": "自行部署環境" }, "selfHostedBaseUrlHint": { - "message": "指定您自架的 Bitwarden 伺服器的網域 URL。例如:https://bitwarden.company.com" + "message": "請指定您自行部署的 Bitwarden 安裝環境的基礎 URL。例如:https://bitwarden.company.com" }, "selfHostedCustomEnvHeader": { - "message": "適用於進階設定。您可以單獨指定各個服務的網域 URL。" + "message": "在進階設定中,您可獨立指定每項服務的基礎 URL。" }, "selfHostedEnvFormInvalid": { - "message": "您必須新增伺服器網域 URL 或至少一個自訂環境。" + "message": "您必須新增基礎伺服器 URL,或至少一個自訂環境。" }, "selfHostedEnvMustUseHttps": { "message": "URL 必須使用 HTTPS。" @@ -1715,7 +1742,7 @@ "message": "伺服器 URL" }, "selfHostBaseUrl": { - "message": "自架伺服器 URL", + "message": "自行部署伺服器 URL", "description": "Label for field requesting a self-hosted integration service URL" }, "apiUrl": { @@ -1849,7 +1876,7 @@ "message": "不要在頁面載入時自動填入" }, "commandOpenPopup": { - "message": "在彈出式視窗中開啟密碼庫" + "message": "開啟密碼庫彈出視窗" }, "commandOpenSidebar": { "message": "在側邊欄中開啟密碼庫" @@ -1864,7 +1891,7 @@ "message": "自動將上次使用的身分資料填入目前網站" }, "commandGeneratePasswordDesc": { - "message": "產生一組新的隨機密碼並將它複製到剪貼簿中。" + "message": "產生新的隨機密碼並複製到剪貼簿" }, "commandLockVaultDesc": { "message": "鎖定密碼庫" @@ -1882,7 +1909,7 @@ "message": "新增自訂欄位" }, "dragToSort": { - "message": "透過拖曳來排序" + "message": "拖曳以排序" }, "dragToReorder": { "message": "拖曳以重新排序" @@ -1904,11 +1931,11 @@ "description": "This describes a field that is 'linked' (tied) to another field." }, "linkedValue": { - "message": "連結的值", + "message": "連結值", "description": "This describes a value that is 'linked' (tied) to another value." }, "popup2faCloseMessage": { - "message": "如果您點選彈出式視窗外的任意區域,將導致彈出式視窗關閉。您想在新視窗中開啟此彈出式視窗,以讓它不關閉嗎?" + "message": "為避免在查看電子郵件驗證碼時關閉彈出視窗,是否要在新視窗中開啟?" }, "showIconsChangePasswordUrls": { "message": "顯示網站圖示並取得變更密碼網址" @@ -1917,22 +1944,22 @@ "message": "持卡人姓名" }, "number": { - "message": "號碼" + "message": "卡號" }, "brand": { "message": "發卡組織" }, "expirationMonth": { - "message": "逾期月份" + "message": "到期月份" }, "expirationYear": { - "message": "逾期年份" + "message": "到期年份" }, "monthly": { "message": "月" }, "expiration": { - "message": "逾期" + "message": "有效期限" }, "january": { "message": "一月" @@ -1971,7 +1998,7 @@ "message": "十二月" }, "securityCode": { - "message": "安全代碼" + "message": "安全碼" }, "cardNumber": { "message": "信用卡號碼" @@ -1980,7 +2007,7 @@ "message": "例如" }, "title": { - "message": "稱呼" + "message": "稱謂" }, "mr": { "message": "Mr" @@ -2010,23 +2037,26 @@ "message": "全名" }, "identityName": { - "message": "身份名稱" + "message": "身分名稱" }, "company": { "message": "公司" }, "ssn": { - "message": "社會保險號碼" + "message": "社會安全號碼" }, "passportNumber": { "message": "護照號碼" }, "licenseNumber": { - "message": "許可證號碼" + "message": "駕照號碼" }, "email": { "message": "電子郵件" }, + "emails": { + "message": "電子郵件" + }, "phone": { "message": "電話號碼" }, @@ -2034,19 +2064,19 @@ "message": "地址" }, "address1": { - "message": "地址 1" + "message": "地址第 1 行" }, "address2": { - "message": "地址 2" + "message": "地址第 2 行" }, "address3": { - "message": "地址 3" + "message": "地址第 3 行" }, "cityTown": { - "message": "市/鎮" + "message": "市/鎮" }, "stateProvince": { - "message": "州/省" + "message": "州/省" }, "zipPostalCode": { "message": "郵遞區號" @@ -2058,7 +2088,7 @@ "message": "類型" }, "typeLogin": { - "message": "登入" + "message": "登入資料" }, "typeLogins": { "message": "登入資料" @@ -2067,7 +2097,7 @@ "message": "安全筆記" }, "typeCard": { - "message": "支付卡" + "message": "付款卡" }, "typeIdentity": { "message": "身分" @@ -2083,7 +2113,7 @@ "description": "Header for new login item type" }, "newItemHeaderCard": { - "message": "新增支付卡", + "message": "新增付款卡", "description": "Header for new card item type" }, "newItemHeaderIdentity": { @@ -2111,7 +2141,7 @@ "description": "Header for edit login item type" }, "editItemHeaderCard": { - "message": "編輯支付卡", + "message": "編輯付款卡", "description": "Header for edit card item type" }, "editItemHeaderIdentity": { @@ -2139,7 +2169,7 @@ "description": "Header for view login item type" }, "viewItemHeaderCard": { - "message": "檢視支付卡", + "message": "檢視付款卡", "description": "Header for view card item type" }, "viewItemHeaderIdentity": { @@ -2185,13 +2215,13 @@ "message": "我的最愛" }, "popOutNewWindow": { - "message": "彈出至新視窗" + "message": "在新視窗中開啟" }, "refresh": { "message": "重新整理" }, "cards": { - "message": "支付卡" + "message": "付款卡" }, "identities": { "message": "身分" @@ -2210,10 +2240,10 @@ "description": "To clear something out. example: To clear browser history." }, "checkPassword": { - "message": "檢查密碼是否已暴露。" + "message": "檢查密碼是否已外洩。" }, "passwordExposed": { - "message": "此密碼在資料外洩事件中被暴露了 $VALUE$ 次,應立即變更它。", + "message": "此密碼已在資料外洩事件中外洩 $VALUE$ 次,建議您立即更換。", "placeholders": { "value": { "content": "$1", @@ -2222,10 +2252,10 @@ } }, "passwordSafe": { - "message": "任何已知的外洩密碼資料庫中都沒有此密碼,它目前是安全的。" + "message": "在任何已知的資料外洩事件中皆未發現此密碼,應可安全使用。" }, "baseDomain": { - "message": "基底網域", + "message": "基礎網域", "description": "Domain name. Ex. website.com" }, "baseDomainOptionRecommended": { @@ -2251,11 +2281,11 @@ "description": "A programming term, also known as 'RegEx'." }, "matchDetection": { - "message": "一致性偵測", + "message": "比對偵測", "description": "URI match detection for autofill." }, "defaultMatchDetection": { - "message": "預設一致性偵測", + "message": "預設比對偵測", "description": "Default URI match detection for autofill." }, "toggleOptions": { @@ -2280,7 +2310,7 @@ "message": "所有項目" }, "noPasswordsInList": { - "message": "沒有可列出的密碼。" + "message": "沒有可顯示的密碼。" }, "clearHistory": { "message": "清除歷史紀錄" @@ -2310,16 +2340,16 @@ "description": "ex. Date this password was updated" }, "neverLockWarning": { - "message": "您確定要使用「永不」選項嗎?將鎖定選項設定為「永不」會將密碼庫的加密金鑰儲存在您的裝置上。如果使用此選項,應確保您的裝置是安全的。" + "message": "您確定要使用「永不」選項嗎?將鎖定選項設為「永不」會把密碼庫的加密金鑰儲存在您的裝置上。若您選擇此選項,請務必確保您的裝置受到妥善保護。" }, "noOrganizationsList": { - "message": "您沒有加入任何組織。組織允許您與其他使用者安全地共用項目。" + "message": "您目前未加入任何組織。組織可讓您與其他使用者安全地共用項目。" }, "noCollectionsInList": { "message": "沒有可顯示的集合。" }, "ownership": { - "message": "擁有權" + "message": "所有權" }, "whoOwnsThisItem": { "message": "誰擁有這個項目?" @@ -2337,13 +2367,13 @@ "description": "ex. A weak password. Scale: Weak -> Good -> Strong" }, "weakMasterPassword": { - "message": "主密碼強度太弱" + "message": "主密碼強度不足" }, "weakMasterPasswordDesc": { - "message": "您設定的主密碼很脆弱。您應該使用高強度的密碼(或密碼短語)來正確保護您的 bitwarden 帳戶。仍要使用這組主密碼嗎?" + "message": "您選擇的主密碼強度過弱。請使用高強度的主密碼(或密碼短語),以妥善保護您的 Bitwarden 帳戶。您確定要使用此主密碼嗎?" }, "pin": { - "message": "PIN", + "message": "PIN 碼", "description": "PIN code. Ex. The short code (often numeric) that you use to unlock a device." }, "unlockWithPin": { @@ -2356,46 +2386,46 @@ "message": "設定 PIN 碼" }, "setYourPinCode": { - "message": "設定您用來解鎖 Bitwarden 的 PIN 碼。您的 PIN 設定將在您完全登出本應用程式時被重設。" + "message": "設定用於解鎖 Bitwarden 的 PIN 碼。若您完全登出應用程式,PIN 碼設定將會重設。" }, "setPinCode": { "message": "你可以使用此 PIN 來解鎖 Bitwarden。若你完全登出應用程式,PIN 將會被重設。" }, "pinRequired": { - "message": "必須填入 PIN 碼。" + "message": "必須輸入 PIN 碼。" }, "invalidPin": { - "message": "無效的 PIN 碼。" + "message": "PIN 碼無效。" }, "tooManyInvalidPinEntryAttemptsLoggingOut": { "message": "輸入太多無效 PIN 碼。 登出。" }, "unlockWithBiometrics": { - "message": "使用生物特徵辨識解鎖" + "message": "使用生物辨識解鎖" }, "unlockWithMasterPassword": { "message": "使用主密碼解鎖" }, "awaitDesktop": { - "message": "等待來自桌面應用程式的確認" + "message": "正在等待桌面應用程式確認" }, "awaitDesktopDesc": { - "message": "請確認在 Bitwarden 桌面應用程式中使用了生物特徵辨識,以啟用瀏覽器的生物特徵辨識功能。" + "message": "請在 Bitwarden 桌面應用程式中使用生物辨識進行確認,以設定瀏覽器的生物辨識功能。" }, "lockWithMasterPassOnRestart": { - "message": "瀏覽器重啟後使用主密碼鎖定" + "message": "瀏覽器重新啟動時使用主密碼鎖定" }, "lockWithMasterPassOnRestart1": { "message": "瀏覽器重啟後使用主密碼鎖定" }, "selectOneCollection": { - "message": "您必須至少選擇一個集合。" + "message": "必須選取至少一個集合。" }, "cloneItem": { - "message": "克隆項目" + "message": "複製項目" }, "clone": { - "message": "克隆" + "message": "複製" }, "passwordGenerator": { "message": "密碼產生器" @@ -2455,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "項目已永久刪除" }, + "archivedItemRestored": { + "message": "已還原封存項目" + }, "restoreItem": { "message": "還原項目" }, @@ -2465,7 +2498,7 @@ "message": "已經有帳號了嗎?" }, "vaultTimeoutLogOutConfirmation": { - "message": "選擇登出將會在密碼庫逾時後移除對密碼庫的所有存取權限,若要重新驗證則需連線網路。確定要使用此設定嗎?" + "message": "登出後將移除對密碼庫的所有存取權限,並在逾時後需要進行線上驗證。您確定要使用此設定嗎?" }, "vaultTimeoutLogOutConfirmationTitle": { "message": "逾時動作確認" @@ -2483,7 +2516,7 @@ "message": "項目已自動填入 " }, "insecurePageWarning": { - "message": "警告:這是不安全的 HTTP 頁面,任何您送出的資訊均可能被其他人看見和更改。此登入資訊原先是在安全的 (HTTPS) 頁面儲存的。" + "message": "警告:此為不安全的 HTTP 頁面,您送出的任何資訊都可能被他人查看並修改。此登入資料原本儲存在安全的(HTTPS)頁面上。" }, "insecurePageWarningFillPrompt": { "message": "您仍要填入此登入資訊嗎?" @@ -2510,13 +2543,13 @@ "message": "目前的主密碼" }, "newMasterPass": { - "message": "新的主密碼" + "message": "新主密碼" }, "confirmNewMasterPass": { - "message": "確認新的主密碼" + "message": "確認新主密碼" }, "masterPasswordPolicyInEffect": { - "message": "一個或多個組織原則要求您的主密碼須符合下列條件:" + "message": "一或多個組織的政策要求您的主密碼必須符合以下條件:" }, "policyInEffectMinComplexity": { "message": "最小複雜度為 $SCORE$", @@ -2528,7 +2561,7 @@ } }, "policyInEffectMinLength": { - "message": "最小長度為 $LENGTH$", + "message": "最短長度為 $LENGTH$", "placeholders": { "length": { "content": "$1", @@ -2555,7 +2588,7 @@ } }, "masterPasswordPolicyRequirementsNotMet": { - "message": "您新的主密碼不符合原則要求。" + "message": "您的新主密碼未符合政策要求。" }, "receiveMarketingEmailsV2": { "message": "獲得來自 Bitwarden 的公告、建議及研究資訊電子郵件。" @@ -2573,10 +2606,10 @@ "message": "和" }, "acceptPolicies": { - "message": "選中此選取框,即表示您同意下列條款:" + "message": "勾選此核取方塊即表示您同意以下內容:" }, "acceptPoliciesRequired": { - "message": "尚未接受服務條款與隱私權政策。" + "message": "尚未同意服務條款與隱私權政策。" }, "termsOfService": { "message": "服務條款" @@ -2588,7 +2621,7 @@ "message": "你的新密碼不能與目前的密碼相同。" }, "hintEqualsPassword": { - "message": "密碼提示不能與您的密碼相同。" + "message": "密碼提示不可與密碼相同。" }, "ok": { "message": "確定" @@ -2600,22 +2633,22 @@ "message": "未找到存取權杖或 API 密鑰。請重試登出再登入。" }, "desktopSyncVerificationTitle": { - "message": "桌面同步驗證" + "message": "桌面應用程式同步驗證" }, "desktopIntegrationVerificationText": { - "message": "請驗證桌面應用程式顯示的指紋為: " + "message": "請確認桌面應用程式顯示以下指紋:" }, "desktopIntegrationDisabledTitle": { - "message": "瀏覽器整合未設定" + "message": "瀏覽器整合尚未設定" }, "desktopIntegrationDisabledDesc": { - "message": "瀏覽器整合在桌面應用程式中未設定,請在桌面應用程式的設定中設定它。" + "message": "Bitwarden 桌面應用程式尚未設定瀏覽器整合。請在桌面應用程式的設定中完成設定。" }, "startDesktopTitle": { "message": "啟動 Bitwarden 桌面應用程式" }, "startDesktopDesc": { - "message": "Bitwarden 桌面應用程式需要在啟動狀態才能使用生物特徵辨識解鎖功能。" + "message": "在使用生物辨識解鎖前,必須先啟動 Bitwarden 桌面應用程式。" }, "errorEnableBiometricTitle": { "message": "無法啟用生物特徵辨識" @@ -2630,7 +2663,7 @@ "message": "桌面通訊已中斷" }, "nativeMessagingWrongUserDesc": { - "message": "桌面應用程式登入了不同的帳戶。請確保兩個應用程式登入的是同一個帳戶。" + "message": "桌面應用程式目前登入的是不同的帳戶。請確認兩個應用程式皆登入相同的帳戶。" }, "nativeMessagingWrongUserTitle": { "message": "帳戶不相符" @@ -2642,16 +2675,16 @@ "message": "生物辨識解鎖失敗。生物辨識金鑰解鎖密碼庫失敗。請嘗試重新設定生物辨識。" }, "biometricsNotEnabledTitle": { - "message": "生物特徵辨識未設定" + "message": "尚未設定生物辨識" }, "biometricsNotEnabledDesc": { - "message": "需先在桌面應用程式設定中設定生物特徵辨識,才能使用瀏覽器的生物特徵辨識功能。" + "message": "瀏覽器生物辨識功能需要先在設定中完成桌面應用程式的生物辨識設定。" }, "biometricsNotSupportedTitle": { - "message": "不支援生物特徵辨識" + "message": "不支援生物辨識" }, "biometricsNotSupportedDesc": { - "message": "此裝置不支援瀏覽器生物特徵辨識。" + "message": "此裝置不支援瀏覽器生物辨識。" }, "biometricsNotUnlockedTitle": { "message": "使用者已鎖定或登出" @@ -2675,19 +2708,19 @@ "message": "未提供權限" }, "nativeMessaginPermissionErrorDesc": { - "message": "沒有與 Bitwarden 桌面應用程式通訊的權限,我們無法在瀏覽器擴充套件中提供生物特徵辨識功能。請再試一次。" + "message": "若未取得與 Bitwarden 桌面應用程式通訊的權限,無法在瀏覽器擴充套件中提供生物辨識。請再試一次。" }, "nativeMessaginPermissionSidebarTitle": { "message": "權限要求錯誤" }, "nativeMessaginPermissionSidebarDesc": { - "message": "此動作無法在側邊欄中完成,請在彈出式視窗中再試一次。" + "message": "此動作無法在側邊欄中執行,請在快顯視窗或彈出視窗中重試此動作。" }, "personalOwnershipSubmitError": { - "message": "由於某個企業原則,您被限制為儲存項目到您的個人密碼庫。將擁有權變更為組織,並從可用的集合中選擇。" + "message": "由於企業政策限制,您無法將項目儲存至個人密碼庫。請將「所有權」選項變更為組織,並從可用的集合中選擇。" }, "personalOwnershipPolicyInEffect": { - "message": "組織原則正在影響您的擁有權選項。" + "message": "組織政策正在影響您的所有權選項。" }, "personalOwnershipPolicyInEffectImports": { "message": "某個組織原則已禁止您將項目匯入至您的個人密碼庫。" @@ -2696,7 +2729,7 @@ "message": "無法匯入卡片項目類別" }, "restrictCardTypeImportDesc": { - "message": "由於一或多個組織設有政策,您無法匯入支付卡至您的密碼庫。" + "message": "由於一或多個組織設有政策,您無法匯入付款卡至您的密碼庫。" }, "domainsTitle": { "message": "網域", @@ -2712,10 +2745,7 @@ "message": "排除網域" }, "excludedDomainsDesc": { - "message": "Bitwarden 不會要求儲存這些網域的詳細登入資訊。必須重新整理頁面才能使變更生效。" - }, - "excludedDomainsDescAlt": { - "message": "對於所有已登入的帳戶,Bitwarden 不會詢問是否儲存這些網域的登入資訊。您必須重新整理頁面變更才會生效。" + "message": "Bitwarden 不會在這些網域要求儲存登入資料。您必須重新整理頁面,變更才會生效。" }, "blockedDomainsDesc": { "message": "自動填入及其它相關的功能無法在這些網站上使用。您必須重新整理頁面來使變更生效。" @@ -2866,7 +2896,7 @@ } }, "excludedDomainsInvalidDomain": { - "message": "$DOMAIN$ 不是一個有效的網域", + "message": "$DOMAIN$ 不是有效的網域", "placeholders": { "domain": { "content": "$1", @@ -2945,7 +2975,7 @@ "message": "刪除" }, "removedPassword": { - "message": "已移除密碼" + "message": "密碼已移除" }, "deletedSend": { "message": "Send 已刪除", @@ -2985,7 +3015,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "expirationDate": { - "message": "逾期日期" + "message": "到期日期" }, "oneDay": { "message": "1 天" @@ -3002,10 +3032,6 @@ "custom": { "message": "自訂" }, - "sendPasswordDescV3": { - "message": "新增一個用於收件人存取此 Send 的可選密碼。", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "建立新 Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3018,7 +3044,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendDisabledWarning": { - "message": "由於企業原則限制,您只能刪除現有的 Send。", + "message": "由於企業政策限制,您只能刪除現有的 Send。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "createdSend": { @@ -3065,43 +3091,23 @@ "message": "Send 已儲存", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendFilePopoutDialogText": { - "message": "彈出擴充程式?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendFilePopoutDialogDesc": { - "message": "要建立檔案 Send,您需要彈出擴充程式到新視窗。", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendLinuxChromiumFileWarning": { - "message": "要選擇檔案,請在側邊欄中開啟擴充套件(若可以),或點選此橫幅彈出至新視窗。" - }, - "sendFirefoxFileWarning": { - "message": "要使用 Firefox 來選擇檔案,請在側邊欄中開啟擴充套件,或點選此橫幅彈出至新視窗。" - }, - "sendSafariFileWarning": { - "message": "要使用 Safari 來選擇檔案,請點選此橫幅彈出至新視窗。" - }, "popOut": { "message": "彈出" }, - "sendFileCalloutHeader": { - "message": "在開始之前" - }, "expirationDateIsInvalid": { - "message": "指定的逾期日期無效。" + "message": "指定的到期日期無效。" }, "deletionDateIsInvalid": { "message": "指定的刪除日期無效。" }, "expirationDateAndTimeRequired": { - "message": "要求指定逾期日期和時間。" + "message": "需要指定到期日期和時間。" }, "deletionDateAndTimeRequired": { - "message": "要求指定刪除日期和時間。" + "message": "需要指定刪除日期和時間。" }, "dateParsingError": { - "message": "儲存刪除日期和逾期日期時發生錯誤。" + "message": "儲存刪除日期和到期日期時發生錯誤。" }, "hideYourEmail": { "message": "對查看者隱藏您的電子郵件地址。" @@ -3113,28 +3119,28 @@ "message": "確認主密碼" }, "passwordConfirmationDesc": { - "message": "此動作受到保護。若要繼續,請重新輸入您的主密碼以驗證您的身份。" + "message": "此動作受保護。請重新輸入主密碼以驗證您的身分,才能繼續。" }, "emailVerificationRequired": { - "message": "需要驗證電子郵件" + "message": "需要電子郵件驗證" }, "emailVerifiedV2": { "message": "電子郵件已驗證" }, "emailVerificationRequiredDesc": { - "message": "您必須驗證您的電子郵件才能使用此功能。您可以在網頁密碼庫裡驗證您的電子郵件。" + "message": "必須驗證電子郵件才能使用此功能。您可以在網頁版密碼庫中驗證電子郵件。" }, "masterPasswordSuccessfullySet": { "message": "主密碼設定成功" }, "updatedMasterPassword": { - "message": "已更新主密碼" + "message": "主密碼已更新" }, "updateMasterPassword": { "message": "更新主密碼" }, "updateMasterPasswordWarning": { - "message": "您的主密碼最近被您的組織管理者變更過。若要存取密碼庫,您必須立即更新主密碼。繼續操作會登出目前的登入階段,並要求您重新登入。其他裝置上的活動登入階段最多會保持一個小時。" + "message": "您的主密碼近期已由所屬組織的管理員變更。若要存取密碼庫,您必須立即更新主密碼。繼續操作將會登出目前的工作階段,並需要您重新登入。其他裝置上的使用中工作階段可能仍會維持最長可達一小時。" }, "updateWeakMasterPasswordWarning": { "message": "您的主密碼不符合一個或多個組織政策規定。您必須立即更新您的主密碼才能存取密碼庫。進行此動作將登出您目前的工作階段,需要您重新登入。其他裝置上的工作階段可能持續長達一小時。" @@ -3143,13 +3149,13 @@ "message": "您的組織停用了信任裝置加密。若要存取您的密碼庫,請設定主密碼。" }, "resetPasswordPolicyAutoEnroll": { - "message": "自動註冊" + "message": "自動加入" }, "resetPasswordAutoEnrollInviteWarning": { - "message": "此組織有一個可以為您自動註冊密碼重設的企業原則。註冊後將允許組織管理員變更您的主密碼。" + "message": "此組織有一項企業政策,會自動將您加入密碼重設。加入後,組織管理員將能變更您的主密碼。" }, "selectFolder": { - "message": "選擇資料夾⋯" + "message": "選擇資料夾…" }, "noFoldersFound": { "message": "未找到資料夾", @@ -3251,19 +3257,19 @@ } }, "vaultTimeoutTooLarge": { - "message": "您的密碼庫逾時時間超過組織設定的限制。" + "message": "您的密碼庫逾時時間超過您所屬組織設定的限制。" }, "vaultExportDisabled": { - "message": "密碼庫匯出已停用" + "message": "密碼庫匯出不可用" }, "personalVaultExportPolicyInEffect": { - "message": "一個或多個組織原則禁止您匯出個人密碼庫。" + "message": "一項或多項組織政策禁止您匯出個人密碼庫。" }, "copyCustomFieldNameInvalidElement": { - "message": "未能找出有效的表單元件。請試試看改用 HTML 檢查功能。" + "message": "無法識別有效的表單元素。請嘗試檢查 HTML。" }, "copyCustomFieldNameNotUnique": { - "message": "找不到唯一識別碼。" + "message": "未找到唯一識別碼。" }, "organizationName": { "message": "組織名稱" @@ -3281,22 +3287,22 @@ "message": "主密碼已移除" }, "leaveOrganizationConfirmation": { - "message": "您確定要離開這個組織嗎?" + "message": "您確定要離開此組織嗎?" }, "leftOrganization": { "message": "您已離開此組織。" }, "toggleCharacterCount": { - "message": "切換字元計數" + "message": "顯示/隱藏字元數" }, "sessionTimeout": { - "message": "您的登入階段已逾時,請返回並嘗試重新登入。" + "message": "您的工作階段已逾時,請返回並重新登入。" }, "exportingPersonalVaultTitle": { - "message": "正匯出個人密碼庫" + "message": "正在匯出個人密碼庫" }, "exportingIndividualVaultDescription": { - "message": "僅匯出與 $EMAIL$ 關聯的個人密碼庫項目。組織密碼庫項目將不包括在內。僅匯出密碼庫項目資訊,不包括關聯的附件。", + "message": "僅匯出與 $EMAIL$ 相關的個人密碼庫項目,不包含組織密碼庫項目。僅匯出項目資訊,不包含相關附件。", "placeholders": { "email": { "content": "$1", @@ -3346,6 +3352,12 @@ "error": { "message": "錯誤" }, + "prfUnlockFailed": { + "message": "使用通行金鑰解鎖失敗。請再試一次或改用其他解鎖方式。" + }, + "noPrfCredentialsAvailable": { + "message": "沒有可用的支援 PRF 的通行金鑰可用於解鎖。請先使用通行金鑰登入。" + }, "decryptionError": { "message": "解密發生錯誤" }, @@ -3408,13 +3420,13 @@ "description": "Username generator option that appends a random sub-address to the username. For example: address+subaddress@email.com" }, "plusAddressedEmailDesc": { - "message": "使用您電子郵件提供者的子地址功能。" + "message": "使用您的電子郵件服務提供者的子地址功能。" }, "catchallEmail": { "message": "Catch-all 電子郵件" }, "catchallEmailDesc": { - "message": "使用您的網域設定的 Catch-all 收件匣。" + "message": "使用您網域中已設定的 Catch-all 收件匣。" }, "random": { "message": "隨機" @@ -3429,10 +3441,10 @@ "message": "服務" }, "forwardedEmail": { - "message": "轉寄的電子郵件別名" + "message": "轉寄電子郵件別名" }, "forwardedEmailDesc": { - "message": "使用外部轉寄服務產生一個電子郵件別名。" + "message": "使用外部轉寄服務產生電子郵件別名。" }, "forwarderDomainName": { "message": "電子郵件網域", @@ -3579,7 +3591,7 @@ "message": "API 金鑰" }, "ssoKeyConnectorError": { - "message": "Key Connector 錯誤:請確保 Key Connector 可用且運作正常。" + "message": "Key Connector 錯誤:請確認 Key Connector 可用且運作正常。" }, "premiumSubcriptionRequired": { "message": "需要進階版訂閲" @@ -3588,7 +3600,7 @@ "message": "組織已停用。" }, "disabledOrganizationFilterError": { - "message": "無法存取已停用組織中的項目。請聯絡您組織的擁有者以獲取協助。" + "message": "無法存取已停用組織中的項目。請聯絡組織擁有者以取得協助。" }, "loggingInTo": { "message": "正在登入至 $DOMAIN$", @@ -3603,13 +3615,13 @@ "message": "伺服器版本" }, "selfHostedServer": { - "message": "自架" + "message": "自行託管(自行部署並管理)" }, "thirdParty": { "message": "第三方" }, "thirdPartyServerMessage": { - "message": "已連線至第三方伺服器實作,$SERVERNAME$。 請使用官方伺服器驗證錯誤,或將其報告給第三方伺服器。", + "message": "已連線至第三方伺服器實作:$SERVERNAME$。請使用官方伺服器驗證是否為程式錯誤,或向第三方伺服器回報。", "placeholders": { "servername": { "content": "$1", @@ -3893,10 +3905,10 @@ "message": "記住這個裝置" }, "uncheckIfPublicDevice": { - "message": "若使用公用裝置,請勿勾選" + "message": "若為公用裝置,請取消勾選" }, "approveFromYourOtherDevice": { - "message": "使用其他裝置核准" + "message": "在其他裝置上核准" }, "requestAdminApproval": { "message": "要求管理員核准" @@ -4010,7 +4022,7 @@ "message": "搜尋" }, "inputMinLength": { - "message": "必須輸入至少 $COUNT$ 個字元。", + "message": "輸入內容至少需 $COUNT$ 個字元。", "placeholders": { "count": { "content": "$1", @@ -4019,7 +4031,7 @@ } }, "inputMaxLength": { - "message": "輸入的內容長度不得超過 $COUNT$ 字元。", + "message": "輸入內容不得超過 $COUNT$ 個字元。", "placeholders": { "count": { "content": "$1", @@ -4055,10 +4067,10 @@ } }, "multipleInputEmails": { - "message": "一個或多個電子郵件無效" + "message": "一或多個電子郵件地址無效" }, "inputTrimValidator": { - "message": "輸入不得僅包含空格。", + "message": "輸入內容不得僅包含空白字元。", "description": "Notification to inform the user that a form's input can't contain only whitespace." }, "inputEmail": { @@ -4086,7 +4098,7 @@ } }, "selectPlaceholder": { - "message": "-- 選擇 --" + "message": "-- 請選擇 --" }, "multiSelectPlaceholder": { "message": "-- 輸入以進行篩選 --" @@ -4280,7 +4292,7 @@ "message": "使用 PIN 碼" }, "useBiometrics": { - "message": "用生物識別" + "message": "使用生物辨識" }, "enterVerificationCodeSentToEmail": { "message": "輸入傳送到你的電子郵件的驗證碼。" @@ -4349,7 +4361,7 @@ "message": "選擇匯入檔案的格式" }, "selectImportFile": { - "message": "選擇要匯入的檔案" + "message": "選擇匯入的檔案" }, "chooseFile": { "message": "選擇檔案" @@ -4358,10 +4370,10 @@ "message": "未選擇任何檔案" }, "orCopyPasteFileContents": { - "message": "或複製/貼上要匯入的檔案內容" + "message": "或複製/貼上匯入檔案的內容" }, "instructionsFor": { - "message": "$NAME$ 教學", + "message": "$NAME$ 操作說明", "description": "The title for the import tool instructions.", "placeholders": { "name": { @@ -4371,10 +4383,10 @@ } }, "confirmVaultImport": { - "message": "確認匯入密碼庫" + "message": "確認密碼庫匯入" }, "confirmVaultImportDesc": { - "message": "此檔案受密碼保護,請輸入檔案密碼以匯入資料。" + "message": "此檔案受密碼保護。請輸入檔案密碼以匯入資料。" }, "confirmFilePassword": { "message": "確認檔案密碼" @@ -4398,13 +4410,13 @@ "message": "不會將密碼金鑰複製到拓製的項目中。您想繼續拓製該項目嗎?" }, "logInWithPasskeyQuestion": { - "message": "使用密碼金鑰登入?" + "message": "要使用密碼金鑰登入嗎?" }, "passkeyAlreadyExists": { - "message": "用於這個應用程式的密碼金鑰已經存在。" + "message": "此應用程式已存在密碼金鑰。" }, "noPasskeysFoundForThisApplication": { - "message": "未發現用於這個應用程式的密碼金鑰。" + "message": "此應用程式未發現密碼金鑰。" }, "noMatchingPasskeyLogin": { "message": "您沒有符合該網站的登入資訊。" @@ -4434,16 +4446,16 @@ "message": "密碼金鑰項目" }, "overwritePasskey": { - "message": "要覆寫密碼金鑰嗎?" + "message": "要覆寫目前的密碼金鑰嗎?" }, "overwritePasskeyAlert": { - "message": "該項目已包含密碼金鑰。您確定要覆寫目前的密碼金鑰嗎?" + "message": "此項目已包含密碼金鑰。確定要覆寫目前的密碼金鑰嗎?" }, "featureNotSupported": { "message": "尚未支援此功能" }, "yourPasskeyIsLocked": { - "message": "使用密碼金鑰需要身分驗證。請驗證您的身份以繼續。" + "message": "使用密碼金鑰需要身分驗證。請驗證身分以繼續。" }, "multifactorAuthenticationCancelled": { "message": "多因素驗證已取消" @@ -4452,7 +4464,7 @@ "message": "未找到任何 LastPass 資料" }, "incorrectUsernameOrPassword": { - "message": "使用者名稱或密碼不正確" + "message": "使用者名稱或密碼錯誤" }, "incorrectPassword": { "message": "密碼錯誤" @@ -4494,7 +4506,7 @@ "message": "需要 LastPass 驗證" }, "awaitingSSO": { - "message": "等待 SSO 驗證" + "message": "正在等待 SSO 驗證" }, "awaitingSSODesc": { "message": "請使用您的公司憑證繼續登入。" @@ -4531,7 +4543,7 @@ "message": "目前帳戶" }, "bitwardenAccount": { - "message": "Bitwarden 帳號" + "message": "Bitwarden 帳戶" }, "availableAccounts": { "message": "可用帳戶" @@ -4721,6 +4733,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "更多選項" + }, "moreOptionsTitle": { "message": "更多選項 - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4811,6 +4826,39 @@ "adminConsole": { "message": "管理控制台" }, + "admin": { + "message": "管理員" + }, + "automaticUserConfirmation": { + "message": "自動使用者確認" + }, + "automaticUserConfirmationHint": { + "message": "在此裝置解鎖時自動確認待處理的使用者" + }, + "autoConfirmOnboardingCallout": { + "message": "透過自動使用者確認節省時間" + }, + "autoConfirmWarning": { + "message": "可能影響您的組織資料安全性。" + }, + "autoConfirmWarningLink": { + "message": "了解風險" + }, + "autoConfirmSetup": { + "message": "自動確認新使用者" + }, + "autoConfirmSetupDesc": { + "message": "當此裝置處於解鎖狀態時,新的使用者將會自動獲得確認。" + }, + "autoConfirmSetupHint": { + "message": "潛在的安全性風險有哪些?" + }, + "autoConfirmEnabled": { + "message": "開啟自動確認" + }, + "availableNow": { + "message": "立即可用" + }, "accountSecurity": { "message": "帳戶安全性" }, @@ -4935,11 +4983,14 @@ } } }, + "downloadAttachmentLabel": { + "message": "下載附件" + }, "downloadBitwarden": { "message": "下載 Bitwarden" }, "downloadBitwardenOnAllDevices": { - "message": "在所有裝置中下載 Bitwarden" + "message": "在所有裝置上下載 Bitwarden" }, "getTheMobileApp": { "message": "取得手機應用程式" @@ -4954,7 +5005,7 @@ "message": "在不使用瀏覽器的情況下存取你的密碼庫,然後設定生物辨識解鎖,以加快在桌面應用程式和瀏覽器擴充功能中的解鎖速度。" }, "downloadFromBitwardenNow": { - "message": "立即從 bitwarden.com 下載" + "message": "立即前往 bitwarden.com 下載" }, "getItOnGooglePlay": { "message": "在 Google Play上取得" @@ -5074,14 +5125,11 @@ } } }, - "hideMatchDetection": { - "message": "隱藏偵測到的吻合 $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "顯示比對偵測" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "隱藏比對偵測" }, "autoFillOnPageLoad": { "message": "在頁面載入時自動填寫?" @@ -5350,10 +5398,10 @@ "message": "企業政策已套用至您的選項中" }, "sshPrivateKey": { - "message": "私密金鑰" + "message": "私鑰" }, "sshPublicKey": { - "message": "公共金鑰" + "message": "公鑰" }, "sshFingerprint": { "message": "指紋" @@ -5380,7 +5428,7 @@ "message": "自訂逾時時間最小為 1 分鐘。" }, "fileSavedToDevice": { - "message": "檔案已儲存至裝置。在您的裝置中管理下載的檔案。" + "message": "檔案已儲存至裝置。請在裝置的下載項目中管理檔案。" }, "showCharacterCount": { "message": "顯示字元數" @@ -5619,6 +5667,9 @@ "extraWide": { "message": "超寬" }, + "narrow": { + "message": "縮小" + }, "sshKeyWrongPassword": { "message": "您輸入的密碼錯誤。" }, @@ -5668,10 +5719,10 @@ "message": "此登入資訊存在風險,且缺少網站。請新增網站並變更密碼以提升安全性。" }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "有安全疑慮的密碼。" }, "changeNow": { - "message": "Change now" + "message": "立即變更" }, "missingWebsite": { "message": "缺少網站" @@ -5910,7 +5961,10 @@ "message": "郵編 / 郵政代碼" }, "cardNumberLabel": { - "message": "支付卡號碼" + "message": "付款卡號碼" + }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "您的組織已不再使用主密碼登入 Bitwarden。若要繼續,請驗證組織與網域。" @@ -6049,7 +6103,38 @@ "whyAmISeeingThis": { "message": "為什麼我會看到此訊息?" }, + "items": { + "message": "項目" + }, + "searchResults": { + "message": "搜尋結果" + }, "resizeSideNavigation": { "message": "調整側邊欄大小" + }, + "whoCanView": { + "message": "誰可以檢視" + }, + "specificPeople": { + "message": "特定人員" + }, + "emailVerificationDesc": { + "message": "分享此 Send 連結後,收件者需使用驗證碼驗證其電子郵件,才能檢視此 Send。" + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "請以逗號分隔輸入多個電子郵件地址。" + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "downloadBitwardenApps": { + "message": "下載 Bitwarden 應用程式" + }, + "emailProtected": { + "message": "電子郵件已受保護" + }, + "sendPasswordHelperText": { + "message": "對方必須輸入密碼才能檢視此 Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } } 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/notification.background.ts b/apps/browser/src/autofill/background/abstractions/notification.background.ts index e50a317e8a7..bc416d98634 100644 --- a/apps/browser/src/autofill/background/abstractions/notification.background.ts +++ b/apps/browser/src/autofill/background/abstractions/notification.background.ts @@ -4,70 +4,70 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { CollectionView } from "../../content/components/common-types"; -import { NotificationType, NotificationTypes } from "../../enums/notification-type.enum"; +import { NotificationType } from "../../enums/notification-type.enum"; import AutofillPageDetails from "../../models/autofill-page-details"; /** - * @todo Remove Standard_ label when implemented as standard NotificationQueueMessage. + * Generic notification queue message structure. + * All notification types use this structure with type-specific data. */ -export interface Standard_NotificationQueueMessage { - // universal notification properties +export interface NotificationQueueMessage { domain: string; tab: chrome.tabs.Tab; launchTimestamp: number; expires: Date; wasVaultLocked: boolean; - - type: T; // NotificationType - data: D; // notification-specific data + type: T; + data: D; } -/** - * @todo Deprecate in favor of Standard_NotificationQueueMessage. - */ -interface NotificationQueueMessage { - type: NotificationTypes; - domain: string; - tab: chrome.tabs.Tab; - launchTimestamp: number; - expires: Date; - wasVaultLocked: boolean; -} +// Notification data type definitions +export type AddLoginNotificationData = { + username: string; + password: string; + uri: string; +}; -type ChangePasswordNotificationData = { +export type ChangePasswordNotificationData = { cipherIds: CipherView["id"][]; newPassword: string; }; -type AddChangePasswordNotificationQueueMessage = Standard_NotificationQueueMessage< +export type UnlockVaultNotificationData = never; + +export type AtRiskPasswordNotificationData = { + organizationName: string; + passwordChangeUri?: string; +}; + +// Notification queue message types using generic pattern +export type AddLoginQueueMessage = NotificationQueueMessage< + typeof NotificationType.AddLogin, + AddLoginNotificationData +>; + +export type AddChangePasswordNotificationQueueMessage = NotificationQueueMessage< typeof NotificationType.ChangePassword, ChangePasswordNotificationData >; -interface AddLoginQueueMessage extends NotificationQueueMessage { - type: "add"; - username: string; - password: string; - uri: string; -} +export type AddUnlockVaultQueueMessage = NotificationQueueMessage< + typeof NotificationType.UnlockVault, + UnlockVaultNotificationData +>; -interface AddUnlockVaultQueueMessage extends NotificationQueueMessage { - type: "unlock"; -} +export type AtRiskPasswordQueueMessage = NotificationQueueMessage< + typeof NotificationType.AtRiskPassword, + AtRiskPasswordNotificationData +>; -interface AtRiskPasswordQueueMessage extends NotificationQueueMessage { - type: "at-risk-password"; - organizationName: string; - passwordChangeUri?: string; -} - -type NotificationQueueMessageItem = +export type NotificationQueueMessageItem = | AddLoginQueueMessage | AddChangePasswordNotificationQueueMessage | AddUnlockVaultQueueMessage | AtRiskPasswordQueueMessage; -type LockedVaultPendingNotificationsData = { +export type LockedVaultPendingNotificationsData = { commandToRetry: { message: { command: string; @@ -80,26 +80,26 @@ type LockedVaultPendingNotificationsData = { target: string; }; -type AdjustNotificationBarMessageData = { +export type AdjustNotificationBarMessageData = { height: number; }; -type AddLoginMessageData = { +export type AddLoginMessageData = { username: string; password: string; url: string; }; -type UnlockVaultMessageData = { +export type UnlockVaultMessageData = { skipNotification?: boolean; }; /** - * @todo Extend generics to this type, see Standard_NotificationQueueMessage + * @todo Extend generics to this type, see NotificationQueueMessage * - use new `data` types as generic * - eliminate optional status of properties as needed per Notification Type */ -type NotificationBackgroundExtensionMessage = { +export type NotificationBackgroundExtensionMessage = { [key: string]: any; command: string; data?: Partial & @@ -119,7 +119,7 @@ type BackgroundMessageParam = { message: NotificationBackgroundExtensionMessage type BackgroundSenderParam = { sender: chrome.runtime.MessageSender }; type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSenderParam; -type NotificationBackgroundExtensionMessageHandlers = { +export type NotificationBackgroundExtensionMessageHandlers = { [key: string]: CallableFunction; unlockCompleted: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; bgGetFolderData: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; @@ -150,16 +150,3 @@ type NotificationBackgroundExtensionMessageHandlers = { bgGetActiveUserServerConfig: () => Promise; getWebVaultUrlForNotification: () => Promise; }; - -export { - AddChangePasswordNotificationQueueMessage, - AddLoginQueueMessage, - AddUnlockVaultQueueMessage, - NotificationQueueMessageItem, - LockedVaultPendingNotificationsData, - AdjustNotificationBarMessageData, - UnlockVaultMessageData, - AddLoginMessageData, - NotificationBackgroundExtensionMessage, - NotificationBackgroundExtensionMessageHandlers, -}; 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..95d4111987b 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; @@ -122,9 +126,11 @@ describe("NotificationBackground", () => { it("returns a cipher view when passed an `AddLoginQueueMessage`", () => { const message: AddLoginQueueMessage = { type: "add", - username: "test", - password: "password", - uri: "https://example.com", + data: { + username: "test", + password: "password", + uri: "https://example.com", + }, domain: "", tab: createChromeTabMock(), expires: new Date(), @@ -136,13 +142,13 @@ describe("NotificationBackground", () => { expect(cipherView.name).toEqual("example.com"); expect(cipherView.login).toEqual({ fido2Credentials: [], - password: message.password, + password: message.data.password, uris: [ { - _uri: message.uri, + _uri: message.data.uri, }, ], - username: message.username, + username: message.data.username, }); }); @@ -150,9 +156,11 @@ describe("NotificationBackground", () => { const folderId = "folder-id"; const message: AddLoginQueueMessage = { type: "add", - username: "test", - password: "password", - uri: "https://example.com", + data: { + username: "test", + password: "password", + uri: "https://example.com", + }, domain: "example.com", tab: createChromeTabMock(), expires: new Date(), @@ -166,6 +174,44 @@ describe("NotificationBackground", () => { expect(cipherView.folderId).toEqual(folderId); }); + + it("removes 'www.' prefix from hostname when generating cipher name", () => { + const message: AddLoginQueueMessage = { + type: "add", + data: { + username: "test", + password: "password", + uri: "https://www.example.com", + }, + domain: "www.example.com", + tab: createChromeTabMock(), + expires: new Date(), + wasVaultLocked: false, + launchTimestamp: 0, + }; + const cipherView = notificationBackground["convertAddLoginQueueMessageToCipherView"](message); + + expect(cipherView.name).toEqual("example.com"); + }); + + it("uses domain as fallback when hostname cannot be extracted from uri", () => { + const message: AddLoginQueueMessage = { + type: "add", + data: { + username: "test", + password: "password", + uri: "", + }, + domain: "fallback-domain.com", + tab: createChromeTabMock(), + expires: new Date(), + wasVaultLocked: false, + launchTimestamp: 0, + }; + const cipherView = notificationBackground["convertAddLoginQueueMessageToCipherView"](message); + + expect(cipherView.name).toEqual("fallback-domain.com"); + }); }); describe("notification bar extension message handlers and triggers", () => { @@ -290,7 +336,7 @@ describe("NotificationBackground", () => { username: "test", password: "password", uri: "https://example.com", - newPassword: null, + newPassword: "", }; beforeEach(() => { tab = createChromeTabMock(); @@ -323,7 +369,7 @@ describe("NotificationBackground", () => { ...mockModifyLoginCipherFormData, uri: "", }; - activeAccountStatusMock$.next(AuthenticationStatus.Locked); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); await notificationBackground.triggerAddLoginNotification(data, tab); @@ -389,14 +435,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 +472,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 +573,7 @@ describe("NotificationBackground", () => { ...mockModifyLoginCipherFormData, uri: "https://example.com", password: "newPasswordUpdatedElsewhere", - newPassword: null, + newPassword: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -589,7 +635,7 @@ describe("NotificationBackground", () => { "example.com", data?.newPassword, sender.tab, - true, + true, // will yield an unlock followed by an update password notification ); }); @@ -597,8 +643,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 +683,7 @@ describe("NotificationBackground", () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, uri: "https://example.com", - password: null, + password: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -665,7 +711,7 @@ describe("NotificationBackground", () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, uri: "https://example.com", - password: null, + password: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -686,6 +732,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 +2304,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 +2327,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; }); @@ -1051,8 +2586,11 @@ describe("NotificationBackground", () => { type: NotificationType.AddLogin, tab, domain: "example.com", - username: "test", - password: "updated-password", + data: { + username: "test", + password: "updated-password", + uri: "https://example.com", + }, wasVaultLocked: true, }); notificationBackground["notificationQueue"] = [queueMessage]; @@ -1066,7 +2604,7 @@ describe("NotificationBackground", () => { expect(updatePasswordSpy).toHaveBeenCalledWith( cipherView, - queueMessage.password, + queueMessage.data.password, message.edit, sender.tab, "testId", @@ -1138,9 +2676,14 @@ describe("NotificationBackground", () => { type: NotificationType.AddLogin, tab, domain: "example.com", - username: "test", - password: "password", + data: { + username: "test", + password: "password", + uri: "https://example.com", + }, wasVaultLocked: false, + launchTimestamp: Date.now(), + expires: new Date(Date.now() + 10000), }); notificationBackground["notificationQueue"] = [queueMessage]; const cipherView = mock({ @@ -1177,9 +2720,14 @@ describe("NotificationBackground", () => { type: NotificationType.AddLogin, tab, domain: "example.com", - username: "test", - password: "password", + data: { + username: "test", + password: "password", + uri: "https://example.com", + }, wasVaultLocked: false, + launchTimestamp: Date.now(), + expires: new Date(Date.now() + 10000), }); notificationBackground["notificationQueue"] = [queueMessage]; const cipherView = mock({ @@ -1190,13 +2738,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 +2747,6 @@ describe("NotificationBackground", () => { queueMessage, null, ); - expect(cipherEncryptSpy).toHaveBeenCalledWith(cipherView, "testId"); expect(createWithServerSpy).toHaveBeenCalled(); expect(tabSendMessageDataSpy).toHaveBeenCalledWith( sender.tab, @@ -1230,9 +2771,14 @@ describe("NotificationBackground", () => { type: NotificationType.AddLogin, tab, domain: "example.com", - username: "test", - password: "password", + data: { + username: "test", + password: "password", + uri: "https://example.com", + }, wasVaultLocked: false, + launchTimestamp: Date.now(), + expires: new Date(Date.now() + 10000), }); notificationBackground["notificationQueue"] = [queueMessage]; const cipherView = mock({ @@ -1241,13 +2787,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 +2795,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..3713cd7c4c2 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"; @@ -67,6 +68,7 @@ import { AddChangePasswordNotificationQueueMessage, AddLoginQueueMessage, AddLoginMessageData, + AtRiskPasswordQueueMessage, NotificationQueueMessageItem, LockedVaultPendingNotificationsData, NotificationBackgroundExtensionMessage, @@ -79,6 +81,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 +178,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 +322,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 +339,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 +347,7 @@ export default class NotificationBackground { modifyLoginData.uri, activeUserId, ); - if (!urlCiphers?.length) { + if (!(urlCiphers?.length > 0)) { return null; } @@ -499,12 +529,14 @@ export default class NotificationBackground { this.removeTabFromNotificationQueue(tab); const launchTimestamp = new Date().getTime(); - const queueMessage: NotificationQueueMessageItem = { + const queueMessage: AtRiskPasswordQueueMessage = { domain, wasVaultLocked, type: NotificationType.AtRiskPassword, - passwordChangeUri, - organizationName: organization.name, + data: { + passwordChangeUri, + organizationName: organization.name, + }, tab: tab, launchTimestamp, expires: new Date(launchTimestamp + NOTIFICATION_BAR_LIFESPAN_MS), @@ -583,10 +615,12 @@ export default class NotificationBackground { const launchTimestamp = new Date().getTime(); const message: AddLoginQueueMessage = { type: NotificationType.AddLogin, - username: loginInfo.username, - password: loginInfo.password, + data: { + username: loginInfo.username, + password: loginInfo.password, + uri: loginInfo.url, + }, domain: loginDomain, - uri: loginInfo.url, tab: tab, launchTimestamp, expires: new Date(launchTimestamp + NOTIFICATION_BAR_LIFESPAN_MS), @@ -596,6 +630,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 +912,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 +955,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 +1182,7 @@ export default class NotificationBackground { }); } + // @TODO this needs the whole input record, and not just newPassword private async pushChangePasswordToQueue( cipherIds: CipherView["id"][], loginDomain: string, @@ -843,16 +1296,23 @@ export default class NotificationBackground { // If the vault was locked, check if a cipher needs updating instead of creating a new one if (queueMessage.wasVaultLocked) { const allCiphers = await this.cipherService.getAllDecryptedForUrl( - queueMessage.uri, + queueMessage.data.uri, activeUserId, ); const existingCipher = allCiphers.find( (c) => - c.login.username != null && c.login.username.toLowerCase() === queueMessage.username, + c.login.username != null && + c.login.username.toLowerCase() === queueMessage.data.username, ); if (existingCipher != null) { - await this.updatePassword(existingCipher, queueMessage.password, edit, tab, activeUserId); + await this.updatePassword( + existingCipher, + queueMessage.data.password, + edit, + tab, + activeUserId, + ); return; } } @@ -866,13 +1326,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 +1368,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 +1396,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), @@ -1276,15 +1733,15 @@ export default class NotificationBackground { folderId?: string, ): CipherView { const uriView = new LoginUriView(); - uriView.uri = message.uri; + uriView.uri = message.data.uri; const loginView = new LoginView(); loginView.uris = [uriView]; - loginView.username = message.username; - loginView.password = message.password; + loginView.username = message.data.username; + loginView.password = message.data.password; const cipherView = new CipherView(); - cipherView.name = (Utils.getHostname(message.uri) || message.domain).replace(/^www\./, ""); + cipherView.name = (Utils.getHostname(message.data.uri) || message.domain).replace(/^www\./, ""); cipherView.folderId = folderId; cipherView.type = CipherType.Login; cipherView.login = loginView; 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 86cdbffe059..dea6dc5c44c 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.ts @@ -1,12 +1,9 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -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 { @@ -16,6 +13,8 @@ import { OverlayNotificationsBackground as OverlayNotificationsBackgroundInterface, OverlayNotificationsExtensionMessage, OverlayNotificationsExtensionMessageHandlers, + NotificationScenarios, + NotificationScenario, WebsiteOriginsWithFields, } from "./abstractions/overlay-notifications.background"; import NotificationBackground from "./notification.background"; @@ -25,7 +24,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg private activeFormSubmissionRequests: ActiveFormSubmissionRequests = new Set(); private modifyLoginCipherFormData: ModifyLoginCipherFormDataForTab = new Map(); private clearLoginCipherFormDataSubject: Subject = new Subject(); - private notificationFallbackTimeout: number | NodeJS.Timeout | null; + private notificationFallbackTimeout: number | NodeJS.Timeout | null = null; private readonly formSubmissionRequestMethods: Set = new Set(["POST", "PUT", "PATCH"]); private readonly extensionMessageHandlers: OverlayNotificationsExtensionMessageHandlers = { generatedPasswordFilled: ({ message, sender }) => @@ -34,7 +33,6 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg collectPageDetailsResponse: ({ message, sender }) => this.handleCollectPageDetailsResponse(message, sender), }; - constructor( private logService: LogService, private notificationBackground: NotificationBackground, @@ -63,7 +61,11 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg sender: chrome.runtime.MessageSender, ) { if (await this.shouldInitAddLoginOrChangePasswordNotification(message, sender)) { - this.websiteOriginsWithFields.set(sender.tab.id, this.getSenderUrlMatchPatterns(sender)); + const tabId = sender.tab?.id; + if (tabId === undefined) { + return; + } + this.websiteOriginsWithFields.set(tabId, this.getSenderUrlMatchPatterns(sender)); this.setupWebRequestsListeners(); } } @@ -80,11 +82,16 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg message: OverlayNotificationsExtensionMessage, sender: chrome.runtime.MessageSender, ) { + const tabId = sender.tab?.id; + if (tabId === undefined) { + return false; + } + return ( (await this.isAddLoginOrChangePasswordNotificationEnabled()) && !(await this.isSenderFromExcludedDomain(sender)) && - message.details?.fields?.length > 0 && - !this.websiteOriginsWithFields.has(sender.tab.id) + (message.details?.fields?.length ?? 0) > 0 && + !this.websiteOriginsWithFields.has(tabId) ); } @@ -107,8 +114,8 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg */ private getSenderUrlMatchPatterns(sender: chrome.runtime.MessageSender) { return new Set([ - ...generateDomainMatchPatterns(sender.url), - ...generateDomainMatchPatterns(sender.tab.url), + ...(sender.url ? generateDomainMatchPatterns(sender.url) : []), + ...(sender.tab?.url ? generateDomainMatchPatterns(sender.tab.url) : []), ]); } @@ -123,7 +130,8 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg message: OverlayNotificationsExtensionMessage, sender: chrome.runtime.MessageSender, ) => { - if (!this.websiteOriginsWithFields.has(sender.tab.id)) { + const tabId = sender.tab?.id; + if (tabId === undefined || !this.websiteOriginsWithFields.has(tabId)) { return; } @@ -135,25 +143,24 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg this.clearLoginCipherFormDataSubject.next(); const formData = { uri, username, password, newPassword }; - const existingModifyLoginData = this.modifyLoginCipherFormData.get(sender.tab.id); + const existingModifyLoginData = this.modifyLoginCipherFormData.get(tabId); if (existingModifyLoginData) { formData.username = formData.username || existingModifyLoginData.username; formData.password = formData.password || existingModifyLoginData.password; formData.newPassword = formData.newPassword || existingModifyLoginData.newPassword; } - this.modifyLoginCipherFormData.set(sender.tab.id, formData); + this.modifyLoginCipherFormData.set(tabId, formData); this.clearNotificationFallbackTimeout(); - this.notificationFallbackTimeout = setTimeout( - () => - this.setupNotificationInitTrigger( - sender.tab.id, - "", - this.modifyLoginCipherFormData.get(sender.tab.id), - ).catch((error) => this.logService.error(error)), - 1500, - ); + this.notificationFallbackTimeout = setTimeout(() => { + const modifyLoginData = this.modifyLoginCipherFormData.get(tabId); + if (modifyLoginData) { + this.setupNotificationInitTrigger(tabId, "", modifyLoginData).catch((error) => + this.logService.error(error), + ); + } + }, 1500); }; /** @@ -176,6 +183,10 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg private async isSenderFromExcludedDomain(sender: chrome.runtime.MessageSender): Promise { try { const senderOrigin = sender.origin; + if (!senderOrigin) { + return false; + } + const serverConfig = await this.notificationBackground.getActiveUserServerConfig(); const activeUserVault = serverConfig?.environment?.vault; if (activeUserVault === senderOrigin) { @@ -232,11 +243,12 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg details: chrome.webRequest.OnBeforeRequestDetails, ): undefined => { if (this.isPostSubmissionFormRedirection(details)) { - this.setupNotificationInitTrigger( - details.tabId, - details.requestId, - this.modifyLoginCipherFormData.get(details.tabId), - ).catch((error) => this.logService.error(error)); + const modifyLoginData = this.modifyLoginCipherFormData.get(details.tabId); + if (modifyLoginData) { + this.setupNotificationInitTrigger(details.tabId, details.requestId, modifyLoginData).catch( + (error) => this.logService.error(error), + ); + } return; } @@ -269,7 +281,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg const shouldAttemptAddNotification = this.shouldAttemptNotification( modifyLoginData, - NotificationTypes.Add, + NotificationScenarios.Add, ); if (shouldAttemptAddNotification) { @@ -278,7 +290,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg const shouldAttemptChangeNotification = this.shouldAttemptNotification( modifyLoginData, - NotificationTypes.Change, + NotificationScenarios.Change, ); if (shouldAttemptChangeNotification) { @@ -385,6 +397,10 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg this.clearNotificationFallbackTimeout(); const tab = await BrowserApi.getTab(tabId); + if (!tab) { + return; + } + if (tab.status !== "complete") { await this.delayNotificationInitUntilTabIsComplete(tabId, requestId, modifyLoginData); return; @@ -410,7 +426,9 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg const handleWebNavigationOnCompleted = async () => { chrome.webNavigation.onCompleted.removeListener(handleWebNavigationOnCompleted); const tab = await BrowserApi.getTab(tabId); - await this.processNotifications(requestId, modifyLoginData, tab); + if (tab) { + await this.processNotifications(requestId, modifyLoginData, tab); + } }; chrome.webNavigation.onCompleted.addListener(handleWebNavigationOnCompleted); }; @@ -427,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}`); @@ -471,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; @@ -486,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/background/web-request.background.ts b/apps/browser/src/autofill/background/web-request.background.ts index 5c02f2df34d..5bab219d0b1 100644 --- a/apps/browser/src/autofill/background/web-request.background.ts +++ b/apps/browser/src/autofill/background/web-request.background.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -28,7 +26,7 @@ export default class WebRequestBackground { this.webRequest.onAuthRequired.addListener( (async ( details: chrome.webRequest.OnAuthRequiredDetails, - callback: (response: chrome.webRequest.BlockingResponse) => void, + callback: (response: chrome.webRequest.BlockingResponse | null) => void, ) => { if (!details.url || this.pendingAuthRequests.has(details.requestId)) { if (callback) { @@ -51,16 +49,16 @@ export default class WebRequestBackground { ); this.webRequest.onCompleted.addListener((details) => this.completeAuthRequest(details), { - urls: ["http://*/*"], + urls: ["http://*/*", "https://*/*"], }); this.webRequest.onErrorOccurred.addListener((details) => this.completeAuthRequest(details), { - urls: ["http://*/*"], + urls: ["http://*/*", "https://*/*"], }); } private async resolveAuthCredentials( domain: string, - success: (response: chrome.webRequest.BlockingResponse) => void, + success: (response: chrome.webRequest.BlockingResponse | null) => void, // eslint-disable-next-line error: Function, ) { @@ -82,7 +80,7 @@ export default class WebRequestBackground { const ciphers = await this.cipherService.getAllDecryptedForUrl( domain, activeUserId, - null, + undefined, UriMatchStrategy.Host, ); if (ciphers == null || ciphers.length !== 1) { @@ -90,10 +88,17 @@ export default class WebRequestBackground { return; } + const username = ciphers[0].login?.username; + const password = ciphers[0].login?.password; + if (username == null || password == null) { + error(); + return; + } + success({ authCredentials: { - username: ciphers[0].login.username, - password: ciphers[0].login.password, + username, + password, }, }); } catch { diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts index 61d6b9dc480..5c2b266f829 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts @@ -4,8 +4,10 @@ import { of } from "rxjs"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AUTOFILL_ID, + COPY_IDENTIFIER_ID, COPY_PASSWORD_ID, COPY_USERNAME_ID, COPY_VERIFICATION_CODE_ID, @@ -85,6 +87,7 @@ describe("ContextMenuClickedHandler", () => { accountService = mockAccountServiceWith(mockUserId as UserId); totpService = mock(); eventCollectionService = mock(); + userVerificationService = mock(); sut = new ContextMenuClickedHandler( copyToClipboard, @@ -102,6 +105,93 @@ describe("ContextMenuClickedHandler", () => { afterEach(() => jest.resetAllMocks()); describe("run", () => { + beforeEach(() => { + authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked); + userVerificationService.hasMasterPasswordAndMasterKeyHash.mockResolvedValue(false); + }); + + const runWithUrl = (data: chrome.contextMenus.OnClickData) => + sut.run(data, { url: "https://test.com" } as any); + + describe("early returns", () => { + it.each([ + { + name: "tab id is missing", + data: createData(COPY_IDENTIFIER_ID), + tab: { url: "https://test.com" } as any, + expectNotCalled: () => expect(copyToClipboard).not.toHaveBeenCalled(), + }, + { + name: "tab url is missing", + data: createData(`${COPY_USERNAME_ID}_${NOOP_COMMAND_SUFFIX}`, COPY_USERNAME_ID), + tab: {} as any, + expectNotCalled: () => { + expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled(); + expect(copyToClipboard).not.toHaveBeenCalled(); + }, + }, + ])("returns early when $name", async ({ data, tab, expectNotCalled }) => { + await expect(sut.run(data, tab)).resolves.toBeUndefined(); + expectNotCalled(); + }); + }); + + describe("missing cipher", () => { + it.each([ + { + label: "AUTOFILL", + parentId: AUTOFILL_ID, + extra: () => expect(autofill).not.toHaveBeenCalled(), + }, + { label: "username", parentId: COPY_USERNAME_ID, extra: () => {} }, + { label: "password", parentId: COPY_PASSWORD_ID, extra: () => {} }, + { + label: "totp", + parentId: COPY_VERIFICATION_CODE_ID, + extra: () => expect(totpService.getCode$).not.toHaveBeenCalled(), + }, + ])("breaks silently when cipher is missing for $label", async ({ parentId, extra }) => { + cipherService.getAllDecrypted.mockResolvedValue([]); + + await expect(runWithUrl(createData(`${parentId}_1`, parentId))).resolves.toBeUndefined(); + + expect(copyToClipboard).not.toHaveBeenCalled(); + extra(); + }); + }); + + describe("missing login properties", () => { + it.each([ + { + label: "username", + parentId: COPY_USERNAME_ID, + unset: (c: CipherView): void => (c.login.username = undefined), + }, + { + label: "password", + parentId: COPY_PASSWORD_ID, + unset: (c: CipherView): void => (c.login.password = undefined), + }, + { + label: "totp", + parentId: COPY_VERIFICATION_CODE_ID, + unset: (c: CipherView): void => (c.login.totp = undefined), + isTotp: true, + }, + ])("breaks silently when $label property is missing", async ({ parentId, unset, isTotp }) => { + const cipher = createCipher(); + unset(cipher); + cipherService.getAllDecrypted.mockResolvedValue([cipher]); + + await expect(runWithUrl(createData(`${parentId}_1`, parentId))).resolves.toBeUndefined(); + + expect(copyToClipboard).not.toHaveBeenCalled(); + if (isTotp) { + expect(totpService.getCode$).not.toHaveBeenCalled(); + } + }); + }); + it("can generate password", async () => { await sut.run(createData(GENERATE_PASSWORD_ID), { id: 5 } as any); diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts index 6f0979d4fd5..aa01ada0838 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { firstValueFrom } from "rxjs"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; @@ -72,6 +70,10 @@ export class ContextMenuClickedHandler { await this.generatePasswordToClipboard(tab); break; case COPY_IDENTIFIER_ID: + if (!tab.id) { + return; + } + this.copyToClipboard({ text: await this.getIdentifier(tab, info), tab: tab }); break; default: @@ -120,6 +122,10 @@ export class ContextMenuClickedHandler { if (isCreateCipherAction) { // pass; defer to logic below } else if (menuItemId === NOOP_COMMAND_SUFFIX) { + if (!tab.url) { + return; + } + const additionalCiphersToGet = info.parentMenuItemId === AUTOFILL_IDENTITY_ID ? [CipherType.Identity] @@ -158,6 +164,10 @@ export class ContextMenuClickedHandler { break; } + if (!cipher) { + break; + } + if (await this.isPasswordRepromptRequired(cipher)) { await openVaultItemPasswordRepromptPopout(tab, { cipherId: cipher.id, @@ -176,6 +186,10 @@ export class ContextMenuClickedHandler { break; } + if (!cipher || !cipher.login?.username) { + break; + } + this.copyToClipboard({ text: cipher.login.username, tab: tab }); break; case COPY_PASSWORD_ID: @@ -184,6 +198,10 @@ export class ContextMenuClickedHandler { break; } + if (!cipher || !cipher.login?.password) { + break; + } + if (await this.isPasswordRepromptRequired(cipher)) { await openVaultItemPasswordRepromptPopout(tab, { cipherId: cipher.id, @@ -205,6 +223,10 @@ export class ContextMenuClickedHandler { break; } + if (!cipher || !cipher.login?.totp) { + break; + } + if (await this.isPasswordRepromptRequired(cipher)) { await openVaultItemPasswordRepromptPopout(tab, { cipherId: cipher.id, @@ -240,9 +262,10 @@ export class ContextMenuClickedHandler { } private async getIdentifier(tab: chrome.tabs.Tab, info: chrome.contextMenus.OnClickData) { + const tabId = tab.id!; return new Promise((resolve, reject) => { BrowserApi.sendTabsMessage( - tab.id, + tabId, { command: "getClickedElement" }, { frameId: info.frameId }, (identifier: string) => { diff --git a/apps/browser/src/autofill/content/autofill-init.spec.ts b/apps/browser/src/autofill/content/autofill-init.spec.ts index d612e63f82c..eef7fe32dd0 100644 --- a/apps/browser/src/autofill/content/autofill-init.spec.ts +++ b/apps/browser/src/autofill/content/autofill-init.spec.ts @@ -347,6 +347,18 @@ describe("AutofillInit", () => { ); }); + it("removes the LOAD event listener", () => { + jest.spyOn(window, "removeEventListener"); + + autofillInit.init(); + autofillInit.destroy(); + + expect(window.removeEventListener).toHaveBeenCalledWith( + "load", + autofillInit["sendCollectDetailsMessage"], + ); + }); + it("removes the extension message listeners", () => { autofillInit.destroy(); diff --git a/apps/browser/src/autofill/content/autofill-init.ts b/apps/browser/src/autofill/content/autofill-init.ts index b6fc6c3392e..80cfe5de49f 100644 --- a/apps/browser/src/autofill/content/autofill-init.ts +++ b/apps/browser/src/autofill/content/autofill-init.ts @@ -72,21 +72,24 @@ class AutofillInit implements AutofillInitInterface { * to act on the page. */ private collectPageDetailsOnLoad() { - const sendCollectDetailsMessage = () => { - this.clearCollectPageDetailsOnLoadTimeout(); - this.collectPageDetailsOnLoadTimeout = setTimeout( - () => this.sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }), - 750, - ); - }; - if (globalThis.document.readyState === "complete") { - sendCollectDetailsMessage(); + this.sendCollectDetailsMessage(); } - globalThis.addEventListener(EVENTS.LOAD, sendCollectDetailsMessage); + globalThis.addEventListener(EVENTS.LOAD, this.sendCollectDetailsMessage); } + /** + * Sends a message to collect page details after a short delay. + */ + private sendCollectDetailsMessage = () => { + this.clearCollectPageDetailsOnLoadTimeout(); + this.collectPageDetailsOnLoadTimeout = setTimeout( + () => this.sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }), + 750, + ); + }; + /** * Collects the page details and sends them to the * extension background script. If the `sendDetailsInResponse` @@ -218,6 +221,7 @@ class AutofillInit implements AutofillInitInterface { */ destroy() { this.clearCollectPageDetailsOnLoadTimeout(); + globalThis.removeEventListener(EVENTS.LOAD, this.sendCollectDetailsMessage); chrome.runtime.onMessage.removeListener(this.handleExtensionMessage); this.collectAutofillContentService.destroy(); this.autofillOverlayContentService?.destroy(); diff --git a/apps/browser/src/autofill/content/components/buttons/action-button.ts b/apps/browser/src/autofill/content/components/buttons/action-button.ts index 73fc1e79ec5..e83f2b4b77c 100644 --- a/apps/browser/src/autofill/content/components/buttons/action-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/action-button.ts @@ -3,6 +3,7 @@ import { html, TemplateResult } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { border, themes, typography, spacing } from "../constants/styles"; import { Spinner } from "../icons"; @@ -26,7 +27,7 @@ export function ActionButton({ fullWidth = true, }: ActionButtonProps) { const handleButtonClick = (event: Event) => { - if (!disabled && !isLoading) { + if (EventSecurity.isEventTrusted(event) && !disabled && !isLoading) { handleClick(event); } }; diff --git a/apps/browser/src/autofill/content/components/buttons/badge-button.ts b/apps/browser/src/autofill/content/components/buttons/badge-button.ts index 3cdd453ee1a..98968d0b57b 100644 --- a/apps/browser/src/autofill/content/components/buttons/badge-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/badge-button.ts @@ -3,6 +3,7 @@ import { html } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { border, themes, typography, spacing } from "../constants/styles"; export type BadgeButtonProps = { @@ -23,7 +24,7 @@ export function BadgeButton({ username, }: BadgeButtonProps) { const handleButtonClick = (event: Event) => { - if (!disabled) { + if (EventSecurity.isEventTrusted(event) && !disabled) { buttonAction(event); } }; diff --git a/apps/browser/src/autofill/content/components/buttons/edit-button.ts b/apps/browser/src/autofill/content/components/buttons/edit-button.ts index ecbb736bb8e..88caae13590 100644 --- a/apps/browser/src/autofill/content/components/buttons/edit-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/edit-button.ts @@ -3,6 +3,7 @@ import { html } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { themes, typography, spacing } from "../constants/styles"; import { PencilSquare } from "../icons"; @@ -21,7 +22,7 @@ export function EditButton({ buttonAction, buttonText, disabled = false, theme } aria-label=${buttonText} class=${editButtonStyles({ disabled, theme })} @click=${(event: Event) => { - if (!disabled) { + if (EventSecurity.isEventTrusted(event) && !disabled) { buttonAction(event); } }} 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/notification/confirmation/message.ts b/apps/browser/src/autofill/content/components/notification/confirmation/message.ts index 36ea9c1f9d6..480b2acd0dd 100644 --- a/apps/browser/src/autofill/content/components/notification/confirmation/message.ts +++ b/apps/browser/src/autofill/content/components/notification/confirmation/message.ts @@ -3,6 +3,7 @@ import { html, nothing } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../../utils/event-security"; import { spacing, themes, typography } from "../../constants/styles"; export type NotificationConfirmationMessageProps = { @@ -127,7 +128,7 @@ const AdditionalMessageStyles = ({ theme }: { theme: Theme }) => css` `; function handleButtonKeyDown(event: KeyboardEvent, handleClick: () => void) { - if (event.key === "Enter" || event.key === " ") { + if (EventSecurity.isEventTrusted(event) && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); handleClick(); } diff --git a/apps/browser/src/autofill/content/components/option-selection/option-item.ts b/apps/browser/src/autofill/content/components/option-selection/option-item.ts index 6af6a2d6538..1cbabcb4f85 100644 --- a/apps/browser/src/autofill/content/components/option-selection/option-item.ts +++ b/apps/browser/src/autofill/content/components/option-selection/option-item.ts @@ -3,6 +3,7 @@ import { html, nothing } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { IconProps, Option } from "../common-types"; import { themes, spacing } from "../constants/styles"; @@ -29,6 +30,13 @@ export function OptionItem({ handleSelection, }: OptionItemProps) { const handleSelectionKeyUpProxy = (event: KeyboardEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + const listenedForKeys = new Set(["Enter", "Space"]); if (listenedForKeys.has(event.code) && event.target instanceof Element) { handleSelection(); @@ -37,6 +45,17 @@ export function OptionItem({ return; }; + const handleSelectionClickProxy = (event: MouseEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + + handleSelection(); + }; + const iconProps: IconProps = { color: themes[theme].text.main, theme }; const itemIcon = icon?.(iconProps); const ariaLabel = @@ -52,7 +71,7 @@ export function OptionItem({ title=${text} role="option" aria-label=${ariaLabel} - @click=${handleSelection} + @click=${handleSelectionClickProxy} @keyup=${handleSelectionKeyUpProxy} > ${itemIcon ? html`
${itemIcon}
` : nothing} diff --git a/apps/browser/src/autofill/content/components/option-selection/option-items.ts b/apps/browser/src/autofill/content/components/option-selection/option-items.ts index 58216b6c1b2..4c24a2fde8b 100644 --- a/apps/browser/src/autofill/content/components/option-selection/option-items.ts +++ b/apps/browser/src/autofill/content/components/option-selection/option-items.ts @@ -3,6 +3,7 @@ import { html, nothing } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { Option } from "../common-types"; import { themes, typography, scrollbarStyles, spacing } from "../constants/styles"; @@ -57,6 +58,10 @@ export function OptionItems({ } function handleMenuKeyUp(event: KeyboardEvent) { + if (!EventSecurity.isEventTrusted(event)) { + return; + } + const items = [ ...(event.currentTarget as HTMLElement).querySelectorAll('[tabindex="0"]'), ]; diff --git a/apps/browser/src/autofill/content/components/option-selection/option-selection.ts b/apps/browser/src/autofill/content/components/option-selection/option-selection.ts index ee711456e9c..78c7d9f0646 100644 --- a/apps/browser/src/autofill/content/components/option-selection/option-selection.ts +++ b/apps/browser/src/autofill/content/components/option-selection/option-selection.ts @@ -4,6 +4,7 @@ import { property, state } from "lit/decorators.js"; import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { OptionSelectionButton } from "../buttons/option-selection-button"; import { Option } from "../common-types"; @@ -54,7 +55,7 @@ export class OptionSelection extends LitElement { private static currentOpenInstance: OptionSelection | null = null; private handleButtonClick = async (event: Event) => { - if (!this.disabled) { + if (EventSecurity.isEventTrusted(event) && !this.disabled) { const isOpening = !this.showMenu; if (isOpening) { 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/content/content-message-handler.spec.ts b/apps/browser/src/autofill/content/content-message-handler.spec.ts index 874e1cc76ff..fb17874b0b7 100644 --- a/apps/browser/src/autofill/content/content-message-handler.spec.ts +++ b/apps/browser/src/autofill/content/content-message-handler.spec.ts @@ -3,6 +3,7 @@ import { mock } from "jest-mock-extended"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; import { postWindowMessage, sendMockExtensionMessage } from "../spec/testing-utils"; +import { EventSecurity } from "../utils/event-security"; describe("ContentMessageHandler", () => { const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage"); @@ -19,6 +20,7 @@ describe("ContentMessageHandler", () => { ); beforeEach(() => { + jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-require-imports require("./content-message-handler"); diff --git a/apps/browser/src/autofill/content/content-message-handler.ts b/apps/browser/src/autofill/content/content-message-handler.ts index 63afc215923..874e760c4f8 100644 --- a/apps/browser/src/autofill/content/content-message-handler.ts +++ b/apps/browser/src/autofill/content/content-message-handler.ts @@ -1,6 +1,8 @@ import { ExtensionPageUrls } from "@bitwarden/common/vault/enums"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; +import { EventSecurity } from "../utils/event-security"; + import { ContentMessageWindowData, ContentMessageWindowEventHandlers, @@ -92,7 +94,10 @@ function handleOpenBrowserExtensionToUrlMessage({ url }: { url?: ExtensionPageUr */ function handleWindowMessageEvent(event: MessageEvent) { const { source, data, origin } = event; - if (source !== window || !data?.command) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event) || source !== window || !data?.command) { return; } diff --git a/apps/browser/src/autofill/content/context-menu-handler.ts b/apps/browser/src/autofill/content/context-menu-handler.ts index d3926d57c9a..919ab5f1a3d 100644 --- a/apps/browser/src/autofill/content/context-menu-handler.ts +++ b/apps/browser/src/autofill/content/context-menu-handler.ts @@ -1,3 +1,5 @@ +import { EventSecurity } from "../utils/event-security"; + const inputTags = ["input", "textarea", "select"]; const labelTags = ["label", "span"]; const attributeKeys = ["id", "name", "label-aria", "placeholder"]; @@ -52,6 +54,12 @@ function isNullOrEmpty(s: string | null) { // We only have access to the element that's been clicked when the context menu is first opened. // Remember it for use later. document.addEventListener("contextmenu", (event) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } clickedElement = event.target as HTMLElement; }); 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 61ed7a8ed08..78bb9aa8f33 100644 --- a/apps/browser/src/autofill/fido2/content/messaging/messenger.ts +++ b/apps/browser/src/autofill/fido2/content/messaging/messenger.ts @@ -23,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(); /** @@ -58,12 +60,6 @@ export class Messenger { this.broadcastChannel.addEventListener(this.messageEventListener); } - private stripMetadata({ SENDER, senderId, ...message }: MessageWithMetadata): Message { - void SENDER; - void senderId; - return message; - } - /** * Sends a request to the content script and returns the response. * AbortController signals will be forwarded to the content script. @@ -78,9 +74,7 @@ export class Messenger { try { const promise = new Promise((resolve) => { - localPort.onmessage = (event: MessageEvent) => { - resolve(this.stripMetadata(event.data)); - }; + localPort.onmessage = (event: MessageEvent) => resolve(event.data); }); const abortListener = () => @@ -135,9 +129,7 @@ export class Messenger { try { const handlerResponse = await this.handler(message, abortController); - if (handlerResponse !== undefined) { - port.postMessage({ ...handlerResponse, SENDER }); - } + port.postMessage({ ...handlerResponse, SENDER }); } catch (error) { port.postMessage({ SENDER, diff --git a/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts b/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts index e0ab45e9f84..19c1dbc8790 100644 --- a/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts +++ b/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { BehaviorSubject, EmptyError, @@ -79,7 +77,7 @@ export type BrowserFido2Message = { sessionId: string } & ( } | { type: typeof BrowserFido2MessageTypes.PickCredentialResponse; - cipherId?: string; + cipherId: string; userVerified: boolean; } | { diff --git a/apps/browser/src/autofill/fido2/utils/webauthn-utils.ts b/apps/browser/src/autofill/fido2/utils/webauthn-utils.ts index 0cccd91876d..07ffa553b07 100644 --- a/apps/browser/src/autofill/fido2/utils/webauthn-utils.ts +++ b/apps/browser/src/autofill/fido2/utils/webauthn-utils.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CreateCredentialResult, AssertCredentialResult, diff --git a/apps/browser/src/autofill/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/overlay/inline-menu/abstractions/autofill-inline-menu-iframe.service.ts b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-iframe.service.ts index f55faec887a..ab8b0e2553e 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-iframe.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-iframe.service.ts @@ -32,4 +32,5 @@ export type BackgroundPortMessageHandlers = { export interface AutofillInlineMenuIframeService { initMenuIframe(): void; + destroy(): void; } diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts index b7bd24c537b..00c214c32e7 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts @@ -645,6 +645,292 @@ describe("AutofillInlineMenuContentService", () => { expect(disconnectSpy).toHaveBeenCalled(); }); + + it("unobserves custom elements", () => { + const disconnectSpy = jest.spyOn( + autofillInlineMenuContentService["inlineMenuElementsMutationObserver"], + "disconnect", + ); + + autofillInlineMenuContentService.destroy(); + + expect(disconnectSpy).toHaveBeenCalled(); + }); + + it("unobserves the container element", () => { + const disconnectSpy = jest.spyOn( + autofillInlineMenuContentService["containerElementMutationObserver"], + "disconnect", + ); + + autofillInlineMenuContentService.destroy(); + + expect(disconnectSpy).toHaveBeenCalled(); + }); + + it("clears the mutation observer iterations reset timeout", () => { + jest.useFakeTimers(); + const clearTimeoutSpy = jest.spyOn(globalThis, "clearTimeout"); + autofillInlineMenuContentService["mutationObserverIterationsResetTimeout"] = setTimeout( + jest.fn(), + 1000, + ); + + autofillInlineMenuContentService.destroy(); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + expect(autofillInlineMenuContentService["mutationObserverIterationsResetTimeout"]).toBeNull(); + }); + + it("destroys the button iframe", () => { + const mockButtonIframe = { destroy: jest.fn() }; + autofillInlineMenuContentService["buttonIframe"] = mockButtonIframe as any; + + autofillInlineMenuContentService.destroy(); + + expect(mockButtonIframe.destroy).toHaveBeenCalled(); + }); + + it("destroys the list iframe", () => { + const mockListIframe = { destroy: jest.fn() }; + autofillInlineMenuContentService["listIframe"] = mockListIframe as any; + + autofillInlineMenuContentService.destroy(); + + expect(mockListIframe.destroy).toHaveBeenCalled(); + }); + }); + + describe("observeCustomElements", () => { + it("observes the button element for attribute mutations", () => { + const buttonElement = document.createElement("div"); + autofillInlineMenuContentService["buttonElement"] = buttonElement; + const observeSpy = jest.spyOn( + autofillInlineMenuContentService["inlineMenuElementsMutationObserver"], + "observe", + ); + + autofillInlineMenuContentService["observeCustomElements"](); + + expect(observeSpy).toHaveBeenCalledWith(buttonElement, { attributes: true }); + }); + + it("observes the list element for attribute mutations", () => { + const listElement = document.createElement("div"); + autofillInlineMenuContentService["listElement"] = listElement; + const observeSpy = jest.spyOn( + autofillInlineMenuContentService["inlineMenuElementsMutationObserver"], + "observe", + ); + + autofillInlineMenuContentService["observeCustomElements"](); + + expect(observeSpy).toHaveBeenCalledWith(listElement, { attributes: true }); + }); + + it("does not observe when no elements exist", () => { + autofillInlineMenuContentService["buttonElement"] = undefined; + autofillInlineMenuContentService["listElement"] = undefined; + const observeSpy = jest.spyOn( + autofillInlineMenuContentService["inlineMenuElementsMutationObserver"], + "observe", + ); + + autofillInlineMenuContentService["observeCustomElements"](); + + expect(observeSpy).not.toHaveBeenCalled(); + }); + }); + + describe("observeContainerElement", () => { + it("observes the container element for child list mutations", () => { + const containerElement = document.createElement("div"); + const observeSpy = jest.spyOn( + autofillInlineMenuContentService["containerElementMutationObserver"], + "observe", + ); + + autofillInlineMenuContentService["observeContainerElement"](containerElement); + + expect(observeSpy).toHaveBeenCalledWith(containerElement, { childList: true }); + }); + }); + + describe("unobserveContainerElement", () => { + it("disconnects the container element mutation observer", () => { + const disconnectSpy = jest.spyOn( + autofillInlineMenuContentService["containerElementMutationObserver"], + "disconnect", + ); + + autofillInlineMenuContentService["unobserveContainerElement"](); + + expect(disconnectSpy).toHaveBeenCalled(); + }); + + it("handles the case when the mutation observer is undefined", () => { + autofillInlineMenuContentService["containerElementMutationObserver"] = undefined as any; + + expect(() => autofillInlineMenuContentService["unobserveContainerElement"]()).not.toThrow(); + }); + }); + + describe("observePageAttributes", () => { + it("observes the document element for attribute mutations", () => { + const observeSpy = jest.spyOn( + autofillInlineMenuContentService["htmlMutationObserver"], + "observe", + ); + + autofillInlineMenuContentService["observePageAttributes"](); + + expect(observeSpy).toHaveBeenCalledWith(document.documentElement, { attributes: true }); + }); + + it("observes the body element for attribute mutations", () => { + const observeSpy = jest.spyOn( + autofillInlineMenuContentService["bodyMutationObserver"], + "observe", + ); + + autofillInlineMenuContentService["observePageAttributes"](); + + expect(observeSpy).toHaveBeenCalledWith(document.body, { attributes: true }); + }); + }); + + describe("unobservePageAttributes", () => { + it("disconnects the html mutation observer", () => { + const disconnectSpy = jest.spyOn( + autofillInlineMenuContentService["htmlMutationObserver"], + "disconnect", + ); + + autofillInlineMenuContentService["unobservePageAttributes"](); + + expect(disconnectSpy).toHaveBeenCalled(); + }); + + it("disconnects the body mutation observer", () => { + const disconnectSpy = jest.spyOn( + autofillInlineMenuContentService["bodyMutationObserver"], + "disconnect", + ); + + autofillInlineMenuContentService["unobservePageAttributes"](); + + expect(disconnectSpy).toHaveBeenCalled(); + }); + }); + + describe("checkPageRisks", () => { + it("returns true and closes inline menu when page is not opaque", async () => { + jest.spyOn(autofillInlineMenuContentService as any, "getPageIsOpaque").mockReturnValue(false); + const closeInlineMenuSpy = jest.spyOn( + autofillInlineMenuContentService as any, + "closeInlineMenu", + ); + + const result = await autofillInlineMenuContentService["checkPageRisks"](); + + expect(result).toBe(true); + expect(closeInlineMenuSpy).toHaveBeenCalled(); + }); + + it("returns true and closes inline menu when inline menu is disabled", async () => { + jest.spyOn(autofillInlineMenuContentService as any, "getPageIsOpaque").mockReturnValue(true); + autofillInlineMenuContentService["inlineMenuEnabled"] = false; + const closeInlineMenuSpy = jest.spyOn( + autofillInlineMenuContentService as any, + "closeInlineMenu", + ); + + const result = await autofillInlineMenuContentService["checkPageRisks"](); + + expect(result).toBe(true); + expect(closeInlineMenuSpy).toHaveBeenCalled(); + }); + + it("returns false when page is opaque and inline menu is enabled", async () => { + jest.spyOn(autofillInlineMenuContentService as any, "getPageIsOpaque").mockReturnValue(true); + autofillInlineMenuContentService["inlineMenuEnabled"] = true; + const closeInlineMenuSpy = jest.spyOn( + autofillInlineMenuContentService as any, + "closeInlineMenu", + ); + + const result = await autofillInlineMenuContentService["checkPageRisks"](); + + expect(result).toBe(false); + expect(closeInlineMenuSpy).not.toHaveBeenCalled(); + }); + }); + + describe("handlePageMutations", () => { + it("checks page risks when mutations include attribute changes", async () => { + const checkPageRisksSpy = jest.spyOn( + autofillInlineMenuContentService as any, + "checkPageRisks", + ); + const mutations = [{ type: "attributes" } as MutationRecord]; + + await autofillInlineMenuContentService["handlePageMutations"](mutations); + + expect(checkPageRisksSpy).toHaveBeenCalled(); + }); + + it("does not check page risks when mutations do not include attribute changes", async () => { + const checkPageRisksSpy = jest.spyOn( + autofillInlineMenuContentService as any, + "checkPageRisks", + ); + const mutations = [{ type: "childList" } as MutationRecord]; + + await autofillInlineMenuContentService["handlePageMutations"](mutations); + + expect(checkPageRisksSpy).not.toHaveBeenCalled(); + }); + }); + + describe("clearPersistentLastChildOverrideTimeout", () => { + it("clears the timeout when it exists", () => { + jest.useFakeTimers(); + const clearTimeoutSpy = jest.spyOn(globalThis, "clearTimeout"); + autofillInlineMenuContentService["handlePersistentLastChildOverrideTimeout"] = setTimeout( + jest.fn(), + 1000, + ); + + autofillInlineMenuContentService["clearPersistentLastChildOverrideTimeout"](); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); + + it("does nothing when the timeout is null", () => { + const clearTimeoutSpy = jest.spyOn(globalThis, "clearTimeout"); + autofillInlineMenuContentService["handlePersistentLastChildOverrideTimeout"] = null; + + autofillInlineMenuContentService["clearPersistentLastChildOverrideTimeout"](); + + expect(clearTimeoutSpy).not.toHaveBeenCalled(); + }); + }); + + describe("elementAtCenterOfInlineMenuPosition", () => { + it("returns the element at the center of the given position", () => { + const mockElement = document.createElement("div"); + jest.spyOn(globalThis.document, "elementFromPoint").mockReturnValue(mockElement); + + const result = autofillInlineMenuContentService["elementAtCenterOfInlineMenuPosition"]({ + top: 100, + left: 200, + width: 50, + height: 30, + }); + + expect(globalThis.document.elementFromPoint).toHaveBeenCalledWith(225, 115); + expect(result).toBe(mockElement); + }); }); describe("getOwnedTagNames", () => { @@ -975,6 +1261,25 @@ describe("AutofillInlineMenuContentService", () => { }); }); + describe("unobserveCustomElements", () => { + it("disconnects the inline menu elements mutation observer", () => { + const disconnectSpy = jest.spyOn( + autofillInlineMenuContentService["inlineMenuElementsMutationObserver"], + "disconnect", + ); + + autofillInlineMenuContentService["unobserveCustomElements"](); + + expect(disconnectSpy).toHaveBeenCalled(); + }); + + it("handles the case when the mutation observer is undefined", () => { + autofillInlineMenuContentService["inlineMenuElementsMutationObserver"] = undefined as any; + + expect(() => autofillInlineMenuContentService["unobserveCustomElements"]()).not.toThrow(); + }); + }); + describe("getPageIsOpaque", () => { it("returns false when no page elements exist", () => { jest.spyOn(globalThis.document, "querySelectorAll").mockReturnValue([] as any); diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts index b61e5e19d53..24e6f34df4b 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { InlineMenuElementPosition, InlineMenuPosition, @@ -43,7 +41,9 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte globalThis.navigator.userAgent.indexOf(" Firefox/") !== -1 || globalThis.navigator.userAgent.indexOf(" Gecko/") !== -1; private buttonElement?: HTMLElement; + private buttonIframe?: AutofillInlineMenuButtonIframe; private listElement?: HTMLElement; + private listIframe?: AutofillInlineMenuListIframe; private htmlMutationObserver: MutationObserver; private bodyMutationObserver: MutationObserver; private inlineMenuElementsMutationObserver: MutationObserver; @@ -62,8 +62,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte */ private inlineMenuEnabled = true; private mutationObserverIterations = 0; - private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout; - private handlePersistentLastChildOverrideTimeout: number | NodeJS.Timeout; + private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout | null = null; + private handlePersistentLastChildOverrideTimeout: number | NodeJS.Timeout | null = null; private lastElementOverrides: WeakMap = new WeakMap(); private readonly customElementDefaultStyles: Partial = { all: "initial", @@ -77,7 +77,21 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte }; constructor() { - this.setupMutationObserver(); + /** + * Sets up mutation observers for the inline menu elements, the menu container, and + * the document element. The mutation observers are used to remove any styles that + * are added to the inline menu elements by the website. They are also used to ensure + * that the inline menu elements are always present at the bottom of the menu container. + */ + this.htmlMutationObserver = new MutationObserver(this.handlePageMutations); + this.bodyMutationObserver = new MutationObserver(this.handlePageMutations); + this.inlineMenuElementsMutationObserver = new MutationObserver( + this.handleInlineMenuElementMutationObserverUpdate, + ); + this.containerElementMutationObserver = new MutationObserver( + this.handleContainerElementMutationObserverUpdate, + ); + this.observePageAttributes(); } /** @@ -181,12 +195,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * Updates the position of the inline menu button. */ private async appendButtonElement(): Promise { - if (!this.inlineMenuEnabled) { - return; - } - if (!this.buttonElement) { - this.createButtonElement(); + this.buttonElement = this.createButtonElement(); this.updateCustomElementDefaultStyles(this.buttonElement); } @@ -201,12 +211,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * Updates the position of the inline menu list. */ private async appendListElement(): Promise { - if (!this.inlineMenuEnabled) { - return; - } - if (!this.listElement) { - this.createListElement(); + this.listElement = this.createListElement(); this.updateCustomElementDefaultStyles(this.listElement); } @@ -257,31 +263,29 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * to create the element if it already exists in the DOM. */ private createButtonElement() { - if (!this.inlineMenuEnabled) { - return; - } - if (this.isFirefoxBrowser) { this.buttonElement = globalThis.document.createElement("div"); this.buttonElement.setAttribute("popover", "manual"); - new AutofillInlineMenuButtonIframe(this.buttonElement); + this.buttonIframe = new AutofillInlineMenuButtonIframe(this.buttonElement); - return; + return this.buttonElement; } const customElementName = this.generateRandomCustomElementName(); + const self = this; globalThis.customElements?.define( customElementName, class extends HTMLElement { constructor() { super(); - new AutofillInlineMenuButtonIframe(this); + self.buttonIframe = new AutofillInlineMenuButtonIframe(this); } }, ); this.buttonElement = globalThis.document.createElement(customElementName); this.buttonElement.setAttribute("popover", "manual"); + return this.buttonElement; } /** @@ -289,31 +293,29 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * to create the element if it already exists in the DOM. */ private createListElement() { - if (!this.inlineMenuEnabled) { - return; - } - if (this.isFirefoxBrowser) { this.listElement = globalThis.document.createElement("div"); this.listElement.setAttribute("popover", "manual"); - new AutofillInlineMenuListIframe(this.listElement); + this.listIframe = new AutofillInlineMenuListIframe(this.listElement); - return; + return this.listElement; } const customElementName = this.generateRandomCustomElementName(); + const self = this; globalThis.customElements?.define( customElementName, class extends HTMLElement { constructor() { super(); - new AutofillInlineMenuListIframe(this); + self.listIframe = new AutofillInlineMenuListIframe(this); } }, ); this.listElement = globalThis.document.createElement(customElementName); this.listElement.setAttribute("popover", "manual"); + return this.listElement; } /** @@ -330,27 +332,6 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte this.observeCustomElements(); } - /** - * Sets up mutation observers for the inline menu elements, the menu container, and - * the document element. The mutation observers are used to remove any styles that - * are added to the inline menu elements by the website. They are also used to ensure - * that the inline menu elements are always present at the bottom of the menu container. - */ - private setupMutationObserver = () => { - this.htmlMutationObserver = new MutationObserver(this.handlePageMutations); - this.bodyMutationObserver = new MutationObserver(this.handlePageMutations); - - this.inlineMenuElementsMutationObserver = new MutationObserver( - this.handleInlineMenuElementMutationObserverUpdate, - ); - - this.containerElementMutationObserver = new MutationObserver( - this.handleContainerElementMutationObserverUpdate, - ); - - this.observePageAttributes(); - }; - /** * Sets up mutation observers to verify that the inline menu * elements are not modified by the website. @@ -652,6 +633,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte return; } + if (!this.buttonElement) { + return; + } + const lastChild = containerElement.lastElementChild; const secondToLastChild = lastChild?.previousElementSibling; const lastChildIsInlineMenuList = lastChild === this.listElement; @@ -667,7 +652,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte this.lastElementOverrides.set(lastChild, lastChildEncounterCount + 1); } - if (this.lastElementOverrides.get(lastChild) >= 3) { + const lastChildEncounterCountAfterUpdate = this.lastElementOverrides.get(lastChild) || 0; + if (lastChildEncounterCountAfterUpdate >= 3) { this.handlePersistentLastChildOverride(lastChild); return; @@ -686,6 +672,9 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte (lastChildIsInlineMenuList && !secondToLastChildIsInlineMenuButton) || (lastChildIsInlineMenuButton && isInlineMenuListVisible) ) { + if (!this.listElement) { + return; + } containerElement.insertBefore(this.buttonElement, this.listElement); return; } @@ -793,5 +782,13 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte this.closeInlineMenu(); this.clearPersistentLastChildOverrideTimeout(); this.unobservePageAttributes(); + this.unobserveCustomElements(); + this.unobserveContainerElement(); + if (this.mutationObserverIterationsResetTimeout) { + clearTimeout(this.mutationObserverIterationsResetTimeout); + this.mutationObserverIterationsResetTimeout = null; + } + this.buttonIframe?.destroy(); + this.listIframe?.destroy(); } } diff --git a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe-element.ts b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe-element.ts index 3e2b364b17b..e26b6ba9ccc 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe-element.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe-element.ts @@ -1,6 +1,8 @@ import { AutofillInlineMenuIframeService } from "./autofill-inline-menu-iframe.service"; export class AutofillInlineMenuIframeElement { + private autofillInlineMenuIframeService: AutofillInlineMenuIframeService; + constructor( element: HTMLElement, portName: string, @@ -12,14 +14,14 @@ export class AutofillInlineMenuIframeElement { const shadow: ShadowRoot = element.attachShadow({ mode: "closed" }); shadow.prepend(style); - const autofillInlineMenuIframeService = new AutofillInlineMenuIframeService( + this.autofillInlineMenuIframeService = new AutofillInlineMenuIframeService( shadow, portName, initStyles, iframeTitle, ariaAlert, ); - autofillInlineMenuIframeService.initMenuIframe(); + this.autofillInlineMenuIframeService.initMenuIframe(); } /** @@ -67,4 +69,11 @@ export class AutofillInlineMenuIframeElement { return style; } + + /** + * Cleans up the iframe service to prevent memory leaks. + */ + destroy() { + this.autofillInlineMenuIframeService?.destroy(); + } } diff --git a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.spec.ts index f1ed6875f90..5e9d7c1da48 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.spec.ts @@ -752,4 +752,164 @@ describe("AutofillInlineMenuIframeService", () => { expect(autofillInlineMenuIframeService["iframe"].title).toBe("title"); }); }); + + describe("destroy", () => { + beforeEach(() => { + autofillInlineMenuIframeService.initMenuIframe(); + autofillInlineMenuIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD)); + portSpy = autofillInlineMenuIframeService["port"]; + }); + + it("removes the LOAD event listener from the iframe", () => { + const removeEventListenerSpy = jest.spyOn( + autofillInlineMenuIframeService["iframe"], + "removeEventListener", + ); + + autofillInlineMenuIframeService.destroy(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + EVENTS.LOAD, + autofillInlineMenuIframeService["setupPortMessageListener"], + ); + }); + + it("clears the aria alert timeout", () => { + jest.spyOn(autofillInlineMenuIframeService, "clearAriaAlert"); + autofillInlineMenuIframeService["ariaAlertTimeout"] = setTimeout(jest.fn(), 1000); + + autofillInlineMenuIframeService.destroy(); + + expect(autofillInlineMenuIframeService.clearAriaAlert).toHaveBeenCalled(); + }); + + it("clears the fade in timeout", () => { + jest.useFakeTimers(); + jest.spyOn(globalThis, "clearTimeout"); + autofillInlineMenuIframeService["fadeInTimeout"] = setTimeout(jest.fn(), 1000); + + autofillInlineMenuIframeService.destroy(); + + expect(globalThis.clearTimeout).toHaveBeenCalled(); + expect(autofillInlineMenuIframeService["fadeInTimeout"]).toBeNull(); + }); + + it("clears the delayed close timeout", () => { + jest.useFakeTimers(); + jest.spyOn(globalThis, "clearTimeout"); + autofillInlineMenuIframeService["delayedCloseTimeout"] = setTimeout(jest.fn(), 1000); + + autofillInlineMenuIframeService.destroy(); + + expect(globalThis.clearTimeout).toHaveBeenCalled(); + expect(autofillInlineMenuIframeService["delayedCloseTimeout"]).toBeNull(); + }); + + it("clears the mutation observer iterations reset timeout", () => { + jest.useFakeTimers(); + jest.spyOn(globalThis, "clearTimeout"); + autofillInlineMenuIframeService["mutationObserverIterationsResetTimeout"] = setTimeout( + jest.fn(), + 1000, + ); + + autofillInlineMenuIframeService.destroy(); + + expect(globalThis.clearTimeout).toHaveBeenCalled(); + expect(autofillInlineMenuIframeService["mutationObserverIterationsResetTimeout"]).toBeNull(); + }); + + it("unobserves the iframe mutation observer", () => { + const disconnectSpy = jest.spyOn( + autofillInlineMenuIframeService["iframeMutationObserver"], + "disconnect", + ); + + autofillInlineMenuIframeService.destroy(); + + expect(disconnectSpy).toHaveBeenCalled(); + }); + + it("removes the port message listeners and disconnects the port", () => { + autofillInlineMenuIframeService.destroy(); + + expect(portSpy.onMessage.removeListener).toHaveBeenCalledWith(handlePortMessageSpy); + expect(portSpy.onDisconnect.removeListener).toHaveBeenCalledWith(handlePortDisconnectSpy); + expect(portSpy.disconnect).toHaveBeenCalled(); + expect(autofillInlineMenuIframeService["port"]).toBeNull(); + }); + + it("handles the case when the port is null", () => { + autofillInlineMenuIframeService["port"] = null; + + expect(() => autofillInlineMenuIframeService.destroy()).not.toThrow(); + }); + + it("handles the case when the iframe is undefined", () => { + autofillInlineMenuIframeService["iframe"] = undefined as any; + + expect(() => autofillInlineMenuIframeService.destroy()).not.toThrow(); + }); + }); + + describe("clearAriaAlert", () => { + it("clears the aria alert timeout when it exists", () => { + jest.useFakeTimers(); + jest.spyOn(globalThis, "clearTimeout"); + autofillInlineMenuIframeService["ariaAlertTimeout"] = setTimeout(jest.fn(), 1000); + + autofillInlineMenuIframeService.clearAriaAlert(); + + expect(globalThis.clearTimeout).toHaveBeenCalled(); + expect(autofillInlineMenuIframeService["ariaAlertTimeout"]).toBeNull(); + }); + + it("does nothing when the aria alert timeout is null", () => { + jest.spyOn(globalThis, "clearTimeout"); + autofillInlineMenuIframeService["ariaAlertTimeout"] = null; + + autofillInlineMenuIframeService.clearAriaAlert(); + + expect(globalThis.clearTimeout).not.toHaveBeenCalled(); + }); + }); + + describe("unobserveIframe", () => { + it("disconnects the iframe mutation observer", () => { + autofillInlineMenuIframeService.initMenuIframe(); + const disconnectSpy = jest.spyOn( + autofillInlineMenuIframeService["iframeMutationObserver"], + "disconnect", + ); + + autofillInlineMenuIframeService["unobserveIframe"](); + + expect(disconnectSpy).toHaveBeenCalled(); + }); + + it("handles the case when the mutation observer is undefined", () => { + autofillInlineMenuIframeService["iframeMutationObserver"] = undefined as any; + + expect(() => autofillInlineMenuIframeService["unobserveIframe"]()).not.toThrow(); + }); + }); + + describe("observeIframe", () => { + beforeEach(() => { + autofillInlineMenuIframeService.initMenuIframe(); + }); + + it("observes the iframe for attribute mutations", () => { + const observeSpy = jest.spyOn( + autofillInlineMenuIframeService["iframeMutationObserver"], + "observe", + ); + + autofillInlineMenuIframeService["observeIframe"](); + + expect(observeSpy).toHaveBeenCalledWith(autofillInlineMenuIframeService["iframe"], { + attributes: true, + }); + }); + }); }); diff --git a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts index ad1241e98d2..40db2eef9fd 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe.service.ts @@ -555,4 +555,26 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe return false; } + + /** + * Cleans up all event listeners, timeouts, and observers to prevent memory leaks. + */ + destroy() { + this.iframe?.removeEventListener(EVENTS.LOAD, this.setupPortMessageListener); + this.clearAriaAlert(); + this.clearFadeInTimeout(); + if (this.delayedCloseTimeout) { + clearTimeout(this.delayedCloseTimeout); + this.delayedCloseTimeout = null; + } + if (this.mutationObserverIterationsResetTimeout) { + clearTimeout(this.mutationObserverIterationsResetTimeout); + this.mutationObserverIterationsResetTimeout = null; + } + this.unobserveIframe(); + this.port?.onMessage.removeListener(this.handlePortMessage); + this.port?.onDisconnect.removeListener(this.handlePortDisconnect); + this.port?.disconnect(); + this.port = null; + } } diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap b/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap index 22e3a765666..53a055075fe 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap @@ -716,7 +716,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f stroke-dasharray="78.5" stroke-dashoffset="78.5" stroke-width="3" - style="stroke-dashoffset: NaN;" + style="stroke-dashoffset: 34.033920413889426;" transform="rotate(-90 14.5 14.5)" /> @@ -737,7 +737,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f bittypography="helper" class="totp-sec-span" > - NaN + 17
@@ -2115,7 +2115,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f stroke-dasharray="78.5" stroke-dashoffset="78.5" stroke-width="3" - style="stroke-dashoffset: NaN;" + style="stroke-dashoffset: 34.033920413889426;" transform="rotate(-90 14.5 14.5)" /> @@ -2136,7 +2136,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f bittypography="helper" class="totp-sec-span" > - NaN + 17 @@ -2227,7 +2227,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f stroke-dasharray="78.5" stroke-dashoffset="78.5" stroke-width="3" - style="stroke-dashoffset: NaN;" + style="stroke-dashoffset: 34.033920413889426;" transform="rotate(-90 14.5 14.5)" /> @@ -2248,7 +2248,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f bittypography="helper" class="totp-sec-span" > - NaN + 17 diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts index 81bf7240230..212fe6d8c89 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts @@ -10,6 +10,7 @@ import { createInitAutofillInlineMenuListMessageMock, } from "../../../../spec/autofill-mocks"; import { flushPromises, postWindowMessage } from "../../../../spec/testing-utils"; +import { EventSecurity } from "../../../../utils/event-security"; import { AutofillInlineMenuList } from "./autofill-inline-menu-list"; @@ -28,6 +29,7 @@ describe("AutofillInlineMenuList", () => { const events: { eventName: any; callback: any }[] = []; beforeEach(() => { + jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true); const oldEv = globalThis.addEventListener; globalThis.addEventListener = (eventName: any, callback: any) => { events.push({ eventName, callback }); @@ -157,6 +159,8 @@ describe("AutofillInlineMenuList", () => { }); it("creates the view for a totp field", async () => { + jest.spyOn(Date, "now").mockReturnValue(13000); + postWindowMessage( createInitAutofillInlineMenuListMessageMock({ inlineMenuFillType: CipherType.Login, @@ -184,6 +188,8 @@ describe("AutofillInlineMenuList", () => { }); it("renders correctly when there are multiple TOTP elements with username displayed", async () => { + jest.spyOn(Date, "now").mockReturnValue(13000); + const totpCipher1 = createAutofillOverlayCipherDataMock(1, { type: CipherType.Login, login: { diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts index c680fe4745c..744e3579da1 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import "@webcomponents/custom-elements"; import "lit/polyfill-support.js"; @@ -12,6 +10,7 @@ import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; import { InlineMenuCipherData } from "../../../../background/abstractions/overlay.background"; import { InlineMenuFillType } from "../../../../enums/autofill-overlay.enum"; import { buildSvgDomElement, specialCharacterToKeyMap, throttle } from "../../../../utils"; +import { EventSecurity } from "../../../../utils/event-security"; import { creditCardIcon, globeIcon, @@ -33,27 +32,36 @@ import { import { AutofillInlineMenuPageElement } from "../shared/autofill-inline-menu-page-element"; export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { - private inlineMenuListContainer: HTMLDivElement; - private passwordGeneratorContainer: HTMLDivElement; + /** Non-null asserted. Set in initAutofillInlineMenuList before any read. */ + private inlineMenuListContainer!: HTMLDivElement; + /** Non-null asserted. Set in initAutofillInlineMenuList before any read. */ + private passwordGeneratorContainer!: HTMLDivElement; private resizeObserver: ResizeObserver; private eventHandlersMemo: { [key: string]: EventListener } = {}; private ciphers: InlineMenuCipherData[] = []; - private ciphersList: HTMLUListElement; + /** Non-null asserted. Set in buildInlineMenuList before any read. */ + private ciphersList!: HTMLUListElement; private cipherListScrollIsDebounced = false; - private cipherListScrollDebounceTimeout: number | NodeJS.Timeout; + private cipherListScrollDebounceTimeout: number | ReturnType = 0; private currentCipherIndex = 0; - private inlineMenuFillType: InlineMenuFillType; - private showInlineMenuAccountCreation: boolean; - private showPasskeysLabels: boolean; - private newItemButtonElement: HTMLButtonElement; - private passkeysHeadingElement: HTMLLIElement; - private loginHeadingElement: HTMLLIElement; - private lastPasskeysListItem: HTMLLIElement; - private passkeysHeadingHeight: number; - private lastPasskeysListItemHeight: number; - private ciphersListHeight: number; + /** Non-null asserted. Set in initAutofillInlineMenuList from message. */ + private inlineMenuFillType!: InlineMenuFillType; + private showInlineMenuAccountCreation = false; + private showPasskeysLabels = false; + /** Non-null asserted. Set in buildNewItemButton before any read. */ + private newItemButtonElement!: HTMLButtonElement; + /** Conditionally set in buildPasskeysHeadingElements, may be undefined when no passkeys. */ + private passkeysHeadingElement?: HTMLLIElement; + /** Conditionally set in buildPasskeysHeadingElements, may be undefined when no login heading. */ + private loginHeadingElement?: HTMLLIElement; + /** Conditionally set in buildInlineMenuListActionsItem when showPasskeysLabels and passkey cipher. */ + private lastPasskeysListItem?: HTMLLIElement; + private passkeysHeadingHeight = 0; + private lastPasskeysListItemHeight = 0; + private ciphersListHeight = 0; private isPasskeyAuthInProgress = false; - private authStatus: AuthenticationStatus; + private authStatus: AuthenticationStatus = AuthenticationStatus.Locked; + private isInitialized = false; private readonly showCiphersPerPage = 6; private readonly headingBorderClass = "inline-menu-list-heading--bordered"; private readonly inlineMenuListWindowMessageHandlers: AutofillInlineMenuListWindowMessageHandlers = @@ -70,6 +78,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { constructor() { super(); + this.resizeObserver = new ResizeObserver(this.handleResizeObserver); this.setupInlineMenuListGlobalListeners(); } @@ -85,11 +94,11 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { styleSheetUrl, theme, authStatus, - ciphers, + ciphers = [], portKey, - inlineMenuFillType, - showInlineMenuAccountCreation, - showPasskeysLabels, + inlineMenuFillType = CipherType.Login, + showInlineMenuAccountCreation = false, + showPasskeysLabels = false, generatedPassword, showSaveLoginMenu, } = message; @@ -112,6 +121,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { this.resizeObserver.observe(this.inlineMenuListContainer); this.shadowDom.append(linkElement, this.inlineMenuListContainer); + this.isInitialized = true; if (authStatus !== AuthenticationStatus.Unlocked) { this.buildLockedInlineMenu(); @@ -194,7 +204,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { private handleSaveLoginInlineMenuKeyUp = (event: KeyboardEvent) => { const listenedForKeys = new Set(["ArrowDown"]); - if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) { + if ( + /** + * Reject synthetic events (not originating from the user agent) + */ + !EventSecurity.isEventTrusted(event) || + !listenedForKeys.has(event.code) || + !(event.target instanceof Element) + ) { return; } @@ -220,7 +237,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * Handles the click event for the unlock button. * Sends a message to the parent window to unlock the vault. */ - private handleUnlockButtonClick = () => { + private handleUnlockButtonClick = (event: MouseEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + this.postMessageToParent({ command: "unlockVault" }); }; @@ -343,7 +367,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * Handles the click event for the fill generated password button. Triggers * a message to the background script to fill the generated password. */ - private handleFillGeneratedPasswordClick = () => { + private handleFillGeneratedPasswordClick = (event?: MouseEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (event && !EventSecurity.isEventTrusted(event)) { + return; + } + this.postMessageToParent({ command: "fillGeneratedPassword" }); }; @@ -353,7 +384,16 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param event - The keyup event. */ private handleFillGeneratedPasswordKeyUp = (event: KeyboardEvent) => { - if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if ( + !EventSecurity.isEventTrusted(event) || + event.ctrlKey || + event.altKey || + event.metaKey || + event.shiftKey + ) { return; } @@ -368,7 +408,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { event.target.nextElementSibling ) { (event.target.nextElementSibling as HTMLElement).focus(); - event.target.parentElement.classList.add("remove-outline"); + event.target.parentElement?.classList.add("remove-outline"); return; } }; @@ -379,6 +419,13 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param event - The click event. */ private handleRefreshGeneratedPasswordClick = (event?: MouseEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (event && !EventSecurity.isEventTrusted(event)) { + return; + } + if (event) { (event.target as HTMLElement) .closest(".password-generator-actions") @@ -394,7 +441,16 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param event - The keyup event. */ private handleRefreshGeneratedPasswordKeyUp = (event: KeyboardEvent) => { - if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if ( + !EventSecurity.isEventTrusted(event) || + event.ctrlKey || + event.altKey || + event.metaKey || + event.shiftKey + ) { return; } @@ -409,7 +465,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { event.target.previousElementSibling ) { (event.target.previousElementSibling as HTMLElement).focus(); - event.target.parentElement.classList.remove("remove-outline"); + event.target.parentElement?.classList.remove("remove-outline"); return; } }; @@ -473,8 +529,8 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param showInlineMenuAccountCreation - Whether identity ciphers are shown on login fields. */ private updateListItems({ - ciphers, - showInlineMenuAccountCreation, + ciphers = [], + showInlineMenuAccountCreation = false, }: UpdateAutofillInlineMenuListCiphersParams) { if (this.isPasskeyAuthInProgress) { return; @@ -611,7 +667,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * Handles the click event for the new item button. * Sends a message to the parent window to add a new vault item. */ - private handleNewLoginVaultItemAction = () => { + private handleNewLoginVaultItemAction = (event: MouseEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + let addNewCipherType = this.inlineMenuFillType; if (this.showInlineMenuAccountCreation) { @@ -655,7 +718,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * scroll listeners that reposition the passkeys and login headings when the user scrolls. */ private setupCipherListScrollListeners() { - const options = { passive: true }; + const options: AddEventListenerOptions = { passive: true }; this.ciphersList.addEventListener(EVENTS.SCROLL, this.updateCiphersListOnScroll, options); if (this.showPasskeysLabels) { this.ciphersList.addEventListener( @@ -673,8 +736,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * Handles updating the list of ciphers when the * user scrolls to the bottom of the list. */ - private updateCiphersListOnScroll = (event: MouseEvent) => { - event.preventDefault(); + private updateCiphersListOnScroll = (event: Event) => { event.stopPropagation(); if (this.cipherListScrollIsDebounced) { @@ -721,8 +783,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * * @param event - The scroll event. */ - private handleThrottledOnScrollEvent = (event: MouseEvent) => { - event.preventDefault(); + private handleThrottledOnScrollEvent = (event: Event) => { event.stopPropagation(); this.updatePasskeysHeadingsOnScroll(this.ciphersList.scrollTop); @@ -754,6 +815,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param cipherListScrollTop - The current scroll top position of the ciphers list. */ private togglePasskeysHeadingAnchored(cipherListScrollTop: number) { + if (!this.passkeysHeadingElement || !this.lastPasskeysListItem) { + return; + } if (!this.passkeysHeadingHeight) { this.passkeysHeadingHeight = this.passkeysHeadingElement.offsetHeight; } @@ -776,6 +840,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param cipherListScrollTop - The current scroll top position of the ciphers list. */ private togglePasskeysHeadingBorder(cipherListScrollTop: number) { + if (!this.passkeysHeadingElement) { + return; + } if (cipherListScrollTop < 1) { this.passkeysHeadingElement.classList.remove(this.headingBorderClass); return; @@ -791,6 +858,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param cipherListScrollTop - The current scroll top position of the ciphers list. */ private toggleLoginHeadingBorder(cipherListScrollTop: number) { + if (!this.loginHeadingElement || !this.lastPasskeysListItem) { + return; + } if (!this.lastPasskeysListItemHeight) { this.lastPasskeysListItemHeight = this.lastPasskeysListItem.offsetHeight; } @@ -884,7 +954,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { ); this.addFillCipherElementAriaDescription(fillCipherElement, cipher); - fillCipherElement.append(cipherIcon, cipherDetailsElement); + fillCipherElement.append(cipherIcon, ...(cipherDetailsElement ? [cipherDetailsElement] : [])); fillCipherElement.addEventListener(EVENTS.CLICK, this.handleFillCipherClickEvent(cipher)); fillCipherElement.addEventListener(EVENTS.KEYUP, this.handleFillCipherKeyUpEvent); @@ -942,7 +1012,16 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { private handleFillCipherClickEvent = (cipher: InlineMenuCipherData) => { const usePasskey = !!cipher.login?.passkey; return this.useEventHandlersMemo( - () => this.triggerFillCipherClickEvent(cipher, usePasskey), + (event: Event) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + + this.triggerFillCipherClickEvent(cipher, usePasskey); + }, `${cipher.id}-fill-cipher-button-click-handler-${usePasskey ? "passkey" : ""}`, ); }; @@ -974,7 +1053,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { */ private handleFillCipherKeyUpEvent = (event: KeyboardEvent) => { const listenedForKeys = new Set(["ArrowDown", "ArrowUp", "ArrowRight"]); - if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if ( + !EventSecurity.isEventTrusted(event) || + !listenedForKeys.has(event.code) || + !(event.target instanceof Element) + ) { return; } @@ -1002,7 +1088,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { */ private handleNewItemButtonKeyUpEvent = (event: KeyboardEvent) => { const listenedForKeys = new Set(["ArrowDown", "ArrowUp"]); - if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if ( + !EventSecurity.isEventTrusted(event) || + !listenedForKeys.has(event.code) || + !(event.target instanceof Element) + ) { return; } @@ -1047,11 +1140,16 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param cipher - The cipher to view. */ private handleViewCipherClickEvent = (cipher: InlineMenuCipherData) => { - return this.useEventHandlersMemo( - () => - this.postMessageToParent({ command: "viewSelectedCipher", inlineMenuCipherId: cipher.id }), - `${cipher.id}-view-cipher-button-click-handler`, - ); + return this.useEventHandlersMemo((event: Event) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + + this.postMessageToParent({ command: "viewSelectedCipher", inlineMenuCipherId: cipher.id }); + }, `${cipher.id}-view-cipher-button-click-handler`); }; /** @@ -1064,7 +1162,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { */ private handleViewCipherKeyUpEvent = (event: KeyboardEvent) => { const listenedForKeys = new Set(["ArrowDown", "ArrowUp", "ArrowLeft"]); - if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if ( + !EventSecurity.isEventTrusted(event) || + !listenedForKeys.has(event.code) || + !(event.target instanceof Element) + ) { return; } @@ -1126,7 +1231,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { cipherIcon.appendChild(totpContainer); - const intervalSeconds = cipher.login.totpCodeTimeInterval; + const intervalSeconds = cipher.login.totpCodeTimeInterval ?? 30; const updateCountdown = () => { const epoch = Math.round(Date.now() / 1000); @@ -1266,7 +1371,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { if (this.multipleTotpElements() && username) { const usernameSubtitle = this.buildCipherSubtitleElement(username); - containerElement.appendChild(usernameSubtitle); + if (usernameSubtitle) { + containerElement.appendChild(usernameSubtitle); + } } const totpCodeSpan = document.createElement("span"); @@ -1326,19 +1433,25 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { cipher: InlineMenuCipherData, cipherDetailsElement: HTMLSpanElement, ): HTMLSpanElement { - let rpNameSubtitle: HTMLSpanElement; + const login = cipher.login; + const passkey = login?.passkey; + if (!login || !passkey) { + return cipherDetailsElement; + } - if (cipher.name !== cipher.login.passkey.rpName) { - rpNameSubtitle = this.buildCipherSubtitleElement(cipher.login.passkey.rpName); - if (rpNameSubtitle) { + let rpNameSubtitle: HTMLSpanElement | undefined; + if (cipher.name !== passkey.rpName) { + const element = this.buildCipherSubtitleElement(passkey.rpName); + if (element) { + rpNameSubtitle = element; rpNameSubtitle.prepend(buildSvgDomElement(passkeyIcon)); rpNameSubtitle.classList.add("cipher-subtitle--passkey"); cipherDetailsElement.appendChild(rpNameSubtitle); } } - if (cipher.login.username) { - const usernameSubtitle = this.buildCipherSubtitleElement(cipher.login.username); + if (login.username) { + const usernameSubtitle = this.buildCipherSubtitleElement(login.username); if (usernameSubtitle) { if (!rpNameSubtitle) { usernameSubtitle.prepend(buildSvgDomElement(passkeyIcon)); @@ -1350,7 +1463,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { return cipherDetailsElement; } - const passkeySubtitle = this.buildCipherSubtitleElement(cipher.login.passkey.userName); + const passkeySubtitle = this.buildCipherSubtitleElement(passkey.userName); if (passkeySubtitle) { if (!rpNameSubtitle) { passkeySubtitle.prepend(buildSvgDomElement(passkeyIcon)); @@ -1412,6 +1525,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * If not focused, will check if the button element is focused. */ private checkInlineMenuListFocused() { + if (!this.isInitialized) { + return; + } if (globalThis.document.hasFocus()) { return; } @@ -1450,6 +1566,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * the first cipher button. */ private focusInlineMenuList() { + if (!this.isInitialized) { + return; + } this.inlineMenuListContainer.setAttribute("role", "dialog"); this.inlineMenuListContainer.setAttribute("aria-modal", "true"); @@ -1472,8 +1591,6 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { */ private setupInlineMenuListGlobalListeners() { this.setupGlobalListeners(this.inlineMenuListWindowMessageHandlers); - - this.resizeObserver = new ResizeObserver(this.handleResizeObserver); } /** diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts index 5df6e7cd190..e7f99b28ecc 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts @@ -1,6 +1,7 @@ import { EVENTS } from "@bitwarden/common/autofill/constants"; import { RedirectFocusDirection } from "../../../../enums/autofill-overlay.enum"; +import { EventSecurity } from "../../../../utils/event-security"; import { AutofillInlineMenuPageElementWindowMessage, AutofillInlineMenuPageElementWindowMessageHandlers, @@ -163,7 +164,10 @@ export class AutofillInlineMenuPageElement extends HTMLElement { */ private handleDocumentKeyDownEvent = (event: KeyboardEvent) => { const listenedForKeys = new Set(["Tab", "Escape", "ArrowUp", "ArrowDown"]); - if (!listenedForKeys.has(event.code)) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event) || !listenedForKeys.has(event.code)) { return; } diff --git a/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts b/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts index adabae2c31d..ace314c6a84 100644 --- a/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from "@angular/core"; @@ -33,13 +31,10 @@ export class Fido2CipherRowComponent { @Output() onSelected = new EventEmitter(); // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() cipher: CipherView; + @Input({ required: true }) cipher!: CipherView; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() last: boolean; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() title: string; + @Input({ required: true }) title!: string; protected selectCipher(c: CipherView) { this.onSelected.emit(c); diff --git a/apps/browser/src/autofill/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..74ff0de6f5c 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.html +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.html @@ -7,9 +7,7 @@

- {{ - accountSwitcherEnabled ? ("excludedDomainsDescAlt" | i18n) : ("excludedDomainsDesc" | 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..2316aef390e 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts @@ -35,7 +35,6 @@ import { TypographyModule, } from "@bitwarden/components"; -import { enableAccountSwitching } from "../../../platform/flags"; 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 +73,6 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { @ViewChildren("uriInput") uriInputElements: QueryList> = new QueryList(); - accountSwitcherEnabled = false; dataIsPristine = true; isLoading = false; excludedDomainsState: string[] = []; @@ -95,9 +93,7 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { private toastService: ToastService, private formBuilder: FormBuilder, private popupRouterCacheService: PopupRouterCacheService, - ) { - this.accountSwitcherEnabled = enableAccountSwitching(); - } + ) {} get domainForms() { return this.domainListForm.get("domains") as FormArray; diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts index 0fb031b52e8..c9a522c6b8c 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts @@ -23,6 +23,7 @@ import { sendMockExtensionMessage, } from "../spec/testing-utils"; import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types"; +import { EventSecurity } from "../utils/event-security"; import { AutoFillConstants } from "./autofill-constants"; import { AutofillOverlayContentService } from "./autofill-overlay-content.service"; @@ -55,6 +56,9 @@ describe("AutofillOverlayContentService", () => { const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); beforeEach(async () => { + // Mock EventSecurity to allow synthetic events in tests + jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true); + inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); domQueryService = new DomQueryService(); domElementVisibilityService = new DomElementVisibilityService(); @@ -331,6 +335,8 @@ describe("AutofillOverlayContentService", () => { pageDetailsMock, ); jest.spyOn(globalThis.customElements, "define").mockImplementation(); + // Mock EventSecurity to allow synthetic events in tests + jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true); }); it("closes the autofill inline menu when the `Escape` key is pressed", () => { diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 7ea89e114ab..eb02d05d671 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -45,6 +45,7 @@ import { sendExtensionMessage, throttle, } from "../utils"; +import { EventSecurity } from "../utils/event-security"; import { AutofillOverlayContentExtensionMessageHandlers, @@ -618,6 +619,10 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ */ private handleSubmitButtonInteraction = (event: PointerEvent) => { if ( + /** + * Reject synthetic events (not originating from the user agent) + */ + !EventSecurity.isEventTrusted(event) || !this.submitElements.has(event.target as HTMLElement) || (event.type === "keyup" && !["Enter", "Space"].includes((event as unknown as KeyboardEvent).code)) @@ -703,6 +708,13 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ * @param event - The keyup event. */ private handleFormFieldKeyupEvent = async (event: globalThis.KeyboardEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + const eventCode = event.code; if (eventCode === "Escape") { void this.sendExtensionMessage("closeAutofillInlineMenu", { 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 18eb8e2baf8..e275a2d3ee8 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"; @@ -52,6 +54,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ private ownedExperienceTagNames: string[] = []; private readonly updateAfterMutationTimeout = 1000; private readonly formFieldQueryString; + private readonly debouncedProcessMutations = debounce(() => this.processMutations(), 100); private readonly nonInputFormFieldTags = new Set(["textarea", "select"]); private readonly ignoredInputTypes = new Set([ "hidden", @@ -96,7 +99,9 @@ export class CollectAutofillContentService implements CollectAutofillContentServ */ async getPageDetails(): Promise { // Set up listeners on top-layer candidates that predate Mutation Observer setup - this.setupInitialTopLayerListeners(); + if (this.autofillOverlayContentService) { + this.setupInitialTopLayerListeners(); + } if (!this.mutationObserver) { this.setupMutationObserver(); @@ -240,10 +245,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), }); } @@ -258,7 +263,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; } /** @@ -333,7 +341,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; @@ -382,11 +393,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), }; @@ -402,16 +413,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), }; } @@ -419,21 +430,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); @@ -465,9 +476,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) ); } @@ -955,6 +966,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, }); @@ -974,7 +987,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ } if (!this.mutationsQueue.length) { - requestIdleCallbackPolyfill(debounce(this.processMutations, 100), { timeout: 500 }); + requestIdleCallbackPolyfill(this.debouncedProcessMutations, { timeout: 500 }); } this.mutationsQueue.push(mutations); }; @@ -1072,17 +1085,21 @@ export class CollectAutofillContentService implements CollectAutofillContentServ } private setupTopLayerCandidateListener = (element: Element) => { - const ownedTags = this.autofillOverlayContentService.getOwnedInlineMenuTagNames() || []; - this.ownedExperienceTagNames = ownedTags; + if (this.autofillOverlayContentService) { + const ownedTags = this.autofillOverlayContentService.getOwnedInlineMenuTagNames() || []; + this.ownedExperienceTagNames = ownedTags; - if (!ownedTags.includes(element.tagName)) { - element.addEventListener("toggle", (event: ToggleEvent) => { - if (event.newState === "open") { - // Add a slight delay (but faster than a user's reaction), to ensure the layer - // positioning happens after any triggered toggle has completed. - setTimeout(this.autofillOverlayContentService.refreshMenuLayerPosition, 100); - } - }); + if (!ownedTags.includes(element.tagName)) { + element.addEventListener("toggle", (event: ToggleEvent) => { + if (event.newState === "open") { + // Add a slight delay (but faster than a user's reaction), to ensure the layer + // positioning happens after any triggered toggle has completed. + setTimeout(this.autofillOverlayContentService.refreshMenuLayerPosition, 100); + } + }); + + this.autofillOverlayContentService.refreshMenuLayerPosition(); + } } }; @@ -1315,6 +1332,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ action: () => (dataTarget.htmlAction = this.getFormActionAttribute(element)), name: () => updateAttribute("htmlName"), id: () => updateAttribute("htmlID"), + class: () => updateAttribute("htmlClass"), method: () => updateAttribute("htmlMethod"), }; @@ -1344,29 +1362,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/autofill/utils/event-security.spec.ts b/apps/browser/src/autofill/utils/event-security.spec.ts new file mode 100644 index 00000000000..5cda484d4d2 --- /dev/null +++ b/apps/browser/src/autofill/utils/event-security.spec.ts @@ -0,0 +1,26 @@ +import { EventSecurity } from "./event-security"; + +describe("EventSecurity", () => { + describe("isEventTrusted", () => { + it("should call the event.isTrusted property", () => { + const testEvent = new KeyboardEvent("keyup", { code: "Escape" }); + const result = EventSecurity.isEventTrusted(testEvent); + + // In test environment, events are untrusted by default + expect(result).toBe(false); + expect(result).toBe(testEvent.isTrusted); + }); + + it("should be mockable with jest.spyOn", () => { + const testEvent = new KeyboardEvent("keyup", { code: "Escape" }); + const spy = jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true); + + const result = EventSecurity.isEventTrusted(testEvent); + + expect(result).toBe(true); + expect(spy).toHaveBeenCalledWith(testEvent); + + spy.mockRestore(); + }); + }); +}); diff --git a/apps/browser/src/autofill/utils/event-security.ts b/apps/browser/src/autofill/utils/event-security.ts new file mode 100644 index 00000000000..e53517058df --- /dev/null +++ b/apps/browser/src/autofill/utils/event-security.ts @@ -0,0 +1,13 @@ +/** + * Event security utilities for validating trusted events + */ +export class EventSecurity { + /** + * Validates that an event is trusted (originated from user agent) + * @param event - The event to validate + * @returns true if the event is trusted, false otherwise + */ + static isEventTrusted(event: Event): boolean { + return event.isTrusted; + } +} diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index dc07ca1e258..fa47ddd943b 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -368,20 +368,21 @@ export function getPropertyOrAttribute(element: HTMLElement, attributeName: stri /** * Throttles a callback function to run at most once every `limit` milliseconds. * - * @param callback - The callback function to throttle. + * @param callback - The callback function to throttle (must return void). * @param limit - The time in milliseconds to throttle the callback. */ -export function throttle unknown>( - callback: FunctionType, +export function throttle( + callback: (this: TypeContext, ...args: Args) => void, limit: number, -): (this: ThisParameterType, ...args: Parameters) => void { +): (this: TypeContext, ...args: Args) => void { let waitingDelay = false; - return function (this: ThisParameterType, ...args: Parameters) { - if (!waitingDelay) { - callback.apply(this, args); - waitingDelay = true; - globalThis.setTimeout(() => (waitingDelay = false), limit); + return function (this: TypeContext, ...args: Args) { + if (waitingDelay) { + return; } + callback.apply(this, args); + waitingDelay = true; + globalThis.setTimeout(() => (waitingDelay = false), limit); }; } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index b9b41943b04..585942d7537 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, @@ -661,6 +638,10 @@ export default class MainBackground { this.stateProvider, ); + this.accountCryptographicStateService = new DefaultAccountCryptographicStateService( + this.stateProvider, + ); + this.backgroundSyncService = new BackgroundSyncService(this.taskSchedulerService); this.backgroundSyncService.register(() => this.fullSync()); @@ -685,7 +666,9 @@ export default class MainBackground { logoutCallback, ); - this.securityStateService = new DefaultSecurityStateService(this.stateProvider); + this.securityStateService = new DefaultSecurityStateService( + this.accountCryptographicStateService, + ); this.popupViewCacheBackgroundService = new PopupViewCacheBackgroundService( messageListener, @@ -732,6 +715,7 @@ export default class MainBackground { this.accountService, this.stateProvider, this.kdfConfigService, + this.accountCryptographicStateService, ); const pinStateService = new PinStateService(this.stateProvider); @@ -847,10 +831,6 @@ export default class MainBackground { this.configService, ); - this.accountCryptographicStateService = new DefaultAccountCryptographicStateService( - this.stateProvider, - ); - this.keyConnectorService = new KeyConnectorService( this.accountService, this.masterPasswordService, @@ -973,6 +953,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 +970,7 @@ export default class MainBackground { this.logService, this.cipherEncryptionService, this.messagingService, + this.cipherSdkService, ); this.folderService = new FolderService( this.keyService, @@ -1025,6 +1008,7 @@ export default class MainBackground { this.keyGenerationService, this.sendStateProvider, this.encryptService, + this.configService, ); this.sendApiService = new SendApiService( this.apiService, @@ -1510,6 +1494,7 @@ export default class MainBackground { this.accountService, this.billingAccountProfileStateService, this.configService, + this.logService, this.organizationService, this.platformUtilsService, this.stateProvider, @@ -1564,7 +1549,6 @@ export default class MainBackground { await this.sdkLoadService.loadAndInit(); // Only the "true" background should run migrations await this.migrationRunner.run(); - this.encryptService.init(this.configService); // This is here instead of in the InitService b/c we don't plan for // side effects to run in the Browser InitService. 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..1c6421912ab 100644 --- a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts +++ b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts @@ -1,6 +1,6 @@ export type PhishingResource = { name?: string; - remoteUrl: string; + primaryUrl: string; checksumUrl: string; todayUrl: string; /** Matcher used to decide whether a given URL matches an entry from this resource */ @@ -18,7 +18,8 @@ export const PHISHING_RESOURCES: Record { let service: PhishingDataService; @@ -17,20 +19,31 @@ describe("PhishingDataService", () => { let taskSchedulerService: TaskSchedulerService; let logService: MockProxy; let platformUtilsService: MockProxy; - const stateProvider: FakeGlobalStateProvider = new FakeGlobalStateProvider(); - - const setMockState = (state: PhishingData) => { - stateProvider.getFake(PHISHING_DOMAINS_KEY).stateSubject.next(state); - return state; - }; - + let mockIndexedDbService: MockProxy; + const fakeGlobalStateProvider: FakeGlobalStateProvider = new FakeGlobalStateProvider(); let fetchChecksumSpy: jest.SpyInstance; - let fetchWebAddressesSpy: jest.SpyInstance; - beforeEach(() => { - jest.useFakeTimers(); + beforeEach(async () => { + 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"); @@ -40,119 +53,374 @@ describe("PhishingDataService", () => { service = new PhishingDataService( apiService, taskSchedulerService, - stateProvider, + fakeGlobalStateProvider, logService, platformUtilsService, ); + // Replace the IndexedDB service with our mock + service["indexedDbService"] = mockIndexedDbService; + fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingChecksum"); - fetchWebAddressesSpy = jest.spyOn(service as any, "fetchPhishingWebAddresses"); + fetchChecksumSpy.mockResolvedValue("new-checksum"); + }); + + describe("initialization", () => { + it("should initialize with IndexedDB service", () => { + expect(service["indexedDbService"]).toBeDefined(); + }); + + it("should detect QA test addresses - http protocol", async () => { + const url = new URL("http://phishing.testcategory.com"); + expect(await service.isPhishingWebAddress(url)).toBe(true); + // IndexedDB should not be called for test addresses + expect(mockIndexedDbService.hasUrl).not.toHaveBeenCalled(); + }); + + 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", () => { - it("should detect a phishing web address", async () => { - setMockState({ - webAddresses: ["phish.com", "badguy.net"], - timestamp: Date.now(), - checksum: "abc123", - applicationVersion: "1.0.0", - }); - const url = new URL("http://phish.com"); + 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); + + 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 () => { - setMockState({ - webAddresses: ["phish.com", "badguy.net"], - timestamp: Date.now(), - checksum: "abc123", - applicationVersion: "1.0.0", - }); + // 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 () => { - setMockState({ - webAddresses: ["phish.com", "badguy.net"], - timestamp: Date.now(), - checksum: "abc123", - applicationVersion: "1.0.0", - }); - 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 () => { - setMockState(undefined as any); + 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", () => { - it("refetches all web addresses if applicationVersion has changed", async () => { - const prev: PhishingData = { - webAddresses: ["a.com"], - timestamp: Date.now() - 60000, - checksum: "old", - applicationVersion: "1.0.0", - }; - fetchChecksumSpy.mockResolvedValue("new"); - fetchWebAddressesSpy.mockResolvedValue(["d.com", "e.com"]); + 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("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!.webAddresses).toEqual(["d.com", "e.com"]); - expect(result!.checksum).toBe("new"); - expect(result!.applicationVersion).toBe("2.0.0"); + expect(meta).toBeDefined(); + expect(meta.checksum).toBe("new-checksum"); + expect(meta.applicationVersion).toBe("2.0.0"); + expect(meta.timestamp).toBeDefined(); }); + }); - it("only updates timestamp if checksum matches", async () => { - const prev: PhishingData = { - webAddresses: ["a.com"], - timestamp: Date.now() - 60000, - checksum: "abc", + 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", }; - fetchChecksumSpy.mockResolvedValue("abc"); - const result = await service.getNextWebAddresses(prev); - expect(result!.webAddresses).toEqual(prev.webAddresses); - expect(result!.checksum).toBe("abc"); - expect(result!.timestamp).not.toBe(prev.timestamp); + await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta); + + // 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(); }); - it("patches daily domains if cache is fresh", async () => { - const prev: PhishingData = { - webAddresses: ["a.com"], - timestamp: Date.now() - 60000, - checksum: "old", + 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", }; - fetchChecksumSpy.mockResolvedValue("new"); - fetchWebAddressesSpy.mockResolvedValue(["b.com", "c.com"]); - const result = await service.getNextWebAddresses(prev); - expect(result!.webAddresses).toEqual(["a.com", "b.com", "c.com"]); - expect(result!.checksum).toBe("new"); + 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 }); - it("fetches all domains if cache is old", async () => { - const prev: PhishingData = { - webAddresses: ["a.com"], - timestamp: Date.now() - 2 * 24 * 60 * 60 * 1000, - checksum: "old", + 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", }; - fetchChecksumSpy.mockResolvedValue("new"); - fetchWebAddressesSpy.mockResolvedValue(["d.com", "e.com"]); - const result = await service.getNextWebAddresses(prev); - expect(result!.webAddresses).toEqual(["d.com", "e.com"]); - expect(result!.checksum).toBe("new"); + await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta); + + // 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); + + // 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(); + }); + + 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); + + // 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); + + // Trigger background update + const result = await firstValueFrom(service["_backgroundUpdate"](existingMeta)); + + // Verify metadata WAS updated + expect(result?.timestamp).toBeGreaterThan(existingMeta.timestamp); + expect(result?.checksum).toBe("same-checksum"); + + // 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 21fe74f1873..72415dbbdbe 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,15 +1,24 @@ import { catchError, + concatMap, + defer, EMPTY, + exhaustMap, first, - firstValueFrom, + forkJoin, + from, + iif, map, + Observable, + of, retry, share, + takeUntil, startWith, Subject, switchMap, tap, + throwError, timer, } from "rxjs"; @@ -22,11 +31,16 @@ import { GlobalStateProvider, KeyDefinition, PHISHING_DETECTION_DISK } from "@bi import { getPhishingResources, PhishingResourceType } from "../phishing-resources"; -export type PhishingData = { - webAddresses: string[]; - timestamp: number; - checksum: string; +import { PhishingIndexedDbService } from "./phishing-indexeddb.service"; +/** + * Metadata about the phishing data set + */ +export type PhishingDataMeta = { + /** The last known checksum of the phishing data set */ + checksum: string; + /** The last time the data set was updated */ + timestamp: number; /** * We store the application version to refetch the entire dataset on a new client release. * This counteracts daily appends updates not removing inactive or false positive web addresses. @@ -34,72 +48,72 @@ export type PhishingData = { applicationVersion: string; }; -export const PHISHING_DOMAINS_KEY = new KeyDefinition( +/** + * The phishing data blob is a string representation of the phishing web addresses + */ +export type PhishingDataBlob = string; +export type PhishingData = { meta: PhishingDataMeta; blob: PhishingDataBlob }; + +export const PHISHING_DOMAINS_META_KEY = new KeyDefinition( PHISHING_DETECTION_DISK, - "phishingDomains", + "phishingDomainsMeta", { - deserializer: (value: PhishingData) => - value ?? { webAddresses: [], timestamp: 0, checksum: "", applicationVersion: "" }, + deserializer: (value: PhishingDataMeta) => { + return { + checksum: value?.checksum ?? "", + timestamp: value?.timestamp ?? 0, + applicationVersion: value?.applicationVersion ?? "", + }; + }, + }, +); + +export const PHISHING_DOMAINS_BLOB_KEY = new KeyDefinition( + PHISHING_DETECTION_DISK, + "phishingDomainsBlob", + { + deserializer: (value: string) => value ?? "", }, ); /** Coordinates fetching, caching, and patching of known phishing web addresses */ export class PhishingDataService { + // 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(); - private _cachedState = this.globalStateProvider.get(PHISHING_DOMAINS_KEY); - private _webAddresses$ = this._cachedState.state$.pipe( - map( - (state) => - new Set( - (state?.webAddresses?.filter((line) => line.trim().length > 0) ?? []).concat( - this._testWebAddresses, - "phishing.testcategory.com", // Included for QA to test in prod - ), - ), - ), - ); + private _phishingMetaState = this.globalStateProvider.get(PHISHING_DOMAINS_META_KEY); + + 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 - tap(() => this.logService.info(`[PhishingDataService] Update triggered...`)), switchMap(() => - this._cachedState.state$.pipe( + this._phishingMetaState.state$.pipe( first(), // Only take the first value to avoid an infinite loop when updating the cache below - switchMap(async (cachedState) => { - const next = await this.getNextWebAddresses(cachedState); - if (next) { - await this._cachedState.update(() => next); - this.logService.info(`[PhishingDataService] cache updated`); - } + tap((metaState) => { + // Perform any updates in the background + this._backgroundUpdateTrigger$.next(metaState); }), - retry({ - count: 3, - delay: (err, count) => { - this.logService.error( - `[PhishingDataService] Unable to update web addresses. Attempt ${count}.`, - err, - ); - return timer(5 * 60 * 1000); // 5 minutes - }, - resetOnSuccess: true, + catchError((err: unknown) => { + this.logService.error("[PhishingDataService] Background update failed to start.", err); + return EMPTY; }), - catchError( - ( - err: unknown /** Eslint actually crashed if you remove this type: https://github.com/cartant/eslint-plugin-rxjs/issues/122 */, - ) => { - this.logService.error( - "[PhishingDataService] Retries unsuccessful. Unable to update web addresses.", - err, - ); - return EMPTY; - }, - ), ), ), + takeUntil(this._destroy$), share(), ); @@ -111,6 +125,8 @@ export class PhishingDataService { private platformUtilsService: PlatformUtilsService, 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(); }); @@ -118,6 +134,20 @@ export class PhishingDataService { ScheduledTaskNames.phishingDomainUpdate, this.UPDATE_INTERVAL_DURATION, ); + 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(); } /** @@ -127,79 +157,84 @@ export class PhishingDataService { * @returns True if the URL is a known phishing web address, false otherwise */ async isPhishingWebAddress(url: URL): Promise { - // Use domain (hostname) matching for domain resources, and link matching for links resources - const entries = await firstValueFrom(this._webAddresses$); - - const resource = getPhishingResources(this.resourceType); - if (resource && resource.match) { - for (const entry of entries) { - if (resource.match(url, entry)) { - return true; - } - } + // Skip non-http(s) protocols - phishing database only contains web URLs + if (url.protocol !== "http:" && url.protocol !== "https:") { return false; } - // Default/domain behavior: exact hostname match as a fallback - return entries.has(url.hostname); - } - - async getNextWebAddresses(prev: PhishingData | null): Promise { - prev = prev ?? { webAddresses: [], timestamp: 0, checksum: "", applicationVersion: "" }; - const timestamp = Date.now(); - const prevAge = timestamp - prev.timestamp; - this.logService.info(`[PhishingDataService] Cache age: ${prevAge}`); - - const applicationVersion = await this.platformUtilsService.getApplicationVersion(); - - // If checksum matches, return existing data with new timestamp & version - const remoteChecksum = await this.fetchPhishingChecksum(this.resourceType); - if (remoteChecksum && prev.checksum === remoteChecksum) { - this.logService.info( - `[PhishingDataService] Remote checksum matches local checksum, updating timestamp only.`, - ); - return { ...prev, timestamp, applicationVersion }; - } - // Checksum is different, data needs to be updated. - - // Approach 1: Fetch only new web addresses and append - const isOneDayOldMax = prevAge <= this.UPDATE_INTERVAL_DURATION; - if (isOneDayOldMax && applicationVersion === prev.applicationVersion) { - const webAddressesTodayUrl = getPhishingResources(this.resourceType)!.todayUrl; - const dailyWebAddresses: string[] = - await this.fetchPhishingWebAddresses(webAddressesTodayUrl); - this.logService.info( - `[PhishingDataService] ${dailyWebAddresses.length} new phishing web addresses added`, - ); - return { - webAddresses: prev.webAddresses.concat(dailyWebAddresses), - checksum: remoteChecksum, - timestamp, - applicationVersion, - }; + // 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; } - // Approach 2: Fetch all web addresses - const remoteUrl = getPhishingResources(this.resourceType)!.remoteUrl; - const remoteWebAddresses = await this.fetchPhishingWebAddresses(remoteUrl); - return { - webAddresses: remoteWebAddresses, - timestamp, - checksum: remoteChecksum, - applicationVersion, - }; + const resource = getPhishingResources(this.resourceType); + + 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); + } + + if (hasUrl) { + this.logService.info("[PhishingDataService] Found phishing URL: " + urlHref); + return true; + } + } catch (err) { + this.logService.error("[PhishingDataService] IndexedDB lookup failed", err); + } + + // 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}`); + this.logService.debug(`[PhishingDataService] Fetching checksum from: ${checksumUrl}`); + + 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 await response.text(); + } catch (error) { + this.logService.error( + `[PhishingDataService] Checksum fetch failed from ${checksumUrl}`, + error, + ); + throw error; } - return response.text(); } - private async fetchPhishingWebAddresses(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) { @@ -211,18 +246,169 @@ 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; + } + + private _getUpdatedMeta(): Observable { + return defer(() => { + const now = Date.now(); + + return forkJoin({ + applicationVersion: from(this.platformUtilsService.getApplicationVersion()), + remoteChecksum: from(this.fetchPhishingChecksum(this.resourceType)), + }).pipe( + map(({ applicationVersion, remoteChecksum }) => { + return { + checksum: remoteChecksum, + timestamp: now, + applicationVersion, + }; + }), + ); + }); + } + + // Streams the full phishing data set and saves it to IndexedDB + private _updateFullDataSet() { + const resource = getPhishingResources(this.resourceType); + + if (!resource?.primaryUrl) { + return throwError(() => new Error("Invalid resource URL")); + } + + this.logService.info(`[PhishingDataService] Starting FULL update using ${resource.primaryUrl}`); + return from(this.apiService.nativeFetch(new Request(resource.primaryUrl))).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)); + }), + ); + } + + 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}`, + ); + return appVersionChanged || checksumChanged; + }, + this._updateFullDataSet().pipe(map(() => ({ meta: newMeta, updated: true }))), + of({ meta: newMeta, updated: false }), + ), + ), + // 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`); + }), + ); + }), + 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 d90e872eef8..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); @@ -137,6 +129,9 @@ export class PhishingDetectionService { this._didInit = true; return () => { + // Dispose phishing data service resources + phishingDataService.dispose(); + initSub.unsubscribe(); this._didInit = false; diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts new file mode 100644 index 00000000000..98835a5b366 --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts @@ -0,0 +1,538 @@ +import { ReadableStream as NodeReadableStream } from "stream/web"; + +import { mock, MockProxy } from "jest-mock-extended"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +import { PhishingIndexedDbService } from "./phishing-indexeddb.service"; + +describe("PhishingIndexedDbService", () => { + let service: PhishingIndexedDbService; + let logService: MockProxy; + + // Mock IndexedDB storage (keyed by URL for row-per-URL storage) + let mockStore: Map; + let mockObjectStore: any; + let mockTransaction: any; + let mockDb: any; + let mockOpenRequest: any; + + beforeEach(() => { + logService = mock(); + mockStore = new Map(); + + // Mock IDBObjectStore + mockObjectStore = { + put: jest.fn().mockImplementation((record: { url: string }) => { + const request = { + error: null as DOMException | null, + result: undefined as undefined, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + setTimeout(() => { + mockStore.set(record.url, record); + request.onsuccess?.(); + }, 0); + return request; + }), + get: jest.fn().mockImplementation((key: string) => { + const request = { + error: null as DOMException | null, + result: mockStore.get(key), + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + setTimeout(() => { + request.result = mockStore.get(key); + request.onsuccess?.(); + }, 0); + return request; + }), + clear: jest.fn().mockImplementation(() => { + const request = { + error: null as DOMException | null, + result: undefined as undefined, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + setTimeout(() => { + mockStore.clear(); + request.onsuccess?.(); + }, 0); + return request; + }), + openCursor: jest.fn().mockImplementation(() => { + const entries = Array.from(mockStore.entries()); + let index = 0; + const request = { + error: null as DOMException | null, + result: null as any, + onsuccess: null as ((e: any) => void) | null, + onerror: null as (() => void) | null, + }; + const advanceCursor = () => { + if (index < entries.length) { + const [, value] = entries[index]; + index++; + request.result = { + value, + continue: () => setTimeout(advanceCursor, 0), + }; + } else { + request.result = null; + } + request.onsuccess?.({ target: request }); + }; + setTimeout(advanceCursor, 0); + return request; + }), + }; + + // Mock IDBTransaction + mockTransaction = { + objectStore: jest.fn().mockReturnValue(mockObjectStore), + oncomplete: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + + // Trigger oncomplete after a tick + const originalObjectStore = mockTransaction.objectStore; + mockTransaction.objectStore = jest.fn().mockImplementation((...args: any[]) => { + setTimeout(() => mockTransaction.oncomplete?.(), 0); + return originalObjectStore(...args); + }); + + // Mock IDBDatabase + mockDb = { + transaction: jest.fn().mockReturnValue(mockTransaction), + close: jest.fn(), + objectStoreNames: { + contains: jest.fn().mockReturnValue(true), + }, + createObjectStore: jest.fn(), + }; + + // Mock IDBOpenDBRequest + mockOpenRequest = { + error: null as DOMException | null, + result: mockDb, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + onupgradeneeded: null as ((event: any) => void) | null, + }; + + // Mock indexedDB.open + const mockIndexedDB = { + open: jest.fn().mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onsuccess?.(); + }, 0); + return mockOpenRequest; + }), + }; + + global.indexedDB = mockIndexedDB as any; + + service = new PhishingIndexedDbService(logService); + }); + + afterEach(() => { + jest.clearAllMocks(); + delete (global as any).indexedDB; + }); + + describe("saveUrls", () => { + it("stores URLs in IndexedDB and returns true", async () => { + const urls = ["https://phishing.com", "https://malware.net"]; + + const result = await service.saveUrls(urls); + + expect(result).toBe(true); + expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readwrite"); + expect(mockObjectStore.clear).toHaveBeenCalled(); + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + expect(mockDb.close).toHaveBeenCalled(); + }); + + it("handles empty array", async () => { + const result = await service.saveUrls([]); + + expect(result).toBe(true); + expect(mockObjectStore.clear).toHaveBeenCalled(); + }); + + it("trims whitespace from URLs", async () => { + const urls = [" https://example.com ", "\nhttps://test.org\n"]; + + await service.saveUrls(urls); + + expect(mockObjectStore.put).toHaveBeenCalledWith({ url: "https://example.com" }); + expect(mockObjectStore.put).toHaveBeenCalledWith({ url: "https://test.org" }); + }); + + it("skips empty lines", async () => { + const urls = ["https://example.com", "", " ", "https://test.org"]; + + await service.saveUrls(urls); + + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + }); + + it("handles duplicate URLs via upsert (keyPath deduplication)", async () => { + const urls = [ + "https://example.com", + "https://example.com", // duplicate + "https://test.org", + ]; + + const result = await service.saveUrls(urls); + + expect(result).toBe(true); + // put() is called 3 times, but mockStore (using Map with URL as key) + // only stores 2 unique entries - demonstrating upsert behavior + expect(mockObjectStore.put).toHaveBeenCalledTimes(3); + expect(mockStore.size).toBe(2); + }); + + it("logs error and returns false on failure", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const result = await service.saveUrls(["https://test.com"]); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Save failed", + expect.any(Error), + ); + }); + }); + + describe("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" }); + + const result = await service.hasUrl("https://example.com"); + + expect(result).toBe(true); + expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readonly"); + expect(mockObjectStore.get).toHaveBeenCalledWith("https://example.com"); + }); + + it("returns false for non-existing URL", async () => { + const result = await service.hasUrl("https://notfound.com"); + + expect(result).toBe(false); + }); + + it("returns false on error", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const result = await service.hasUrl("https://example.com"); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Check failed", + expect.any(Error), + ); + }); + }); + + describe("loadAllUrls", () => { + it("loads all URLs using cursor", async () => { + mockStore.set("https://example.com", { url: "https://example.com" }); + mockStore.set("https://test.org", { url: "https://test.org" }); + + const result = await service.loadAllUrls(); + + expect(result).toContain("https://example.com"); + expect(result).toContain("https://test.org"); + expect(result.length).toBe(2); + }); + + it("returns empty array when no data exists", async () => { + const result = await service.loadAllUrls(); + + expect(result).toEqual([]); + }); + + it("returns empty array on error", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const result = await service.loadAllUrls(); + + expect(result).toEqual([]); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Load failed", + expect.any(Error), + ); + }); + }); + + describe("saveUrlsFromStream", () => { + it("saves URLs from stream", async () => { + const content = "https://example.com\nhttps://test.org\nhttps://phishing.net"; + const stream = new NodeReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(content)); + controller.close(); + }, + }) as unknown as ReadableStream; + + const result = await service.saveUrlsFromStream(stream); + + expect(result).toBe(true); + expect(mockObjectStore.clear).toHaveBeenCalled(); + expect(mockObjectStore.put).toHaveBeenCalledTimes(3); + }); + + it("handles chunked stream data", async () => { + const content = "https://url1.com\nhttps://url2.com"; + const encoder = new TextEncoder(); + const encoded = encoder.encode(content); + + // Split into multiple small chunks + const stream = new NodeReadableStream({ + start(controller) { + controller.enqueue(encoded.slice(0, 5)); + controller.enqueue(encoded.slice(5, 10)); + controller.enqueue(encoded.slice(10)); + controller.close(); + }, + }) as unknown as ReadableStream; + + const result = await service.saveUrlsFromStream(stream); + + expect(result).toBe(true); + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + }); + + it("returns false on error", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const stream = new NodeReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("https://test.com")); + controller.close(); + }, + }) as unknown as ReadableStream; + + const result = await service.saveUrlsFromStream(stream); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Stream save failed", + expect.any(Error), + ); + }); + }); + + describe("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); + + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onupgradeneeded?.({ target: mockOpenRequest }); + mockOpenRequest.onsuccess?.(); + }, 0); + return mockOpenRequest; + }); + + await service.hasUrl("https://test.com"); + + expect(mockDb.createObjectStore).toHaveBeenCalledWith("phishing-urls", { keyPath: "url" }); + }); + }); +}); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts new file mode 100644 index 00000000000..ea4b7987607 --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts @@ -0,0 +1,330 @@ +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +/** + * Record type for phishing URL storage in IndexedDB. + */ +type PhishingUrlRecord = { url: string }; + +/** + * IndexedDB storage service for phishing URLs. + * Stores URLs as individual rows. + */ +export class PhishingIndexedDbService { + private readonly DB_NAME = "bitwarden-phishing"; + private readonly STORE_NAME = "phishing-urls"; + private readonly DB_VERSION = 1; + private readonly CHUNK_SIZE = 50000; + + constructor(private logService: LogService) {} + + /** + * Opens the IndexedDB database, creating the object store if needed. + */ + private openDatabase(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(this.DB_NAME, this.DB_VERSION); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result); + req.onupgradeneeded = (e) => { + const db = (e.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(this.STORE_NAME)) { + db.createObjectStore(this.STORE_NAME, { keyPath: "url" }); + } + }; + }); + } + + /** + * Clears all records from the phishing URLs store. + */ + private clearStore(db: IDBDatabase): Promise { + return new Promise((resolve, reject) => { + const req = db.transaction(this.STORE_NAME, "readwrite").objectStore(this.STORE_NAME).clear(); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(); + }); + } + + /** + * Saves an array of phishing URLs to IndexedDB. + * Atomically replaces all existing data. + * + * @param urls - Array of phishing URLs to save + * @returns `true` if save succeeded, `false` on error + */ + async saveUrls(urls: string[]): Promise { + this.logService.debug( + `[PhishingIndexedDbService] Clearing and saving ${urls.length} to the store...`, + ); + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + await this.clearStore(db); + await this.saveChunked(db, urls); + return true; + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Save failed", error); + return false; + } finally { + db?.close(); + } + } + + /** + * 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. + */ + private async saveChunked(db: IDBDatabase, urls: string[]): Promise { + const cleaned = urls.map((u) => u.trim()).filter(Boolean); + for (let i = 0; i < cleaned.length; i += this.CHUNK_SIZE) { + await this.saveChunk(db, cleaned.slice(i, i + this.CHUNK_SIZE)); + await new Promise((r) => setTimeout(r, 0)); // Yield to event loop + } + } + + /** + * Saves a single chunk of URLs in one transaction. + */ + private saveChunk(db: IDBDatabase, urls: string[]): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(this.STORE_NAME, "readwrite"); + const store = tx.objectStore(this.STORE_NAME); + for (const url of urls) { + store.put({ url } as PhishingUrlRecord); + } + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + /** + * Checks if a URL exists in the phishing database. + * + * @param url - The URL to check + * @returns `true` if URL exists, `false` if not found or on error + */ + async hasUrl(url: string): Promise { + this.logService.debug(`[PhishingIndexedDbService] Checking if store contains ${url}...`); + + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + return await this.checkUrlExists(db, url); + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Check failed", error); + return false; + } finally { + db?.close(); + } + } + + /** + * Performs the actual URL existence check using index lookup. + */ + private checkUrlExists(db: IDBDatabase, url: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(this.STORE_NAME, "readonly"); + const req = tx.objectStore(this.STORE_NAME).get(url); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result !== undefined); + }); + } + + /** + * Loads all phishing URLs from IndexedDB. + * + * @returns Array of all stored URLs, or empty array on error + */ + async loadAllUrls(): Promise { + this.logService.debug("[PhishingIndexedDbService] Loading all urls from store..."); + + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + return await this.getAllUrls(db); + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Load failed", error); + return []; + } finally { + db?.close(); + } + } + + /** + * Iterates all records using a cursor. + */ + private getAllUrls(db: IDBDatabase): Promise { + return new Promise((resolve, reject) => { + const urls: string[] = []; + const req = db + .transaction(this.STORE_NAME, "readonly") + .objectStore(this.STORE_NAME) + .openCursor(); + req.onerror = () => reject(req.error); + req.onsuccess = (e) => { + const cursor = (e.target as IDBRequest).result; + if (cursor) { + urls.push((cursor.value as PhishingUrlRecord).url); + cursor.continue(); + } else { + resolve(urls); + } + }; + }); + } + + /** + * 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. + * + * @param stream - ReadableStream of newline-delimited URLs + * @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); + return false; + } finally { + db?.close(); + } + } + + /** + * Processes a stream of URL data, parsing lines and saving in chunks. + */ + private async processStream(db: IDBDatabase, stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let urls: string[] = []; + + try { + while (true) { + const { done, value } = await reader.read(); + + // Decode BEFORE done check; stream: !done flushes on final call + buffer += decoder.decode(value, { stream: !done }); + + if (done) { + // Split remaining buffer by newlines in case it contains multiple URLs + const lines = buffer.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed) { + urls.push(trimmed); + } + } + if (urls.length > 0) { + await this.saveChunk(db, urls); + } + break; + } + + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed) { + urls.push(trimmed); + } + + if (urls.length >= this.CHUNK_SIZE) { + await this.saveChunk(db, urls); + urls = []; + await new Promise((r) => setTimeout(r, 0)); + } + } + } + } finally { + reader.releaseLock(); + } + } +} diff --git a/apps/browser/src/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/badge/badge.service.ts b/apps/browser/src/platform/badge/badge.service.ts index f6d799b2a80..0ecb8210dd0 100644 --- a/apps/browser/src/platform/badge/badge.service.ts +++ b/apps/browser/src/platform/badge/badge.service.ts @@ -1,5 +1,6 @@ import { BehaviorSubject, + catchError, combineLatest, combineLatestWith, concatMap, @@ -73,9 +74,25 @@ export class BadgeService { map((evt) => evt.tab), combineLatestWith(this.stateFunctions), switchMap(([tab, dynamicStateFunctions]) => { - const functions = [...Object.values(dynamicStateFunctions), defaultTabStateFunction]; + const functions = [ + ...Object.entries(dynamicStateFunctions), + ["default" as string, defaultTabStateFunction] as const, + ]; - return combineLatest(functions.map((f) => f(tab).pipe(startWith(undefined)))).pipe( + return combineLatest( + functions.map(([name, f]) => + f(tab).pipe( + startWith(undefined), + catchError((error: unknown) => { + this.logService.error( + `BadgeService: State function "${name}" threw an error`, + error, + ); + return of(undefined); + }), + ), + ), + ).pipe( map((states) => ({ tab, states: states.filter((s): s is BadgeStateSetting => s !== undefined), 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 6e2175e3a79..89459523843 100644 --- a/apps/browser/src/platform/browser/browser-popup-utils.spec.ts +++ b/apps/browser/src/platform/browser/browser-popup-utils.spec.ts @@ -1,7 +1,7 @@ import { createChromeTabMock } from "../../autofill/spec/autofill-mocks"; import { BrowserApi } from "./browser-api"; -import BrowserPopupUtils from "./browser-popup-utils"; +import BrowserPopupUtils, { PopupWidthOptions } from "./browser-popup-utils"; describe("BrowserPopupUtils", () => { afterEach(() => { @@ -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, @@ -152,11 +147,9 @@ describe("BrowserPopupUtils", () => { focused: false, alwaysOnTop: false, incognito: false, - width: 380, + 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 () => { @@ -168,7 +161,7 @@ describe("BrowserPopupUtils", () => { expect(BrowserApi.createWindow).toHaveBeenCalledWith({ type: "popup", focused: true, - width: 380, + width: PopupWidthOptions.default, height: 630, left: 85, top: 190, @@ -197,7 +190,7 @@ describe("BrowserPopupUtils", () => { expect(BrowserApi.createWindow).toHaveBeenCalledWith({ type: "popup", focused: true, - width: 380, + width: PopupWidthOptions.default, height: 630, left: 85, top: 190, @@ -214,7 +207,7 @@ describe("BrowserPopupUtils", () => { expect(BrowserApi.createWindow).toHaveBeenCalledWith({ type: "popup", focused: true, - width: 380, + width: PopupWidthOptions.default, height: 630, left: 85, top: 190, @@ -267,70 +260,13 @@ describe("BrowserPopupUtils", () => { expect(BrowserApi.createWindow).toHaveBeenCalledWith({ type: "popup", focused: true, - width: 380, + width: PopupWidthOptions.default, height: 630, left: 85, top: 190, 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: 380, - 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: 380, - 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 8343799d0eb..7333023d178 100644 --- a/apps/browser/src/platform/browser/browser-popup-utils.ts +++ b/apps/browser/src/platform/browser/browser-popup-utils.ts @@ -10,9 +10,9 @@ import { BrowserApi } from "./browser-api"; * Value represents width in pixels */ export const PopupWidthOptions = Object.freeze({ - default: 380, - wide: 480, - "extra-wide": 600, + default: 480, + wide: 600, + narrow: 380, }); type PopupWidthOptions = typeof PopupWidthOptions; @@ -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-layout.stories.ts b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts index c6ffe1a6414..2e088b8161e 100644 --- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts +++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts @@ -44,7 +44,7 @@ import { PopupTabNavigationComponent } from "./popup-tab-navigation.component"; @Component({ selector: "extension-container", template: ` -
+
`, @@ -678,7 +678,7 @@ export const WidthOptions: Story = { template: /* HTML */ `
Default:
-
+
Wide:
diff --git a/apps/browser/src/platform/popup/layout/popup-page.component.html b/apps/browser/src/platform/popup/layout/popup-page.component.html index 828d9947373..bb24fb800aa 100644 --- a/apps/browser/src/platform/popup/layout/popup-page.component.html +++ b/apps/browser/src/platform/popup/layout/popup-page.component.html @@ -25,7 +25,6 @@
(false); @@ -33,10 +39,21 @@ export class PopupPageComponent { protected readonly scrolled = signal(false); isScrolled = this.scrolled.asReadonly(); + constructor() { + this.scrollLayout.scrollableRef$ + .pipe( + filter((ref): ref is ElementRef => ref != null), + switchMap((ref) => + fromEvent(ref.nativeElement, "scroll").pipe( + startWith(null), + map(() => ref.nativeElement.scrollTop !== 0), + ), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((isScrolled) => this.scrolled.set(isScrolled)); + } + /** Accessible loading label for the spinner. Defaults to "loading" */ readonly loadingText = input(this.i18nService.t("loading")); - - handleScroll(event: Event) { - this.scrolled.set((event.currentTarget as HTMLElement).scrollTop !== 0); - } } diff --git a/apps/browser/src/platform/popup/layout/popup-size.service.ts b/apps/browser/src/platform/popup/layout/popup-size.service.ts index 0e4aacb9a97..4c0c901270e 100644 --- a/apps/browser/src/platform/popup/layout/popup-size.service.ts +++ b/apps/browser/src/platform/popup/layout/popup-size.service.ts @@ -37,7 +37,7 @@ export class PopupSizeService { /** Begin listening for state changes */ async init() { this.width$.subscribe((width: PopupWidthOption) => { - PopupSizeService.setStyle(width); + void PopupSizeService.setStyle(width); localStorage.setItem(PopupSizeService.LocalStorageKey, width); }); } @@ -77,13 +77,14 @@ export class PopupSizeService { } } - private static setStyle(width: PopupWidthOption) { - if (!BrowserPopupUtils.inPopup(window)) { + private static async setStyle(width: PopupWidthOption) { + const isInTab = await BrowserPopupUtils.isInTab(); + if (!BrowserPopupUtils.inPopup(window) || isInTab) { return; } const pxWidth = PopupWidthOptions[width] ?? PopupWidthOptions.default; - document.body.style.minWidth = `${pxWidth}px`; + document.body.style.width = `${pxWidth}px`; } /** @@ -91,6 +92,6 @@ export class PopupSizeService { **/ static initBodyWidthFromLocalStorage() { const storedValue = localStorage.getItem(PopupSizeService.LocalStorageKey); - this.setStyle(storedValue as any); + void this.setStyle(storedValue as any); } } 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..919d01f2d51 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,17 +18,17 @@ 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..f873d25641b 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, BerryComponent } 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, BerryComponent], 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/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 1f1d4d25b40..4e14d1171fd 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -69,7 +69,7 @@ import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router import { RouteCacheOptions } from "../platform/services/popup-view-cache-background.service"; import { CredentialGeneratorHistoryComponent } from "../tools/popup/generator/credential-generator-history.component"; import { CredentialGeneratorComponent } from "../tools/popup/generator/credential-generator.component"; -import { firefoxPopoutGuard } from "../tools/popup/guards/firefox-popout.guard"; +import { filePickerPopoutGuard } from "../tools/popup/guards/file-picker-popout.guard"; import { SendAddEditComponent as SendAddEditV2Component } from "../tools/popup/send-v2/add-edit/send-add-edit.component"; import { SendCreatedComponent } from "../tools/popup/send-v2/send-created/send-created.component"; import { SendV2Component } from "../tools/popup/send-v2/send-v2.component"; @@ -78,13 +78,13 @@ import { ExportBrowserV2Component } from "../tools/popup/settings/export/export- import { ImportBrowserV2Component } from "../tools/popup/settings/import/import-browser-v2.component"; import { SettingsV2Component } from "../tools/popup/settings/settings-v2.component"; import { AtRiskPasswordsComponent } from "../vault/popup/components/at-risk-passwords/at-risk-passwords.component"; -import { AddEditV2Component } from "../vault/popup/components/vault-v2/add-edit/add-edit-v2.component"; -import { AssignCollections } from "../vault/popup/components/vault-v2/assign-collections/assign-collections.component"; -import { AttachmentsV2Component } from "../vault/popup/components/vault-v2/attachments/attachments-v2.component"; -import { IntroCarouselComponent } from "../vault/popup/components/vault-v2/intro-carousel/intro-carousel.component"; -import { PasswordHistoryV2Component } from "../vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component"; -import { VaultV2Component } from "../vault/popup/components/vault-v2/vault-v2.component"; -import { ViewV2Component } from "../vault/popup/components/vault-v2/view-v2/view-v2.component"; +import { AddEditComponent } from "../vault/popup/components/vault/add-edit/add-edit.component"; +import { AssignCollections } from "../vault/popup/components/vault/assign-collections/assign-collections.component"; +import { AttachmentsComponent } from "../vault/popup/components/vault/attachments/attachments.component"; +import { IntroCarouselComponent } from "../vault/popup/components/vault/intro-carousel/intro-carousel.component"; +import { PasswordHistoryComponent } from "../vault/popup/components/vault/vault-password-history/vault-password-history.component"; +import { VaultComponent } from "../vault/popup/components/vault/vault.component"; +import { ViewComponent } from "../vault/popup/components/vault/view/view.component"; import { atRiskPasswordAuthGuard, canAccessAtRiskPasswords, @@ -93,13 +93,13 @@ import { import { clearVaultStateGuard } from "../vault/popup/guards/clear-vault-state.guard"; import { IntroCarouselGuard } from "../vault/popup/guards/intro-carousel.guard"; import { AdminSettingsComponent } from "../vault/popup/settings/admin-settings.component"; -import { AppearanceV2Component } from "../vault/popup/settings/appearance-v2.component"; +import { AppearanceComponent } from "../vault/popup/settings/appearance.component"; import { ArchiveComponent } from "../vault/popup/settings/archive.component"; import { DownloadBitwardenComponent } from "../vault/popup/settings/download-bitwarden.component"; -import { FoldersV2Component } from "../vault/popup/settings/folders-v2.component"; -import { MoreFromBitwardenPageV2Component } from "../vault/popup/settings/more-from-bitwarden-page-v2.component"; +import { FoldersComponent } from "../vault/popup/settings/folders.component"; +import { MoreFromBitwardenPageComponent } from "../vault/popup/settings/more-from-bitwarden-page.component"; import { TrashComponent } from "../vault/popup/settings/trash.component"; -import { VaultSettingsV2Component } from "../vault/popup/settings/vault-settings-v2.component"; +import { VaultSettingsComponent } from "../vault/popup/settings/vault-settings.component"; import { RouteElevation } from "./app-routing.animations"; import { @@ -214,7 +214,7 @@ const routes: Routes = [ }, { path: "view-cipher", - component: ViewV2Component, + component: ViewComponent, canActivate: [authGuard], data: { // Above "trash" @@ -223,20 +223,20 @@ const routes: Routes = [ }, { path: "cipher-password-history", - component: PasswordHistoryV2Component, + component: PasswordHistoryComponent, canActivate: [authGuard], data: { elevation: 4 } satisfies RouteDataProperties, }, { path: "add-cipher", - component: AddEditV2Component, + component: AddEditComponent, canActivate: [authGuard, debounceNavigationGuard()], data: { elevation: 1, resetRouterCacheOnTabChange: true } satisfies RouteDataProperties, runGuardsAndResolvers: "always", }, { path: "edit-cipher", - component: AddEditV2Component, + component: AddEditComponent, canActivate: [authGuard, debounceNavigationGuard()], data: { // Above "trash" @@ -247,8 +247,8 @@ const routes: Routes = [ }, { path: "attachments", - component: AttachmentsV2Component, - canActivate: [authGuard], + component: AttachmentsComponent, + canActivate: [authGuard, filePickerPopoutGuard()], data: { elevation: 4 } satisfies RouteDataProperties, }, { @@ -266,7 +266,7 @@ const routes: Routes = [ { path: "import", component: ImportBrowserV2Component, - canActivate: [authGuard, firefoxPopoutGuard()], + canActivate: [authGuard, filePickerPopoutGuard()], data: { elevation: 1 } satisfies RouteDataProperties, }, { @@ -301,13 +301,13 @@ const routes: Routes = [ }, { path: "vault-settings", - component: VaultSettingsV2Component, + component: VaultSettingsComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "folders", - component: FoldersV2Component, + component: FoldersComponent, canActivate: [authGuard], data: { elevation: 2 } satisfies RouteDataProperties, }, @@ -331,7 +331,7 @@ const routes: Routes = [ }, { path: "appearance", - component: AppearanceV2Component, + component: AppearanceComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, @@ -343,20 +343,20 @@ const routes: Routes = [ }, { path: "clone-cipher", - component: AddEditV2Component, + component: AddEditComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "add-send", component: SendAddEditV2Component, - canActivate: [authGuard, firefoxPopoutGuard()], + canActivate: [authGuard, filePickerPopoutGuard()], data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "edit-send", component: SendAddEditV2Component, - canActivate: [authGuard, firefoxPopoutGuard()], + canActivate: [authGuard, filePickerPopoutGuard()], data: { elevation: 1 } satisfies RouteDataProperties, }, { @@ -635,7 +635,7 @@ const routes: Routes = [ }, { path: "more-from-bitwarden", - component: MoreFromBitwardenPageV2Component, + component: MoreFromBitwardenPageComponent, canActivate: [authGuard], data: { elevation: 2 } satisfies RouteDataProperties, }, @@ -696,7 +696,7 @@ const routes: Routes = [ }, { path: "vault", - component: VaultV2Component, + component: VaultComponent, canActivate: [authGuard], canDeactivate: [clearVaultStateGuard], data: { elevation: 0 } satisfies RouteDataProperties, diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index d178cee2fc3..4ed79dd144d 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -33,7 +33,6 @@ import { PopupFooterComponent } from "../platform/popup/layout/popup-footer.comp import { PopupHeaderComponent } from "../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../platform/popup/layout/popup-page.component"; import { PopupTabNavigationComponent } from "../platform/popup/layout/popup-tab-navigation.component"; -import { FilePopoutCalloutComponent } from "../tools/popup/components/file-popout-callout.component"; import { AppRoutingModule } from "./app-routing.module"; import { AppComponent } from "./app.component"; @@ -67,7 +66,6 @@ import "../platform/popup/locales"; ScrollingModule, ServicesModule, DialogModule, - FilePopoutCalloutComponent, AvatarModule, AccountComponent, ButtonModule, 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/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts index 8fdae06e28a..66c9f655b05 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts @@ -10,6 +10,7 @@ import { import { of } from "rxjs"; import { LockIcon, RegistrationCheckEmailIcon } from "@bitwarden/assets/svg"; +import { PopupWidthOptions } from "@bitwarden/browser/platform/browser/browser-popup-utils"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; @@ -243,7 +244,12 @@ export const DefaultContentExample: Story = { }), parameters: { chromatic: { - viewports: [380, 1280], + viewports: [ + PopupWidthOptions.default, + PopupWidthOptions.narrow, + PopupWidthOptions.wide, + 1280, + ], }, }, }; diff --git a/apps/browser/src/popup/scss/tailwind.css b/apps/browser/src/popup/scss/tailwind.css index f58950cc86a..0ef7b82bfed 100644 --- a/apps/browser/src/popup/scss/tailwind.css +++ b/apps/browser/src/popup/scss/tailwind.css @@ -60,7 +60,7 @@ } body { - width: 380px; + width: 480px; height: 100%; position: relative; min-height: inherit; @@ -84,9 +84,9 @@ animation: redraw 1s linear infinite; } - /** + /** * Text selection style: - * suppress user selection for most elements (to make it more app-like) + * suppress user selection for most elements (to make it more app-like) */ h1, h2, @@ -165,7 +165,7 @@ @apply tw-text-muted; } - /** + /** * Text selection style: * Set explicit selection styles (assumes primary accent color has sufficient * contrast against the background, so its inversion is also still readable) diff --git a/apps/browser/src/popup/services/init.service.ts b/apps/browser/src/popup/services/init.service.ts index f16d82d0810..24ff637c29b 100644 --- a/apps/browser/src/popup/services/init.service.ts +++ b/apps/browser/src/popup/services/init.service.ts @@ -2,8 +2,6 @@ import { inject, Inject, Injectable, DOCUMENT } from "@angular/core"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -30,8 +28,6 @@ export class InitService { private sdkLoadService: SdkLoadService, private viewCacheService: PopupViewCacheService, private readonly migrationRunner: MigrationRunner, - private configService: ConfigService, - private encryptService: EncryptService, @Inject(DOCUMENT) private document: Document, ) {} @@ -43,7 +39,6 @@ export class InitService { this.twoFactorService.init(); await this.viewCacheService.init(); await this.sizeService.init(); - this.encryptService.init(this.configService); const htmlEl = window.document.documentElement; this.themingService.applyThemeChangesTo(this.document); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index c462e798a42..7988bec29b9 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -27,8 +27,12 @@ import { WINDOW, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; -import { AUTOFILL_NUDGE_SERVICE } from "@bitwarden/angular/vault"; -import { SingleNudgeService } from "@bitwarden/angular/vault/services/default-single-nudge.service"; +import { + AUTOFILL_NUDGE_SERVICE, + AUTO_CONFIRM_NUDGE_SERVICE, + AutoConfirmNudgeService, +} from "@bitwarden/angular/vault"; +import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; import { LoginComponentService, TwoFactorAuthComponentService, @@ -50,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 { @@ -67,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, @@ -84,6 +90,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction"; import { PhishingDetectionSettingsService } from "@bitwarden/common/dirt/services/phishing-detection/phishing-detection-settings.service"; import { ClientType } from "@bitwarden/common/enums"; +import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; @@ -92,6 +99,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, @@ -156,12 +164,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"; @@ -220,7 +231,6 @@ import { isNotificationsSupported, } from "../../platform/system-notifications/browser-system-notification.service"; import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging"; -import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service"; import { BrowserAutofillNudgeService } from "../../vault/popup/services/browser-autofill-nudge.service"; import { Fido2UserVerificationService } from "../../vault/services/fido2-user-verification.service"; import { ExtensionAnonLayoutWrapperDataService } from "../components/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; @@ -305,6 +315,7 @@ const safeProviders: SafeProvider[] = [ accountService: AccountServiceAbstraction, stateProvider: StateProvider, kdfConfigService: KdfConfigService, + accountCryptographicStateService: AccountCryptographicStateService, ) => { const keyService = new DefaultKeyService( masterPasswordService, @@ -317,6 +328,7 @@ const safeProviders: SafeProvider[] = [ accountService, stateProvider, kdfConfigService, + accountCryptographicStateService, ); new ContainerService(keyService, encryptService).attachToGlobal(self); return keyService; @@ -332,6 +344,7 @@ const safeProviders: SafeProvider[] = [ AccountServiceAbstraction, StateProvider, KdfConfigService, + AccountCryptographicStateService, ], }), safeProvider({ @@ -492,13 +505,6 @@ const safeProviders: SafeProvider[] = [ }, deps: [PlatformUtilsService], }), - safeProvider({ - provide: FilePopoutUtilsService, - useFactory: (platformUtilsService: PlatformUtilsService) => { - return new FilePopoutUtilsService(platformUtilsService); - }, - deps: [PlatformUtilsService], - }), safeProvider({ provide: DerivedStateProvider, useClass: InlineDerivedStateProvider, @@ -537,6 +543,7 @@ const safeProviders: SafeProvider[] = [ AccountService, BillingAccountProfileStateService, ConfigService, + LogService, OrganizationService, PlatformUtilsService, StateProvider, @@ -567,15 +574,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) => @@ -599,7 +597,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({ @@ -648,6 +653,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, @@ -785,9 +805,14 @@ const safeProviders: SafeProvider[] = [ ], }), safeProvider({ - provide: AUTOFILL_NUDGE_SERVICE as SafeInjectionToken, + provide: AUTOFILL_NUDGE_SERVICE as SafeInjectionToken, useClass: BrowserAutofillNudgeService, - deps: [], + deps: [StateProvider, VaultProfileService, LogService], + }), + safeProvider({ + provide: AUTO_CONFIRM_NUDGE_SERVICE as SafeInjectionToken, + useClass: AutoConfirmNudgeService, + deps: [StateProvider, AutomaticUserConfirmationService], }), ]; diff --git a/apps/browser/src/tools/popup/components/file-popout-callout.component.html b/apps/browser/src/tools/popup/components/file-popout-callout.component.html deleted file mode 100644 index 0df77dc4367..00000000000 --- a/apps/browser/src/tools/popup/components/file-popout-callout.component.html +++ /dev/null @@ -1,11 +0,0 @@ - -
{{ "sendLinuxChromiumFileWarning" | i18n }}
-
{{ "sendFirefoxFileWarning" | i18n }}
-
{{ "sendSafariFileWarning" | i18n }}
-
diff --git a/apps/browser/src/tools/popup/components/file-popout-callout.component.ts b/apps/browser/src/tools/popup/components/file-popout-callout.component.ts deleted file mode 100644 index 33044b79351..00000000000 --- a/apps/browser/src/tools/popup/components/file-popout-callout.component.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; - -import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { CalloutModule } from "@bitwarden/components"; - -import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; -import { FilePopoutUtilsService } from "../services/file-popout-utils.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: "tools-file-popout-callout", - templateUrl: "file-popout-callout.component.html", - imports: [CommonModule, JslibModule, CalloutModule], -}) -export class FilePopoutCalloutComponent implements OnInit { - protected showFilePopoutMessage: boolean = false; - protected showFirefoxFileWarning: boolean = false; - protected showSafariFileWarning: boolean = false; - protected showChromiumFileWarning: boolean = false; - - constructor(private filePopoutUtilsService: FilePopoutUtilsService) {} - - ngOnInit() { - this.showFilePopoutMessage = this.filePopoutUtilsService.showFilePopoutMessage(window); - this.showFirefoxFileWarning = this.filePopoutUtilsService.showFirefoxFileWarning(window); - this.showSafariFileWarning = this.filePopoutUtilsService.showSafariFileWarning(window); - this.showChromiumFileWarning = this.filePopoutUtilsService.showChromiumFileWarning(window); - } - - popOutWindow() { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - BrowserPopupUtils.openCurrentPagePopout(window); - } -} diff --git a/apps/browser/src/tools/popup/guards/file-picker-popout.guard.spec.ts b/apps/browser/src/tools/popup/guards/file-picker-popout.guard.spec.ts new file mode 100644 index 00000000000..2f100ab67f2 --- /dev/null +++ b/apps/browser/src/tools/popup/guards/file-picker-popout.guard.spec.ts @@ -0,0 +1,834 @@ +import { TestBed } from "@angular/core/testing"; +import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; + +import { DeviceType } from "@bitwarden/common/enums"; + +import { BrowserApi } from "../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; +import { BrowserPlatformUtilsService } from "../../../platform/services/platform-utils/browser-platform-utils.service"; + +import { filePickerPopoutGuard } from "./file-picker-popout.guard"; + +describe("filePickerPopoutGuard", () => { + let getDeviceSpy: jest.SpyInstance; + let inPopoutSpy: jest.SpyInstance; + let inSidebarSpy: jest.SpyInstance; + let openPopoutSpy: jest.SpyInstance; + let closePopupSpy: jest.SpyInstance; + + const mockRoute = {} as ActivatedRouteSnapshot; + const mockState: RouterStateSnapshot = { + url: "/add-send?type=1", + } as RouterStateSnapshot; + + beforeEach(() => { + getDeviceSpy = jest.spyOn(BrowserPlatformUtilsService, "getDevice"); + inPopoutSpy = jest.spyOn(BrowserPopupUtils, "inPopout"); + inSidebarSpy = jest.spyOn(BrowserPopupUtils, "inSidebar"); + openPopoutSpy = jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation(); + closePopupSpy = jest.spyOn(BrowserApi, "closePopup").mockImplementation(); + + TestBed.configureTestingModule({}); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("Firefox browser", () => { + beforeEach(() => { + getDeviceSpy.mockReturnValue(DeviceType.FirefoxExtension); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + }); + + it("should open popout and block navigation when not in popout or sidebar", async () => { + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(getDeviceSpy).toHaveBeenCalledWith(window); + expect(inPopoutSpy).toHaveBeenCalledWith(window); + expect(inSidebarSpy).toHaveBeenCalledWith(window); + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + }); + + it("should allow navigation when already in popout", async () => { + inPopoutSpy.mockReturnValue(true); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("should allow navigation when already in sidebar", async () => { + inSidebarSpy.mockReturnValue(true); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); + + describe("Safari browser", () => { + beforeEach(() => { + getDeviceSpy.mockReturnValue(DeviceType.SafariExtension); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + }); + + it("should open popout and block navigation when not in popout", async () => { + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(getDeviceSpy).toHaveBeenCalledWith(window); + expect(inPopoutSpy).toHaveBeenCalledWith(window); + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + }); + + it("should allow navigation when already in popout", async () => { + inPopoutSpy.mockReturnValue(true); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("should not allow sidebar bypass (Safari doesn't support sidebar)", async () => { + inSidebarSpy.mockReturnValue(true); + inPopoutSpy.mockReturnValue(false); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + // Safari requires popout, sidebar is not sufficient + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + }); + }); + + describe("Chromium browsers on Linux", () => { + beforeEach(() => { + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + Object.defineProperty(window, "navigator", { + value: { + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + appVersion: "5.0 (X11; Linux x86_64)", + }, + configurable: true, + writable: true, + }); + }); + + it.each([ + { deviceType: DeviceType.ChromeExtension, name: "Chrome" }, + { deviceType: DeviceType.EdgeExtension, name: "Edge" }, + { deviceType: DeviceType.OperaExtension, name: "Opera" }, + { deviceType: DeviceType.VivaldiExtension, name: "Vivaldi" }, + ])( + "should open popout and block navigation for $name on Linux when not in popout or sidebar", + async ({ deviceType }) => { + getDeviceSpy.mockReturnValue(deviceType); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + }, + ); + + it("should allow navigation when in popout", async () => { + getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension); + inPopoutSpy.mockReturnValue(true); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("should allow navigation when in sidebar", async () => { + getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension); + inSidebarSpy.mockReturnValue(true); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); + + describe("Chromium browsers on Mac", () => { + beforeEach(() => { + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + Object.defineProperty(window, "navigator", { + value: { + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + appVersion: "5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + configurable: true, + writable: true, + }); + }); + + it.each([ + { deviceType: DeviceType.ChromeExtension, name: "Chrome" }, + { deviceType: DeviceType.EdgeExtension, name: "Edge" }, + { deviceType: DeviceType.OperaExtension, name: "Opera" }, + { deviceType: DeviceType.VivaldiExtension, name: "Vivaldi" }, + ])( + "should open popout and block navigation for $name on Mac when not in popout or sidebar", + async ({ deviceType }) => { + getDeviceSpy.mockReturnValue(deviceType); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + }, + ); + + it("should allow navigation when in popout", async () => { + getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension); + inPopoutSpy.mockReturnValue(true); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("should allow navigation when in sidebar", async () => { + getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension); + inSidebarSpy.mockReturnValue(true); + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); + + describe("Chromium browsers on Windows", () => { + beforeEach(() => { + getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + Object.defineProperty(window, "navigator", { + value: { + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + appVersion: "5.0 (Windows NT 10.0; Win64; x64)", + }, + configurable: true, + writable: true, + }); + }); + + it("should allow navigation without popout on Windows", async () => { + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(getDeviceSpy).toHaveBeenCalledWith(window); + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); + + describe("File picker routes", () => { + beforeEach(() => { + getDeviceSpy.mockReturnValue(DeviceType.FirefoxExtension); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + }); + + it.each([ + { route: "/import" }, + { route: "/add-send" }, + { route: "/edit-send" }, + { route: "/attachments" }, + ])("should open popout for $route route", async ({ route }) => { + const importState: RouterStateSnapshot = { + url: route, + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, importState)); + + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#" + route); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + }); + }); + + describe("Url handling", () => { + beforeEach(() => { + getDeviceSpy.mockReturnValue(DeviceType.FirefoxExtension); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + }); + + it("should preserve query parameters in the popout url", async () => { + const stateWithQuery: RouterStateSnapshot = { + url: "/import?foo=bar&baz=qux", + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithQuery)); + + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/import?foo=bar&baz=qux"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + }); + + it("should handle urls without query parameters", async () => { + const stateWithoutQuery: RouterStateSnapshot = { + url: "/simple-path", + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithoutQuery)); + + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/simple-path"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + }); + + it("should not add autoClosePopout parameter to the url", async () => { + const guard = filePickerPopoutGuard(); + await TestBed.runInInjectionContext(() => guard(mockRoute, mockState)); + + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1"); + expect(openPopoutSpy).not.toHaveBeenCalledWith(expect.stringContaining("autoClosePopout")); + }); + }); + + describe("Send type differentiation", () => { + describe("Text Sends (type=0)", () => { + it.each([ + { deviceType: DeviceType.FirefoxExtension, name: "Firefox" }, + { deviceType: DeviceType.SafariExtension, name: "Safari" }, + { deviceType: DeviceType.ChromeExtension, name: "Chrome" }, + { deviceType: DeviceType.EdgeExtension, name: "Edge" }, + ])( + "should allow navigation without popout for new text Sends on $name", + async ({ deviceType }) => { + getDeviceSpy.mockReturnValue(deviceType); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + + const textSendState: RouterStateSnapshot = { + url: "/add-send?type=0&isNew=true", + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, textSendState)); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }, + ); + + it.each([ + { deviceType: DeviceType.FirefoxExtension, name: "Firefox" }, + { deviceType: DeviceType.SafariExtension, name: "Safari" }, + { deviceType: DeviceType.ChromeExtension, name: "Chrome" }, + { deviceType: DeviceType.EdgeExtension, name: "Edge" }, + ])("should allow navigation for editing text Sends on $name", async ({ deviceType }) => { + getDeviceSpy.mockReturnValue(deviceType); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + + const editTextSendState: RouterStateSnapshot = { + url: "/edit-send?sendId=abc123&type=0", + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => + guard(mockRoute, editTextSendState), + ); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); + + describe("File Sends (type=1)", () => { + it.each([ + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + expectPopout: true, + }, + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + expectPopout: true, + }, + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + expectPopout: true, + }, + { + deviceType: DeviceType.SafariExtension, + name: "Safari", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + expectPopout: true, + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + expectPopout: true, + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + expectPopout: true, + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + expectPopout: false, + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + expectPopout: true, + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + expectPopout: true, + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + expectPopout: false, + }, + ])( + "should require popout for a new file Send on $name $os", + async ({ deviceType, userAgent, expectPopout }) => { + getDeviceSpy.mockReturnValue(deviceType); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + + if (userAgent) { + Object.defineProperty(window, "navigator", { + value: { userAgent, appVersion: userAgent }, + configurable: true, + writable: true, + }); + } + + const fileSendState: RouterStateSnapshot = { + url: "/add-send?type=1&isNew=true", + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, fileSendState)); + + if (expectPopout === false) { + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + } else { + expect(openPopoutSpy).toHaveBeenCalledWith( + "popup/index.html#/add-send?type=1&isNew=true", + ); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + } + }, + ); + + it.each([ + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + expectPopout: true, + }, + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + expectPopout: true, + }, + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + expectPopout: true, + }, + { + deviceType: DeviceType.SafariExtension, + name: "Safari", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + expectPopout: true, + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + expectPopout: true, + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + expectPopout: true, + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + expectPopout: false, + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + expectPopout: true, + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + expectPopout: true, + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + expectPopout: false, + }, + ])( + "should require popout for editing a file Send on $name $os", + async ({ deviceType, userAgent, expectPopout }) => { + getDeviceSpy.mockReturnValue(deviceType); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + + if (userAgent) { + Object.defineProperty(window, "navigator", { + value: { userAgent, appVersion: userAgent }, + configurable: true, + writable: true, + }); + } + + const editFileSendState: RouterStateSnapshot = { + url: "/edit-send?sendId=abc123&type=1", + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => + guard(mockRoute, editFileSendState), + ); + + if (expectPopout === false) { + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + } else { + expect(openPopoutSpy).toHaveBeenCalledWith( + "popup/index.html#/edit-send?sendId=abc123&type=1", + ); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + } + }, + ); + }); + + describe("Send routes without type parameter", () => { + it.each([ + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + }, + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + { + deviceType: DeviceType.SafariExtension, + name: "Safari", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + ])( + "should default to requiring popout on $name $os", + async ({ deviceType, userAgent, os }) => { + getDeviceSpy.mockReturnValue(deviceType); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + + if (userAgent) { + Object.defineProperty(window, "navigator", { + value: { userAgent, appVersion: userAgent }, + configurable: true, + writable: true, + }); + } + + const noTypeState: RouterStateSnapshot = { + url: "/add-send", + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, noTypeState)); + + // Windows Chrome/Edge don't need popout + const isChromiumOnWindows = + (deviceType === DeviceType.ChromeExtension || + deviceType === DeviceType.EdgeExtension) && + os === "Windows"; + + if (isChromiumOnWindows) { + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + } else { + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + } + }, + ); + + it.each([ + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + }, + { + deviceType: DeviceType.FirefoxExtension, + name: "Firefox", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + { + deviceType: DeviceType.SafariExtension, + name: "Safari", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + }, + { + deviceType: DeviceType.ChromeExtension, + name: "Chrome", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Mac", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64)", + }, + { + deviceType: DeviceType.EdgeExtension, + name: "Edge", + os: "Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + ])( + "should default to requiring popout when type is invalid on $name $os", + async ({ deviceType, userAgent, os }) => { + getDeviceSpy.mockReturnValue(deviceType); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + + if (userAgent) { + Object.defineProperty(window, "navigator", { + value: { userAgent, appVersion: userAgent }, + configurable: true, + writable: true, + }); + } + + const invalidTypeState: RouterStateSnapshot = { + url: "/add-send?type=invalid", + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => + guard(mockRoute, invalidTypeState), + ); + + // Windows Chrome/Edge don't need popout + const isChromiumOnWindows = + (deviceType === DeviceType.ChromeExtension || + deviceType === DeviceType.EdgeExtension) && + os === "Windows"; + + if (isChromiumOnWindows) { + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(closePopupSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + } else { + expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=invalid"); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + } + }, + ); + }); + + describe("non-Send routes", () => { + it.each([ + { deviceType: DeviceType.FirefoxExtension, name: "Firefox", route: "/import" }, + { deviceType: DeviceType.FirefoxExtension, name: "Firefox", route: "/attachments" }, + { deviceType: DeviceType.SafariExtension, name: "Safari", route: "/import" }, + { deviceType: DeviceType.SafariExtension, name: "Safari", route: "/attachments" }, + ])( + "should always require popout for $route on $name regardless of query params", + async ({ deviceType, route }) => { + getDeviceSpy.mockReturnValue(deviceType); + inPopoutSpy.mockReturnValue(false); + inSidebarSpy.mockReturnValue(false); + + const routeState: RouterStateSnapshot = { + url: `${route}?type=0`, + } as RouterStateSnapshot; + + const guard = filePickerPopoutGuard(); + const result = await TestBed.runInInjectionContext(() => guard(mockRoute, routeState)); + + expect(openPopoutSpy).toHaveBeenCalledWith(`popup/index.html#${route}?type=0`); + expect(closePopupSpy).toHaveBeenCalledWith(window); + expect(result).toBe(false); + }, + ); + }); + }); +}); diff --git a/apps/browser/src/tools/popup/guards/file-picker-popout.guard.ts b/apps/browser/src/tools/popup/guards/file-picker-popout.guard.ts new file mode 100644 index 00000000000..900ff328ac8 --- /dev/null +++ b/apps/browser/src/tools/popup/guards/file-picker-popout.guard.ts @@ -0,0 +1,109 @@ +import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from "@angular/router"; + +import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api"; +import BrowserPopupUtils from "@bitwarden/browser/platform/browser/browser-popup-utils"; +import { BrowserPlatformUtilsService } from "@bitwarden/browser/platform/services/platform-utils/browser-platform-utils.service"; +import { DeviceType } from "@bitwarden/common/enums"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; + +/** + * Composite guard that handles file picker popout requirements for all browsers. + * Forces a popout window when file pickers could be exposed on browsers that require it. + * + * Browser-specific requirements: + * - Firefox: Requires sidebar OR popout (crashes with file picker in popup: https://bugzilla.mozilla.org/show_bug.cgi?id=1292701) + * - Safari: Requires popout only + * - All Chromium browsers (Chrome, Edge, Opera, Vivaldi) on Linux/Mac: Requires sidebar OR popout + * - Chromium on Windows: No special requirement + * + * Send-specific behavior: + * - Text Sends: No popout required (no file picker needed) + * - File Sends: Popout required on affected browsers + * + * @returns CanActivateFn that opens popout and blocks navigation when file picker access is needed + */ +export function filePickerPopoutGuard(): CanActivateFn { + return async (_route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { + // Check if this is a text Send route (no file picker needed) + if (isTextOnlySendRoute(state.url)) { + return true; // Allow navigation without popout + } + + // Check if browser is one that needs popout for file pickers + const deviceType = BrowserPlatformUtilsService.getDevice(window); + + // Check current context + const inPopout = BrowserPopupUtils.inPopout(window); + const inSidebar = BrowserPopupUtils.inSidebar(window); + + let needsPopout = false; + + // Firefox: needs sidebar OR popout to avoid crash with file picker + if (deviceType === DeviceType.FirefoxExtension && !inPopout && !inSidebar) { + needsPopout = true; + } + + // Safari: needs popout only (sidebar not available) + if (deviceType === DeviceType.SafariExtension && !inPopout) { + needsPopout = true; + } + + // Chromium on Linux/Mac: needs sidebar OR popout for file picker access + // All Chromium-based browsers (Chrome, Edge, Opera, Vivaldi) + // Brave intentionally reports itself as Chrome for compatibility + const isChromiumBased = [ + DeviceType.ChromeExtension, + DeviceType.EdgeExtension, + DeviceType.OperaExtension, + DeviceType.VivaldiExtension, + ].includes(deviceType); + + const isLinux = window?.navigator?.userAgent?.includes("Linux"); + const isMac = window?.navigator?.userAgent?.includes("Mac OS X"); + + if (isChromiumBased && (isLinux || isMac) && !inPopout && !inSidebar) { + needsPopout = true; + } + + // Open popout if needed + if (needsPopout) { + // Don't add autoClosePopout for file picker scenarios - user should manually close + await BrowserPopupUtils.openPopout(`popup/index.html#${state.url}`); + + // Close the original popup window + BrowserApi.closePopup(window); + + return false; // Block navigation - popout will reload + } + + return true; // Allow navigation + }; +} + +/** + * Determines if the route is for a text Send that doesn't require file picker display. + * + * @param url The route URL with query parameters + * @returns true if this is a Send route with explicitly text type (SendType.Text = 0) + */ +function isTextOnlySendRoute(url: string): boolean { + // Only apply to Send routes + if (!url.includes("/add-send") && !url.includes("/edit-send")) { + return false; + } + + // Parse query parameters to check Send type + const queryStartIndex = url.indexOf("?"); + if (queryStartIndex === -1) { + // No query params - default to requiring popout for safety + return false; + } + + const queryString = url.substring(queryStartIndex + 1); + const params = new URLSearchParams(queryString); + const typeParam = params.get("type"); + + // Only skip popout for explicitly text-based Sends (SendType.Text = 0) + // If type is missing, null, or not text, default to requiring popout + return typeParam === String(SendType.Text); +} diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html index a72847a5bf2..2d588a9ee78 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html @@ -10,8 +10,6 @@ > - - - -
- diff --git a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog.component.ts b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog.component.ts deleted file mode 100644 index 23fa744995a..00000000000 --- a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog.component.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; - -import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ButtonModule, DialogModule, DialogService, TypographyModule } from "@bitwarden/components"; - -import BrowserPopupUtils from "../../../../platform/browser/browser-popup-utils"; - -// 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: "send-file-popout-dialog", - templateUrl: "./send-file-popout-dialog.component.html", - imports: [JslibModule, CommonModule, DialogModule, ButtonModule, TypographyModule], -}) -export class SendFilePopoutDialogComponent { - constructor(private dialogService: DialogService) {} - - async popOutWindow() { - await BrowserPopupUtils.openCurrentPagePopout(window); - } - - close() { - this.dialogService.closeAll(); - } -} 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 6e73d9811f2..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,16 +11,15 @@ 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"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { mockAccountInfoWith } from "@bitwarden/common/spec"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { ButtonModule, NoItemsModule } from "@bitwarden/components"; import { @@ -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 89769bdd1ce..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,9 +11,7 @@ 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/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { skeletonLoadingDelay } from "@bitwarden/common/vault/utils/skeleton-loading.operator"; @@ -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([ @@ -139,7 +123,7 @@ export class SendV2Component implements OnDestroy { .pipe(takeUntilDestroyed()) .subscribe(([emptyList, noFilteredResults, currentFilter]) => { if (currentFilter?.sendType !== null) { - this.title = this.sendTypeTitles[currentFilter.sendType] ?? "allSends"; + this.title = this.sendTypeTitles[currentFilter.sendType as SendType] ?? "allSends"; } else { this.title = "allSends"; } diff --git a/apps/browser/src/tools/popup/services/file-popout-utils.service.ts b/apps/browser/src/tools/popup/services/file-popout-utils.service.ts deleted file mode 100644 index 9a04d4b8f23..00000000000 --- a/apps/browser/src/tools/popup/services/file-popout-utils.service.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Injectable } from "@angular/core"; - -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; - -import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; - -/** - * Service for determining whether to display file popout callout messages. - */ -@Injectable() -export class FilePopoutUtilsService { - /** - * Creates an instance of FilePopoutUtilsService. - */ - constructor(private platformUtilsService: PlatformUtilsService) {} - - /** - * Determines whether to show any file popout callout message in the current browser. - * @param win - The window context in which the check should be performed. - * @returns True if a file popout callout message should be displayed; otherwise, false. - */ - showFilePopoutMessage(win: Window): boolean { - return ( - this.showFirefoxFileWarning(win) || - this.showSafariFileWarning(win) || - this.showChromiumFileWarning(win) - ); - } - - /** - * Determines whether to show a file popout callout message for the Firefox browser - * @param win - The window context in which the check should be performed. - * @returns True if the extension is not in a sidebar or popout; otherwise, false. - */ - showFirefoxFileWarning(win: Window): boolean { - return ( - this.platformUtilsService.isFirefox() && - !(BrowserPopupUtils.inSidebar(win) || BrowserPopupUtils.inPopout(win)) - ); - } - - /** - * Determines whether to show a file popout message for the Safari browser - * @param win - The window context in which the check should be performed. - * @returns True if the extension is not in a popout; otherwise, false. - */ - showSafariFileWarning(win: Window): boolean { - return this.platformUtilsService.isSafari() && !BrowserPopupUtils.inPopout(win); - } - - /** - * Determines whether to show a file popout callout message for Chromium-based browsers in Linux and Mac OS X - * @param win - The window context in which the check should be performed. - * @returns True if the extension is not in a sidebar or popout; otherwise, false. - */ - showChromiumFileWarning(win: Window): boolean { - return ( - (this.isLinux(win) || this.isUnsupportedMac(win)) && - !this.platformUtilsService.isFirefox() && - !(BrowserPopupUtils.inSidebar(win) || BrowserPopupUtils.inPopout(win)) - ); - } - - private isLinux(win: Window): boolean { - return win?.navigator?.userAgent.indexOf("Linux") !== -1; - } - - private isUnsupportedMac(win: Window): boolean { - return this.platformUtilsService.isChrome() && win?.navigator?.appVersion.includes("Mac OS X"); - } -} 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..5aa962d5cc3 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.html +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.html @@ -1,19 +1,21 @@ - - {{ "unlockFeaturesWithPremium" | i18n }} - - - + @if (!(hasPremium$ | async)) { + + {{ "unlockFeaturesWithPremium" | i18n }} + + + + } @@ -23,14 +25,14 @@ - + {{ "accountSecurity" | i18n }} - +

{{ "autofill" | i18n }}

@@ -44,7 +46,7 @@
- + {{ "notifications" | i18n }} @@ -55,6 +57,7 @@ bit-item-content routerLink="/vault-settings" (click)="dismissBadge(NudgeType.EmptyVaultNudge)" + [truncate]="false" >
@@ -63,20 +66,18 @@ Currently can be only 1 item for notification. Will make this dynamic when more nudges are added --> - 1 + @if (showVaultBadge$ | async) { + 1 + }
- + {{ "appearance" | i18n }} @@ -85,7 +86,7 @@ @if (showAdminSettingsLink$ | async) { - +

{{ "admin" | i18n }}

@@ -101,30 +102,28 @@ } -
+ {{ "about" | i18n }} - + -
-

{{ "downloadBitwardenOnAllDevices" | i18n }}

- 1 - +
+

{{ "downloadBitwardenApps" | i18n }}

+ @if (showDownloadBitwardenNudge$ | async) { + 1 + + }
- + {{ "moreFromBitwarden" | i18n }} diff --git a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts index c37131b3ff1..f1614f800f2 100644 --- a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts @@ -6,7 +6,7 @@ import { firstValueFrom, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { AnchorLinkDirective, CalloutModule, BannerModule } from "@bitwarden/components"; +import { LinkComponent, CalloutModule, BannerModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { AtRiskPasswordCalloutData, AtRiskPasswordCalloutService } from "@bitwarden/vault"; @@ -15,7 +15,7 @@ import { AtRiskPasswordCalloutData, AtRiskPasswordCalloutService } from "@bitwar @Component({ selector: "vault-at-risk-password-callout", imports: [ - AnchorLinkDirective, + LinkComponent, CommonModule, RouterModule, CalloutModule, diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html deleted file mode 100644 index b86ec24fd20..00000000000 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - @if (canEdit) { - - } - - - {{ "clone" | i18n }} - - - {{ "assignToCollections" | i18n }} - - - @if (showArchive$ | async) { - @if (canArchive$ | async) { - - } @else { - - } - } - @if (canDelete$ | async) { - - } - - 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 deleted file mode 100644 index 37c4804e600..00000000000 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; -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"; - -import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; -import { VaultPopupLoadingService } from "../../../services/vault-popup-loading.service"; - -import { VaultV2SearchComponent } from "./vault-v2-search.component"; - -describe("VaultV2SearchComponent", () => { - let component: VaultV2SearchComponent; - let fixture: ComponentFixture; - - const searchText$ = new BehaviorSubject(""); - const loading$ = new BehaviorSubject(false); - const featureFlag$ = new BehaviorSubject(true); - const applyFilter = jest.fn(); - - const createComponent = () => { - fixture = TestBed.createComponent(VaultV2SearchComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }; - - beforeEach(async () => { - applyFilter.mockClear(); - featureFlag$.next(true); - - await TestBed.configureTestingModule({ - imports: [VaultV2SearchComponent, CommonModule, SearchModule, JslibModule, FormsModule], - providers: [ - { - provide: VaultPopupItemsService, - useValue: { - searchText$, - applyFilter, - }, - }, - { - provide: VaultPopupLoadingService, - useValue: { - loading$, - }, - }, - { - provide: ConfigService, - useValue: { - getFeatureFlag$: jest.fn(() => featureFlag$), - }, - }, - { provide: I18nService, useValue: { t: (key: string) => key } }, - ], - }).compileComponents(); - }); - - it("subscribes to search text from service", () => { - createComponent(); - - searchText$.next("test search"); - fixture.detectChanges(); - - expect(component.searchText).toBe("test search"); - }); - - 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); - })); - }); - - describe("when feature flag is disabled", () => { - beforeEach(() => { - featureFlag$.next(false); - createComponent(); - }); - - it("debounces search text changes", fakeAsync(() => { - component.searchText = "test"; - component.onSearchTextChanged(); - - expect(applyFilter).not.toHaveBeenCalled(); - - tick(SearchTextDebounceInterval); - - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); - - it("ignores loading state and always debounces", fakeAsync(() => { - loading$.next(true); - - component.searchText = "test"; - component.onSearchTextChanged(); - - expect(applyFilter).not.toHaveBeenCalled(); - - tick(SearchTextDebounceInterval); - - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); - }); - }); -}); 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 deleted file mode 100644 index 9b8380a4214..00000000000 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html b/apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.html similarity index 53% rename from apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html rename to apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.html index 8f184c6a0c1..d4495cf4c92 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.html @@ -1,3 +1,5 @@ +@let previouslyCouldArchive = !(userCanArchive$ | async) && config?.originalCipher?.archivedDate; + + @if (config?.originalCipher?.archivedDate && (archiveFlagEnabled$ | async)) { + + + {{ "archived" | i18n }} + + + } @@ -24,21 +33,23 @@ - + + @if (canDeleteCipher$ | async) { + + } + diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.spec.ts similarity index 71% rename from apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.spec.ts index f2c9d470816..170b6d2aa04 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.spec.ts @@ -1,10 +1,15 @@ +import { Location } from "@angular/common"; import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { provideNoopAnimations } from "@angular/platform-browser/animations"; import { ActivatedRoute, Router } from "@angular/router"; import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; +import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { EventType } from "@bitwarden/common/enums"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -12,13 +17,17 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { TaskService } from "@bitwarden/common/vault/tasks"; import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher-info"; +import { DialogService } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, CipherFormConfig, CipherFormConfigService, CipherFormMode, @@ -31,29 +40,31 @@ import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-uti import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; import { PopupCloseWarningService } from "../../../../../popup/services/popup-close-warning.service"; -import { AddEditV2Component } from "./add-edit-v2.component"; +import { AddEditComponent } from "./add-edit.component"; // 'qrcode-parser' is used by `BrowserTotpCaptureService` but is an es6 module that jest can't compile. // Mock the entire module here to prevent jest from throwing an error. I wasn't able to find a way to mock the // `BrowserTotpCaptureService` where jest would not load the file in the first place. jest.mock("qrcode-parser", () => {}); -describe("AddEditV2Component", () => { - let component: AddEditV2Component; - let fixture: ComponentFixture; +describe("AddEditComponent", () => { + let component: AddEditComponent; + let fixture: ComponentFixture; let addEditCipherInfo$: BehaviorSubject; let cipherServiceMock: MockProxy; const buildConfigResponse = { originalCipher: {} } as CipherFormConfig; - const buildConfig = jest.fn((mode: CipherFormMode) => - Promise.resolve({ ...buildConfigResponse, mode }), - ); + const buildConfig = jest.fn((mode) => Promise.resolve({ ...buildConfigResponse, mode })); const queryParams$ = new BehaviorSubject({}); const disable = jest.fn(); const navigate = jest.fn(); const back = jest.fn().mockResolvedValue(null); const setHistory = jest.fn(); const collect = jest.fn().mockResolvedValue(null); + const history$ = jest.fn(); + const historyGo = jest.fn().mockResolvedValue(null); + const openSimpleDialog = jest.fn().mockResolvedValue(true); + const cipherArchiveService = mock(); beforeEach(async () => { buildConfig.mockClear(); @@ -61,6 +72,12 @@ describe("AddEditV2Component", () => { navigate.mockClear(); back.mockClear(); collect.mockClear(); + history$.mockClear(); + historyGo.mockClear(); + openSimpleDialog.mockClear(); + + cipherArchiveService.hasArchiveFlagEnabled$ = of(true); + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); addEditCipherInfo$ = new BehaviorSubject(null); cipherServiceMock = mock({ @@ -68,13 +85,15 @@ describe("AddEditV2Component", () => { }); await TestBed.configureTestingModule({ - imports: [AddEditV2Component], + imports: [AddEditComponent], providers: [ + provideNoopAnimations(), { provide: PlatformUtilsService, useValue: mock() }, { provide: ConfigService, useValue: mock() }, - { provide: PopupRouterCacheService, useValue: { back, setHistory } }, + { provide: PopupRouterCacheService, useValue: { back, setHistory, history$ } }, { provide: PopupCloseWarningService, useValue: { disable } }, { provide: Router, useValue: { navigate } }, + { provide: Location, useValue: { historyGo } }, { provide: ActivatedRoute, useValue: { queryParams: queryParams$ } }, { provide: I18nService, useValue: { t: (key: string) => key } }, { provide: CipherService, useValue: cipherServiceMock }, @@ -83,10 +102,33 @@ describe("AddEditV2Component", () => { { provide: CipherAuthorizationService, useValue: { - canDeleteCipher$: jest.fn().mockReturnValue(true), + canDeleteCipher$: jest.fn().mockReturnValue(of(true)), }, }, { provide: AccountService, useValue: mockAccountServiceWith("UserId" as UserId) }, + { + provide: TaskService, + useValue: mock(), + }, + { + provide: ViewCacheService, + useValue: { signal: jest.fn(() => (): any => null) }, + }, + { + provide: BillingAccountProfileStateService, + useValue: mock(), + }, + { + provide: CipherArchiveService, + useValue: cipherArchiveService, + }, + { + provide: ArchiveCipherUtilitiesService, + useValue: { + archiveCipher: jest.fn().mockResolvedValue(null), + unarchiveCipher: jest.fn().mockResolvedValue(null), + }, + }, ], }) .overrideProvider(CipherFormConfigService, { @@ -94,9 +136,14 @@ describe("AddEditV2Component", () => { buildConfig, }, }) + .overrideProvider(DialogService, { + useValue: { + openSimpleDialog, + }, + }) .compileComponents(); - fixture = TestBed.createComponent(AddEditV2Component); + fixture = TestBed.createComponent(AddEditComponent); component = fixture.componentInstance; fixture.detectChanges(); }); @@ -356,6 +403,46 @@ describe("AddEditV2Component", () => { }); }); + describe("submit button text", () => { + beforeEach(() => { + // prevent form from rendering + jest.spyOn(component as any, "loading", "get").mockReturnValue(true); + }); + + it("sets it to 'save' by default", fakeAsync(() => { + buildConfigResponse.originalCipher = {} as Cipher; + + queryParams$.next({}); + + tick(); + + const submitBtn = fixture.debugElement.query(By.css("button[type=submit]")); + expect(submitBtn.nativeElement.textContent.trim()).toBe("save"); + })); + + it("sets it to 'save' when the user is able to archive the item", fakeAsync(() => { + buildConfigResponse.originalCipher = { isArchived: false } as any; + + queryParams$.next({}); + + tick(); + + const submitBtn = fixture.debugElement.query(By.css("button[type=submit]")); + expect(submitBtn.nativeElement.textContent.trim()).toBe("save"); + })); + + it("sets it to 'unarchiveAndSave' when the user cannot archive and the item is archived", fakeAsync(() => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + buildConfigResponse.originalCipher = { isArchived: true } as any; + + queryParams$.next({}); + tick(); + + const submitBtn = fixture.debugElement.query(By.css("button[type=submit]")); + expect(submitBtn.nativeElement.textContent.trim()).toBe("save"); + })); + }); + describe("delete", () => { it("dialogService openSimpleDialog called when deleteBtn is hit", async () => { const dialogSpy = jest @@ -374,12 +461,104 @@ describe("AddEditV2Component", () => { expect(deleteCipherSpy).toHaveBeenCalled(); }); - it("navigates to vault tab after deletion", async () => { + it("navigates to vault tab after deletion by default", async () => { jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true); await component.delete(); expect(navigate).toHaveBeenCalledWith(["/tabs/vault"]); }); + + it("navigates to custom route when not in history", fakeAsync(() => { + buildConfigResponse.originalCipher = { edit: true, id: "123" } as Cipher; + queryParams$.next({ + cipherId: "123", + routeAfterDeletion: "/archive", + }); + + tick(); + + // Mock history without the target route + history$.mockReturnValue( + of([ + { url: "/tabs/vault" }, + { url: "/view-cipher?cipherId=123" }, + { url: "/add-edit?cipherId=123" }, + ]), + ); + + jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true); + + void component.delete(); + tick(); + + expect(history$).toHaveBeenCalled(); + expect(historyGo).not.toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(["/archive"]); + })); + + it("uses historyGo when custom route exists in history", fakeAsync(() => { + buildConfigResponse.originalCipher = { edit: true, id: "123" } as Cipher; + queryParams$.next({ + cipherId: "123", + routeAfterDeletion: "/archive", + }); + + tick(); + + history$.mockReturnValue( + of([ + { url: "/tabs/vault" }, + { url: "/archive" }, + { url: "/view-cipher?cipherId=123" }, + { url: "/add-edit?cipherId=123" }, + ]), + ); + + jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true); + + void component.delete(); + tick(); + + expect(history$).toHaveBeenCalled(); + expect(historyGo).toHaveBeenCalledWith(-2); + expect(navigate).not.toHaveBeenCalled(); + })); + + it("uses router.navigate for default /tabs/vault route", fakeAsync(() => { + buildConfigResponse.originalCipher = { edit: true, id: "456" } as Cipher; + component.routeAfterDeletion = "/tabs/vault"; + + queryParams$.next({ + cipherId: "456", + }); + + tick(); + + jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true); + + void component.delete(); + tick(); + + expect(history$).not.toHaveBeenCalled(); + expect(historyGo).not.toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(["/tabs/vault"]); + })); + + it("ignores invalid routeAfterDeletion query param and uses default route", fakeAsync(() => { + // Reset the component's routeAfterDeletion to default before this test + component.routeAfterDeletion = "/tabs/vault"; + + buildConfigResponse.originalCipher = { edit: true, id: "456" } as Cipher; + queryParams$.next({ + cipherId: "456", + routeAfterDeletion: "/invalid/route", + }); + + tick(); + + // The invalid route should be ignored, routeAfterDeletion should remain default + expect(component.routeAfterDeletion).toBe("/tabs/vault"); + })); }); describe("reloadAddEditCipherData", () => { diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.ts similarity index 87% rename from apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts rename to apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.ts index 22aad854dd0..e62679a1b19 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { CommonModule } from "@angular/common"; -import { Component, OnInit, OnDestroy } from "@angular/core"; +import { CommonModule, Location } from "@angular/common"; +import { Component, OnInit, OnDestroy, viewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; import { ActivatedRoute, Params, Router } from "@angular/router"; @@ -16,6 +16,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { CipherType, toCipherType } from "@bitwarden/common/vault/enums"; @@ -29,8 +30,11 @@ import { IconButtonModule, DialogService, ToastService, + BadgeModule, } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, + CipherFormComponent, CipherFormConfig, CipherFormConfigService, CipherFormGenerationService, @@ -60,6 +64,18 @@ import { import { VaultPopoutType } from "../../../utils/vault-popout-window"; import { OpenAttachmentsComponent } from "../attachments/open-attachments/open-attachments.component"; +/** + * Available routes to navigate to after editing a cipher. + * Useful when the user could be coming from a different view other than the main vault (e.g., archive). + */ +export const ROUTES_AFTER_EDIT_DELETION = Object.freeze({ + tabsVault: "/tabs/vault", + archive: "/archive", +} as const); + +export type ROUTES_AFTER_EDIT_DELETION = + (typeof ROUTES_AFTER_EDIT_DELETION)[keyof typeof ROUTES_AFTER_EDIT_DELETION]; + /** * Helper class to parse query parameters for the AddEdit route. */ @@ -75,6 +91,7 @@ class QueryParams { this.username = params.username; this.name = params.name; this.prefillNameAndURIFromTab = params.prefillNameAndURIFromTab; + this.routeAfterDeletion = params.routeAfterDeletion ?? ROUTES_AFTER_EDIT_DELETION.tabsVault; } /** @@ -127,6 +144,12 @@ class QueryParams { * NOTE: This will override the `uri` and `name` query parameters if set to true. */ prefillNameAndURIFromTab?: true; + + /** + * The view that will be navigated to after deleting the cipher. + * @default "/tabs/vault" + */ + routeAfterDeletion?: ROUTES_AFTER_EDIT_DELETION; } export type AddEditQueryParams = Partial>; @@ -134,8 +157,8 @@ export type AddEditQueryParams = Partial>; // 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-add-edit-v2", - templateUrl: "add-edit-v2.component.html", + selector: "app-add-edit", + templateUrl: "add-edit.component.html", providers: [ { provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }, { provide: TotpCaptureService, useClass: BrowserTotpCaptureService }, @@ -156,12 +179,15 @@ export type AddEditQueryParams = Partial>; AsyncActionsModule, PopOutComponent, IconButtonModule, + BadgeModule, ], }) -export class AddEditV2Component implements OnInit, OnDestroy { +export class AddEditComponent implements OnInit, OnDestroy { + readonly cipherFormComponent = viewChild(CipherFormComponent); headerText: string; config: CipherFormConfig; canDeleteCipher$: Observable; + routeAfterDeletion: ROUTES_AFTER_EDIT_DELETION = "/tabs/vault"; get loading() { return this.config == null; @@ -171,9 +197,18 @@ export class AddEditV2Component implements OnInit, OnDestroy { return this.config?.originalCipher?.id as CipherId; } + get cipher(): CipherView { + return new CipherView(this.config?.originalCipher); + } + private fido2PopoutSessionData$ = fido2PopoutSessionData$(); private fido2PopoutSessionData: Fido2SessionData; + protected userId$ = this.accountService.activeAccount$.pipe(getUserId); + protected userCanArchive$ = this.userId$.pipe( + switchMap((userId) => this.archiveService.userCanArchive$(userId)), + ); + private get inFido2PopoutWindow() { return BrowserPopupUtils.inPopout(window) && this.fido2PopoutSessionData.isFido2Session; } @@ -182,6 +217,8 @@ export class AddEditV2Component implements OnInit, OnDestroy { return BrowserPopupUtils.inSingleActionPopout(window, VaultPopoutType.addEditVaultItem); } + protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$; + constructor( private route: ActivatedRoute, private i18nService: I18nService, @@ -196,6 +233,9 @@ export class AddEditV2Component implements OnInit, OnDestroy { private dialogService: DialogService, protected cipherAuthorizationService: CipherAuthorizationService, private accountService: AccountService, + private location: Location, + private archiveService: CipherArchiveService, + private archiveCipherUtilsService: ArchiveCipherUtilitiesService, ) { this.subscribeToParams(); } @@ -345,9 +385,7 @@ export class AddEditV2Component implements OnInit, OnDestroy { } config.initialValues = await this.setInitialValuesFromParams(params); - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(getUserId), - ); + const activeUserId = await firstValueFrom(this.userId$); // The browser notification bar and overlay use addEditCipherInfo$ to pass modified cipher details to the form // Attempt to fetch them here and overwrite the initialValues if present @@ -378,6 +416,13 @@ export class AddEditV2Component implements OnInit, OnDestroy { ); } + if ( + params.routeAfterDeletion && + Object.values(ROUTES_AFTER_EDIT_DELETION).includes(params.routeAfterDeletion) + ) { + this.routeAfterDeletion = params.routeAfterDeletion; + } + return config; }), ) @@ -444,14 +489,28 @@ export class AddEditV2Component implements OnInit, OnDestroy { } try { - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const activeUserId = await firstValueFrom(this.userId$); await this.deleteCipher(activeUserId); } catch (e) { this.logService.error(e); return false; } - await this.router.navigate(["/tabs/vault"]); + if (this.routeAfterDeletion !== ROUTES_AFTER_EDIT_DELETION.tabsVault) { + const history = await firstValueFrom(this.popupRouterCacheService.history$()); + const targetIndex = history.map((h) => h.url).lastIndexOf(this.routeAfterDeletion); + + if (targetIndex !== -1) { + const stepsBack = targetIndex - (history.length - 1); + // Use historyGo to navigate back to the target route in history + // This allows downstream calls to `back()` to continue working as expected + await this.location.historyGo(stepsBack); + } else { + await this.router.navigate([this.routeAfterDeletion]); + } + } else { + await this.router.navigate([this.routeAfterDeletion]); + } this.toastService.showToast({ variant: "success", diff --git a/apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.html b/apps/browser/src/vault/popup/components/vault/assign-collections/assign-collections.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.html rename to apps/browser/src/vault/popup/components/vault/assign-collections/assign-collections.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.ts b/apps/browser/src/vault/popup/components/vault/assign-collections/assign-collections.component.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.ts rename to apps/browser/src/vault/popup/components/vault/assign-collections/assign-collections.component.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.html b/apps/browser/src/vault/popup/components/vault/attachments/attachments.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.html rename to apps/browser/src/vault/popup/components/vault/attachments/attachments.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault/attachments/attachments.component.spec.ts similarity index 92% rename from apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/attachments/attachments.component.spec.ts index 1da2d352c14..377b4fa27cc 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/attachments/attachments.component.spec.ts @@ -23,7 +23,7 @@ import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component"; import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; -import { AttachmentsV2Component } from "./attachments-v2.component"; +import { AttachmentsComponent } from "./attachments.component"; @Component({ selector: "popup-header", @@ -44,9 +44,9 @@ class MockPopupFooterComponent { readonly pageTitle = input(); } -describe("AttachmentsV2Component", () => { - let component: AttachmentsV2Component; - let fixture: ComponentFixture; +describe("AttachmentsComponent", () => { + let component: AttachmentsComponent; + let fixture: ComponentFixture; const queryParams = new BehaviorSubject<{ cipherId: string }>({ cipherId: "5555-444-3333" }); let cipherAttachment: CipherAttachmentsComponent; const navigate = jest.fn(); @@ -60,7 +60,7 @@ describe("AttachmentsV2Component", () => { navigate.mockClear(); await TestBed.configureTestingModule({ - imports: [AttachmentsV2Component], + imports: [AttachmentsComponent], providers: [ { provide: LogService, useValue: mock() }, { provide: ConfigService, useValue: mock() }, @@ -83,7 +83,7 @@ describe("AttachmentsV2Component", () => { { provide: OrganizationService, useValue: mock() }, ], }) - .overrideComponent(AttachmentsV2Component, { + .overrideComponent(AttachmentsComponent, { remove: { imports: [PopupHeaderComponent, PopupFooterComponent], }, @@ -95,7 +95,7 @@ describe("AttachmentsV2Component", () => { }); beforeEach(() => { - fixture = TestBed.createComponent(AttachmentsV2Component); + fixture = TestBed.createComponent(AttachmentsComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -109,7 +109,7 @@ describe("AttachmentsV2Component", () => { }); 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/attachments/attachments-v2.component.ts b/apps/browser/src/vault/popup/components/vault/attachments/attachments.component.ts similarity index 94% rename from apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts rename to apps/browser/src/vault/popup/components/vault/attachments/attachments.component.ts index 29282d293de..63196edab30 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/attachments/attachments.component.ts @@ -20,8 +20,8 @@ import { PopupRouterCacheService } from "../../../../../platform/popup/view-cach // 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-attachments-v2", - templateUrl: "./attachments-v2.component.html", + selector: "app-attachments", + templateUrl: "./attachments.component.html", imports: [ CommonModule, ButtonModule, @@ -33,7 +33,7 @@ import { PopupRouterCacheService } from "../../../../../platform/popup/view-cach PopOutComponent, ], }) -export class AttachmentsV2Component { +export class AttachmentsComponent { /** The `id` tied to the underlying HTMLFormElement */ attachmentFormId = CipherAttachmentsComponent.attachmentFormID; diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.html b/apps/browser/src/vault/popup/components/vault/attachments/open-attachments/open-attachments.component.html similarity index 65% rename from apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.html rename to apps/browser/src/vault/popup/components/vault/attachments/open-attachments/open-attachments.component.html index 0fbe1c55b0a..1e9d63b709b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.html +++ b/apps/browser/src/vault/popup/components/vault/attachments/open-attachments/open-attachments.component.html @@ -4,14 +4,15 @@ type="button" (click)="openAttachments()" [disabled]="parentFormDisabled" + [title]="'popOutNewWindow' | i18n" >
{{ "attachments" | i18n }}
- - + {{ "popOutNewWindow" | i18n }} +
diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts b/apps/browser/src/vault/popup/components/vault/attachments/open-attachments/open-attachments.component.spec.ts similarity index 85% rename from apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/attachments/open-attachments/open-attachments.component.spec.ts index e9636e09873..b88b435c702 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/attachments/open-attachments/open-attachments.component.spec.ts @@ -20,9 +20,6 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { ToastService } from "@bitwarden/components"; import { CipherFormContainer } from "@bitwarden/vault"; -import BrowserPopupUtils from "../../../../../../platform/browser/browser-popup-utils"; -import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/file-popout-utils.service"; - import { OpenAttachmentsComponent } from "./open-attachments.component"; describe("OpenAttachmentsComponent", () => { @@ -31,9 +28,6 @@ describe("OpenAttachmentsComponent", () => { let router: Router; const showToast = jest.fn(); const hasPremiumFromAnySource$ = new BehaviorSubject(true); - const openCurrentPagePopout = jest - .spyOn(BrowserPopupUtils, "openCurrentPagePopout") - .mockResolvedValue(null); const cipherView = { id: "5555-444-3333", type: CipherType.Login, @@ -55,7 +49,6 @@ describe("OpenAttachmentsComponent", () => { const getCipher = jest.fn().mockResolvedValue(cipherDomain); const organizations$ = jest.fn().mockReturnValue(of([org])); - const showFilePopoutMessage = jest.fn().mockReturnValue(false); const mockUserId = Utils.newGuid() as UserId; const accountService = { @@ -70,11 +63,9 @@ describe("OpenAttachmentsComponent", () => { const formStatusChange$ = new BehaviorSubject<"enabled" | "disabled">("enabled"); beforeEach(async () => { - openCurrentPagePopout.mockClear(); getCipher.mockClear(); showToast.mockClear(); organizations$.mockClear(); - showFilePopoutMessage.mockClear(); hasPremiumFromAnySource$.next(true); formStatusChange$.next("enabled"); @@ -103,10 +94,6 @@ describe("OpenAttachmentsComponent", () => { provide: OrganizationService, useValue: { organizations$ }, }, - { - provide: FilePopoutUtilsService, - useValue: { showFilePopoutMessage }, - }, { provide: AccountService, useValue: accountService, @@ -130,8 +117,7 @@ describe("OpenAttachmentsComponent", () => { fixture.detectChanges(); }); - it("opens attachments in new popout", async () => { - showFilePopoutMessage.mockReturnValue(true); + it("navigates to attachments route", async () => { component.canAccessAttachments = true; await component.ngOnInit(); @@ -140,20 +126,6 @@ describe("OpenAttachmentsComponent", () => { expect(router.navigate).toHaveBeenCalledWith(["/attachments"], { queryParams: { cipherId: "5555-444-3333" }, }); - expect(openCurrentPagePopout).toHaveBeenCalledWith(window); - }); - - it("opens attachments in same window", async () => { - showFilePopoutMessage.mockReturnValue(false); - component.canAccessAttachments = true; - await component.ngOnInit(); - - await component.openAttachments(); - - expect(openCurrentPagePopout).not.toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith(["/attachments"], { - queryParams: { cipherId: "5555-444-3333" }, - }); }); it("routes the user to the premium page when they cannot access premium features", async () => { diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts b/apps/browser/src/vault/popup/components/vault/attachments/open-attachments/open-attachments.component.ts similarity index 86% rename from apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts rename to apps/browser/src/vault/popup/components/vault/attachments/open-attachments/open-attachments.component.ts index a267e7999ab..1a1f767ca8c 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts +++ b/apps/browser/src/vault/popup/components/vault/attachments/open-attachments/open-attachments.component.ts @@ -23,9 +23,6 @@ import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstraction import { BadgeModule, ItemModule, ToastService, TypographyModule } from "@bitwarden/components"; import { CipherFormContainer } from "@bitwarden/vault"; -import BrowserPopupUtils from "../../../../../../platform/browser/browser-popup-utils"; -import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/file-popout-utils.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({ @@ -46,9 +43,6 @@ export class OpenAttachmentsComponent implements OnInit { // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) cipherId: CipherId; - /** True when the attachments window should be opened in a popout */ - openAttachmentsInPopout: boolean; - /** True when the user has access to premium or h */ canAccessAttachments: boolean; @@ -65,7 +59,6 @@ export class OpenAttachmentsComponent implements OnInit { private organizationService: OrganizationService, private toastService: ToastService, private i18nService: I18nService, - private filePopoutUtilsService: FilePopoutUtilsService, private accountService: AccountService, private cipherFormContainer: CipherFormContainer, private premiumUpgradeService: PremiumUpgradePromptService, @@ -87,8 +80,6 @@ export class OpenAttachmentsComponent implements OnInit { } async ngOnInit(): Promise { - this.openAttachmentsInPopout = this.filePopoutUtilsService.showFilePopoutMessage(window); - if (!this.cipherId) { return; } @@ -131,12 +122,5 @@ export class OpenAttachmentsComponent implements OnInit { } await this.router.navigate(["/attachments"], { queryParams: { cipherId: this.cipherId } }); - - // Open the attachments page in a popout - // This is done after the router navigation to ensure that the navigation - // is included in the `PopupRouterCacheService` history - if (this.openAttachmentsInPopout) { - await BrowserPopupUtils.openCurrentPagePopout(window); - } } } diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html b/apps/browser/src/vault/popup/components/vault/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html rename to apps/browser/src/vault/popup/components/vault/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts similarity index 95% rename from apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts index a28b8730109..93cc2cf248a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts @@ -91,10 +91,18 @@ describe("AutofillConfirmationDialogComponent", () => { jest.resetAllMocks(); }); - const findShowAll = (inFx?: ComponentFixture) => - (inFx || fixture).nativeElement.querySelector( - "button.tw-text-sm.tw-font-medium.tw-cursor-pointer", - ) as HTMLButtonElement | null; + const findShowAll = (inFx?: ComponentFixture) => { + // Find the button by its text content (showAll or showLess) + const buttons = Array.from( + (inFx || fixture).nativeElement.querySelectorAll("button"), + ) as HTMLButtonElement[]; + return ( + buttons.find((btn) => { + const text = btn.textContent?.trim() || ""; + return text === "showAll" || text === "showLess"; + }) || null + ); + }; it("normalizes currentUrl and savedUrls via Utils.getHostname", () => { expect(Utils.getHostname).toHaveBeenCalledTimes(1 + (params.savedUrls?.length ?? 0)); diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts b/apps/browser/src/vault/popup/components/vault/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts rename to apps/browser/src/vault/popup/components/vault/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html b/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html similarity index 85% rename from apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html rename to apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html index 47ef0284d6a..38d60233200 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html +++ b/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html @@ -5,8 +5,9 @@ [showRefresh]="showRefresh" (onRefresh)="refreshCurrentTab()" [description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : undefined" - showAutofillButton + isAutofillList [disableDescriptionMargin]="showEmptyAutofillTip$ | async" - [primaryActionAutofill]="clickItemsToAutofillVaultView$ | async" [groupByType]="groupByType()" + [showAutofillButton]="(clickItemsToAutofillVaultView$ | async) === false" + [primaryActionAutofill]="clickItemsToAutofillVaultView$ | async" > diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts b/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts rename to apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/blocked-injection-banner/blocked-injection-banner.component.html b/apps/browser/src/vault/popup/components/vault/blocked-injection-banner/blocked-injection-banner.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/blocked-injection-banner/blocked-injection-banner.component.html rename to apps/browser/src/vault/popup/components/vault/blocked-injection-banner/blocked-injection-banner.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/blocked-injection-banner/blocked-injection-banner.component.ts b/apps/browser/src/vault/popup/components/vault/blocked-injection-banner/blocked-injection-banner.component.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/blocked-injection-banner/blocked-injection-banner.component.ts rename to apps/browser/src/vault/popup/components/vault/blocked-injection-banner/blocked-injection-banner.component.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/index.ts b/apps/browser/src/vault/popup/components/vault/index.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/index.ts rename to apps/browser/src/vault/popup/components/vault/index.ts 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/intro-carousel/intro-carousel.component.html similarity index 91% rename from apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html rename to apps/browser/src/vault/popup/components/vault/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/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/intro-carousel/intro-carousel.component.ts similarity index 92% rename from apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts rename to apps/browser/src/vault/popup/components/vault/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/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-copy-action/item-copy-actions.component.html b/apps/browser/src/vault/popup/components/vault/item-copy-action/item-copy-actions.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html rename to apps/browser/src/vault/popup/components/vault/item-copy-action/item-copy-actions.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.spec.ts b/apps/browser/src/vault/popup/components/vault/item-copy-action/item-copy-actions.component.spec.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/item-copy-action/item-copy-actions.component.spec.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts b/apps/browser/src/vault/popup/components/vault/item-copy-action/item-copy-actions.component.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts rename to apps/browser/src/vault/popup/components/vault/item-copy-action/item-copy-actions.component.ts diff --git a/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.html new file mode 100644 index 00000000000..4df3c8a5c73 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.html @@ -0,0 +1,73 @@ + + + + @if (!decryptionFailure) { + @if (canAutofill && showAutofill()) { + + + + } + @if (showViewOption()) { + + } + + @if (canEdit) { + + } + + + {{ "clone" | i18n }} + + + {{ "assignToCollections" | i18n }} + + + @if (showArchive$ | async) { + @if (canArchive$ | async) { + + } @else { + + } + } + } + @if (canDelete$ | async) { + + } + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.spec.ts similarity index 74% rename from apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.spec.ts index bd9ce108522..b999d8db35a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.spec.ts @@ -158,14 +158,6 @@ describe("ItemMoreOptionsComponent", () => { expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); }); - it("does not show the exact match dialog when the default match strategy is Exact and autofill confirmation is not to be shown", async () => { - uriMatchStrategy$.next(UriMatchStrategy.Exact); - autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); - }); - describe("autofill confirmation dialog", () => { beforeEach(() => { uriMatchStrategy$.next(UriMatchStrategy.Domain); @@ -236,22 +228,30 @@ describe("ItemMoreOptionsComponent", () => { }); describe("URI match strategy handling", () => { + it("calls the passwordService to passwordRepromptCheck", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + + await component.doAutofill(); + + expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); + }); + describe("when the default URI match strategy is Exact", () => { beforeEach(() => { uriMatchStrategy$.next(UriMatchStrategy.Exact); }); - it("calls the passwordService to passwordRepromptCheck", async () => { - autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); - mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); - - await component.doAutofill(); - - expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); - }); - - it("shows the exact match dialog", async () => { + it("shows the exact match dialog when the cipher has no saved URIs", async () => { autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [], + }, + })); await component.doAutofill(); @@ -266,6 +266,53 @@ describe("ItemMoreOptionsComponent", () => { expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); }); + + it("does not show the exact match dialog when the cipher has at least one non-exact match uri", async () => { + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://two.example.com", match: UriMatchStrategy.Domain }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("shows the exact match dialog when the cipher uris all have a match strategy of Exact", async () => { + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://two.example.com/a", match: UriMatchStrategy.Exact }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.objectContaining({ key: "cannotAutofill" }), + content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), + type: "info", + }), + ); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); }); describe("when the default URI match strategy is not Exact", () => { @@ -273,7 +320,45 @@ describe("ItemMoreOptionsComponent", () => { mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); uriMatchStrategy$.next(UriMatchStrategy.Domain); }); - it("does not show the exact match dialog", async () => { + + it("does not show the exact match dialog when the cipher has no saved URIs", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("shows the exact match dialog when the cipher has only exact match saved URIs", async () => { + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://two.example.com/a", match: UriMatchStrategy.Exact }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.objectContaining({ key: "cannotAutofill" }), + content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), + type: "info", + }), + ); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("does not show the exact match dialog when the cipher has at least one uri without a match strategy of Exact", async () => { + mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); cipherService.getFullCipherView.mockImplementation(async (c) => ({ ...baseCipher, ...c, @@ -292,70 +377,6 @@ describe("ItemMoreOptionsComponent", () => { expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); }); - - it("shows the exact match dialog when the cipher has a single uri with a match strategy of Exact", async () => { - cipherService.getFullCipherView.mockImplementation(async (c) => ({ - ...baseCipher, - ...c, - login: { - ...baseCipher.login, - uris: [{ uri: "https://one.example.com", match: UriMatchStrategy.Exact }], - }, - })); - - autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); - - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( - expect.objectContaining({ - title: expect.objectContaining({ key: "cannotAutofill" }), - content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), - type: "info", - }), - ); - expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); - expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); - }); - }); - - it("does not show the exact match dialog when the cipher has no uris", async () => { - mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); - cipherService.getFullCipherView.mockImplementation(async (c) => ({ - ...baseCipher, - ...c, - login: { - ...baseCipher.login, - uris: [], - }, - })); - - autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); - - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); - }); - - it("does not show the exact match dialog when the cipher has a uri with a match strategy of Exact and a uri with a match strategy of Domain", async () => { - mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); - cipherService.getFullCipherView.mockImplementation(async (c) => ({ - ...baseCipher, - ...c, - login: { - ...baseCipher.login, - uris: [ - { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, - { uri: "https://page.example.com", match: UriMatchStrategy.Domain }, - ], - }, - })); - - autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); - - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); }); }); @@ -384,4 +405,42 @@ describe("ItemMoreOptionsComponent", () => { }); }); }); + + describe("canAssignCollections$", () => { + it("emits true when user has organizations and editable collections", (done) => { + jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(true)); + jest + .spyOn(component["collectionService"], "decryptedCollections$") + .mockReturnValue(of([{ id: "col-1", readOnly: false }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it("emits false when user has no organizations", (done) => { + jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(false)); + jest + .spyOn(component["collectionService"], "decryptedCollections$") + .mockReturnValue(of([{ id: "col-1", readOnly: false }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + + it("emits false when all collections are read-only", (done) => { + jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(true)); + jest + .spyOn(component["collectionService"], "decryptedCollections$") + .mockReturnValue(of([{ id: "col-1", readOnly: true }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts similarity index 90% rename from apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts rename to apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts index c4353e17bef..ef4c4a111b6 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/item-more-options/item-more-options.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from "@angular/common"; -import { booleanAttribute, Component, Input } from "@angular/core"; +import { booleanAttribute, Component, input, Input } from "@angular/core"; import { Router, RouterModule } from "@angular/router"; import { BehaviorSubject, combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs"; import { filter } from "rxjs/operators"; @@ -35,7 +35,7 @@ import { PasswordRepromptService } from "@bitwarden/vault"; import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; -import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; +import { AddEditQueryParams } from "../add-edit/add-edit.component"; import { AutofillConfirmationDialogComponent, AutofillConfirmationDialogResult, @@ -76,22 +76,17 @@ export class ItemMoreOptionsComponent { } /** - * Flag to show view item menu option. Used when something else is - * assigned as the primary action for the item, such as autofill. + * Flag to show the autofill menu option. + * When true, the "Autofill" option appears in the menu. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input({ transform: booleanAttribute }) - showViewOption = false; + readonly showAutofill = input(false, { transform: booleanAttribute }); /** - * Flag to hide the autofill menu options. Used for items that are - * already in the autofill list suggestion. + * Flag to show the view menu option. + * When true, the "View" option appears in the menu. + * Used when the primary action is autofill (so users can view without autofilling). */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input({ transform: booleanAttribute }) - hideAutofillOptions = false; + readonly showViewOption = input(false, { transform: booleanAttribute }); protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$; @@ -204,12 +199,15 @@ export class ItemMoreOptionsComponent { } const uris = cipher.login?.uris ?? []; - const cipherHasAllExactMatchLoginUris = - uris.length > 0 && uris.every((u) => u.uri && u.match === UriMatchStrategy.Exact); - const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$); - if (cipherHasAllExactMatchLoginUris || uriMatchStrategy === UriMatchStrategy.Exact) { + const showExactMatchDialog = + uris.length === 0 + ? uriMatchStrategy === UriMatchStrategy.Exact + : // all saved URIs are exact match + uris.every((u) => (u.match ?? uriMatchStrategy) === UriMatchStrategy.Exact); + + if (showExactMatchDialog) { await this.dialogService.openSimpleDialog({ title: { key: "cannotAutofill" }, content: { key: "cannotAutofillExactMatch" }, @@ -274,8 +272,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( @@ -373,7 +370,8 @@ export class ItemMoreOptionsComponent { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "archiveItem" }, - content: { key: "archiveItemConfirmDesc" }, + content: { key: "archiveItemDialogContent" }, + acceptButtonText: { key: "archiveVerb" }, type: "info", }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html b/apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html rename to apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.spec.ts similarity index 93% rename from apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.spec.ts index 48e87e2d192..a20724e3160 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.spec.ts @@ -21,13 +21,13 @@ import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitward import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; -import { NewItemDropdownV2Component, NewItemInitialValues } from "./new-item-dropdown-v2.component"; +import { NewItemDropdownComponent, NewItemInitialValues } from "./new-item-dropdown.component"; -describe("NewItemDropdownV2Component", () => { - let component: NewItemDropdownV2Component; - let fixture: ComponentFixture; +describe("NewItemDropdownComponent", () => { + let component: NewItemDropdownComponent; + let fixture: ComponentFixture; let dialogServiceMock: jest.Mocked; - let browserApiMock: jest.Mocked; + const browserApiMock: jest.Mocked = mock(); let restrictedItemTypesServiceMock: jest.Mocked; const mockTab = { url: "https://example.com" }; @@ -62,7 +62,7 @@ describe("NewItemDropdownV2Component", () => { ButtonModule, MenuModule, NoItemsModule, - NewItemDropdownV2Component, + NewItemDropdownComponent, ], providers: [ { provide: I18nService, useValue: { t: (key: string) => key } }, @@ -80,7 +80,7 @@ describe("NewItemDropdownV2Component", () => { }); beforeEach(() => { - fixture = TestBed.createComponent(NewItemDropdownV2Component); + fixture = TestBed.createComponent(NewItemDropdownComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts b/apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.ts similarity index 94% rename from apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts rename to apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.ts index 004980db181..aa43743960b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.ts @@ -15,7 +15,7 @@ import { AddEditFolderDialogComponent } from "@bitwarden/vault"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; -import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; +import { AddEditQueryParams } from "../add-edit/add-edit.component"; export interface NewItemInitialValues { folderId?: string; @@ -27,10 +27,10 @@ export interface NewItemInitialValues { // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-new-item-dropdown", - templateUrl: "new-item-dropdown-v2.component.html", + templateUrl: "new-item-dropdown.component.html", imports: [NoItemsModule, JslibModule, CommonModule, ButtonModule, RouterLink, MenuModule], }) -export class NewItemDropdownV2Component implements OnInit { +export class NewItemDropdownComponent implements OnInit { cipherType = CipherType; private tab?: chrome.tabs.Tab; /** diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html b/apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html rename to apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.spec.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.spec.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts b/apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts rename to apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-header/vault-header.component.html similarity index 95% rename from apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html rename to apps/browser/src/vault/popup/components/vault/vault-header/vault-header.component.html index 1ab162b56fb..09b4cb2b461 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-header/vault-header.component.html @@ -1,6 +1,6 @@
- +
- - - - + @if (showFillTextOnHover()) { + + + {{ "fill" | i18n }} + + + } + @if (showAutofillBadge()) { + + + + } + @if (showLaunchButton() && CipherViewLikeUtils.canLaunch(cipher)) { + + + + } diff --git a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.spec.ts b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.spec.ts new file mode 100644 index 00000000000..eda84265e90 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.spec.ts @@ -0,0 +1,332 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CompactModeService, DialogService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; +import { VaultPopupSectionService } from "../../../services/vault-popup-section.service"; +import { PopupCipherViewLike } from "../../../views/popup-cipher.view"; + +import { VaultListItemsContainerComponent } from "./vault-list-items-container.component"; + +describe("VaultListItemsContainerComponent", () => { + let fixture: ComponentFixture; + let component: VaultListItemsContainerComponent; + + const featureFlag$ = new BehaviorSubject(false); + const currentTabIsOnBlocklist$ = new BehaviorSubject(false); + + const mockCipher = { + id: "cipher-1", + name: "Test Login", + type: CipherType.Login, + login: { + username: "user@example.com", + uris: [{ uri: "https://example.com", match: null }], + }, + favorite: false, + reprompt: 0, + organizationId: null, + collectionIds: [], + edit: true, + viewPassword: true, + } as any; + + const configService = { + getFeatureFlag$: jest.fn().mockImplementation((flag: FeatureFlag) => { + if (flag === FeatureFlag.PM31039ItemActionInExtension) { + return featureFlag$.asObservable(); + } + return of(false); + }), + }; + + const vaultPopupAutofillService = { + currentTabIsOnBlocklist$: currentTabIsOnBlocklist$.asObservable(), + doAutofill: jest.fn(), + }; + + const compactModeService = { + enabled$: of(false), + }; + + const vaultPopupSectionService = { + getOpenDisplayStateForSection: jest.fn().mockReturnValue(() => true), + updateSectionOpenStoredState: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + featureFlag$.next(false); + currentTabIsOnBlocklist$.next(false); + + await TestBed.configureTestingModule({ + imports: [VaultListItemsContainerComponent, NoopAnimationsModule], + providers: [ + { provide: ConfigService, useValue: configService }, + { provide: VaultPopupAutofillService, useValue: vaultPopupAutofillService }, + { provide: CompactModeService, useValue: compactModeService }, + { provide: VaultPopupSectionService, useValue: vaultPopupSectionService }, + { provide: I18nService, useValue: { t: (k: string) => k } }, + { provide: AccountService, useValue: { activeAccount$: of({ id: "UserId" }) } }, + { provide: CipherService, useValue: mock() }, + { provide: Router, useValue: { navigate: jest.fn() } }, + { provide: PlatformUtilsService, useValue: { getAutofillKeyboardShortcut: () => "" } }, + { provide: DialogService, useValue: mock() }, + { provide: PasswordRepromptService, useValue: mock() }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(VaultListItemsContainerComponent); + component = fixture.componentInstance; + }); + + describe("Updated item action feature flag", () => { + describe("when feature flag is OFF", () => { + beforeEach(() => { + featureFlag$.next(false); + fixture.detectChanges(); + }); + + it("should not show fill text on hover", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.showFillTextOnHover()).toBe(false); + }); + + it("should show autofill badge when showAutofillButton is true and primaryActionAutofill is false", () => { + fixture.componentRef.setInput("showAutofillButton", true); + fixture.componentRef.setInput("primaryActionAutofill", false); + fixture.detectChanges(); + + expect(component.showAutofillBadge()).toBe(true); + }); + + it("should hide autofill badge when primaryActionAutofill is true", () => { + fixture.componentRef.setInput("showAutofillButton", true); + fixture.componentRef.setInput("primaryActionAutofill", true); + fixture.detectChanges(); + + expect(component.showAutofillBadge()).toBe(false); + }); + + it("should show launch button when showAutofillButton is false", () => { + fixture.componentRef.setInput("showAutofillButton", false); + fixture.detectChanges(); + + expect(component.showLaunchButton()).toBe(true); + }); + + it("should hide launch button when showAutofillButton is true", () => { + fixture.componentRef.setInput("showAutofillButton", true); + fixture.detectChanges(); + + expect(component.showLaunchButton()).toBe(false); + }); + + it("should show autofill in menu when showAutofillButton is false", () => { + fixture.componentRef.setInput("showAutofillButton", false); + fixture.detectChanges(); + + expect(component.showAutofillInMenu()).toBe(true); + }); + + it("should hide autofill in menu when showAutofillButton is true", () => { + fixture.componentRef.setInput("showAutofillButton", true); + fixture.detectChanges(); + + expect(component.showAutofillInMenu()).toBe(false); + }); + + it("should show view in menu when primaryActionAutofill is true", () => { + fixture.componentRef.setInput("primaryActionAutofill", true); + fixture.detectChanges(); + + expect(component.showViewInMenu()).toBe(true); + }); + + it("should hide view in menu when primaryActionAutofill is false", () => { + fixture.componentRef.setInput("primaryActionAutofill", false); + fixture.detectChanges(); + + expect(component.showViewInMenu()).toBe(false); + }); + + it("should autofill on select when primaryActionAutofill is true", () => { + fixture.componentRef.setInput("primaryActionAutofill", true); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(true); + }); + + it("should not autofill on select when primaryActionAutofill is false", () => { + fixture.componentRef.setInput("primaryActionAutofill", false); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(false); + }); + }); + + describe("when feature flag is ON", () => { + beforeEach(() => { + featureFlag$.next(true); + fixture.detectChanges(); + }); + + it("should show fill text on hover for autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.showFillTextOnHover()).toBe(true); + }); + + it("should not show fill text on hover for non-autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + expect(component.showFillTextOnHover()).toBe(false); + }); + + it("should not show autofill badge", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.componentRef.setInput("showAutofillButton", true); + fixture.detectChanges(); + + expect(component.showAutofillBadge()).toBe(false); + }); + + it("should hide launch button for autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.showLaunchButton()).toBe(false); + }); + + it("should show launch button for non-autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + expect(component.showLaunchButton()).toBe(true); + }); + + it("should show autofill in menu for non-autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + expect(component.showAutofillInMenu()).toBe(true); + }); + + it("should hide autofill in menu for autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.showAutofillInMenu()).toBe(false); + }); + + it("should show view in menu for autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.showViewInMenu()).toBe(true); + }); + + it("should hide view in menu for non-autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + expect(component.showViewInMenu()).toBe(false); + }); + + it("should autofill on select for autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(true); + }); + + it("should not autofill on select for non-autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(false); + }); + }); + + describe("when current URI is blocked", () => { + beforeEach(() => { + currentTabIsOnBlocklist$.next(true); + fixture.detectChanges(); + }); + + it("should not autofill on select even when feature flag is ON and isAutofillList is true", () => { + featureFlag$.next(true); + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(false); + }); + + it("should not autofill on select even when primaryActionAutofill is true", () => { + featureFlag$.next(false); + fixture.componentRef.setInput("primaryActionAutofill", true); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(false); + }); + }); + }); + + describe("cipherItemTitleKey", () => { + it("should return autofillTitle when canAutofill is true", () => { + featureFlag$.next(true); + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + const titleKeyFn = component.cipherItemTitleKey(); + const result = titleKeyFn(mockCipher); + + expect(result).toBe("autofillTitleWithField"); + }); + + it("should return viewItemTitle when canAutofill is false", () => { + featureFlag$.next(true); + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + const titleKeyFn = component.cipherItemTitleKey(); + const result = titleKeyFn(mockCipher); + + expect(result).toBe("viewItemTitleWithField"); + }); + + it("should return title without WithField when cipher has no username", () => { + featureFlag$.next(true); + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + const cipherWithoutUsername = { + ...mockCipher, + login: { ...mockCipher.login, username: null }, + } as PopupCipherViewLike; + + const titleKeyFn = component.cipherItemTitleKey(); + const result = titleKeyFn(cipherWithoutUsername); + + expect(result).toBe("viewItemTitle"); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts similarity index 72% rename from apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts rename to apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts index 469247f9692..fb8d20c5cf6 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts @@ -21,6 +21,8 @@ import { firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; @@ -88,8 +90,15 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options export class VaultListItemsContainerComponent implements AfterViewInit { private compactModeService = inject(CompactModeService); private vaultPopupSectionService = inject(VaultPopupSectionService); + private configService = inject(ConfigService); protected CipherViewLikeUtils = CipherViewLikeUtils; + /** Signal for the feature flag that controls simplified item action behavior */ + protected readonly simplifiedItemActionEnabled = toSignal( + this.configService.getFeatureFlag$(FeatureFlag.PM31039ItemActionInExtension), + { initialValue: false }, + ); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(CdkVirtualScrollViewport, { static: false }) viewPort!: CdkVirtualScrollViewport; @@ -136,24 +145,18 @@ export class VaultListItemsContainerComponent implements AfterViewInit { */ private viewCipherTimeout?: number; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - ciphers = input([]); + readonly ciphers = input([]); /** * If true, we will group ciphers by type (Login, Card, Identity) * within subheadings in a single container, converted to a WritableSignal. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - groupByType = input(false); + readonly groupByType = input(false); /** * Computed signal for a grouped list of ciphers with an optional header */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - cipherGroups = computed< + readonly cipherGroups = computed< { subHeaderKey?: string; ciphers: PopupCipherViewLike[]; @@ -195,9 +198,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit { /** * Title for the vault list item section. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - title = input(undefined); + readonly title = input(undefined); /** * Optionally allow the items to be collapsed. @@ -205,24 +206,20 @@ export class VaultListItemsContainerComponent implements AfterViewInit { * The key must be added to the state definition in `vault-popup-section.service.ts` since the * collapsed state is stored locally. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - collapsibleKey = input(undefined); + readonly collapsibleKey = input(undefined); /** * Optional description for the vault list item section. Will be shown below the title even when * no ciphers are available. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - description = input(undefined); + + readonly description = input(undefined); /** * Option to show a refresh button in the section header. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - showRefresh = input(false, { transform: booleanAttribute }); + + readonly showRefresh = input(false, { transform: booleanAttribute }); /** * Event emitted when the refresh button is clicked. @@ -235,71 +232,124 @@ export class VaultListItemsContainerComponent implements AfterViewInit { /** * Flag indicating that the current tab location is blocked */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - currentURIIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$); + readonly currentUriIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$); /** * Resolved i18n key to use for suggested cipher items */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - cipherItemTitleKey = computed(() => { + readonly cipherItemTitleKey = computed(() => { return (cipher: CipherViewLike) => { const login = CipherViewLikeUtils.getLogin(cipher); const hasUsername = login?.username != null; - const key = - this.primaryActionAutofill() && !this.currentURIIsBlocked() - ? "autofillTitle" - : "viewItemTitle"; + // Use autofill title when autofill is the primary action + const key = this.canAutofill() ? "autofillTitle" : "viewItemTitle"; return hasUsername ? `${key}WithField` : key; }; }); /** + * @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out * Option to show the autofill button for each item. + * Used when feature flag is disabled. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - showAutofillButton = input(false, { transform: booleanAttribute }); + readonly showAutofillButton = input(false, { transform: booleanAttribute }); /** - * Flag indicating whether the suggested cipher item autofill button should be shown or not + * @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out + * Whether to show the autofill badge button (old behavior). + * Only shown when feature flag is disabled AND conditions are met. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - hideAutofillButton = computed( - () => !this.showAutofillButton() || this.currentURIIsBlocked() || this.primaryActionAutofill(), + readonly showAutofillBadge = computed( + () => !this.simplifiedItemActionEnabled() && !this.hideAutofillButton(), ); /** - * Flag indicating whether the cipher item autofill menu options should be shown or not + * @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out + * Flag indicating whether the cipher item autofill menu options should be shown or not. + * Used when feature flag is disabled. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - hideAutofillMenuOptions = computed(() => this.currentURIIsBlocked() || this.showAutofillButton()); + readonly hideAutofillMenuOptions = computed( + () => this.currentUriIsBlocked() || this.showAutofillButton(), + ); /** + * @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out * Option to perform autofill operation as the primary action for autofill suggestions. + * Used when feature flag is disabled. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - primaryActionAutofill = input(false, { transform: booleanAttribute }); + readonly primaryActionAutofill = input(false, { transform: booleanAttribute }); + + /** + * @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out + * Flag indicating whether the suggested cipher item autofill button should be shown or not. + * Used when feature flag is disabled. + */ + readonly hideAutofillButton = computed( + () => !this.showAutofillButton() || this.currentUriIsBlocked() || this.primaryActionAutofill(), + ); + + /** + * Option to mark this container as an autofill list. + */ + readonly isAutofillList = input(false, { transform: booleanAttribute }); + + /** + * Computed property whether the cipher action may perform autofill. + * When feature flag is enabled, uses isAutofillList. + * When feature flag is disabled, uses primaryActionAutofill. + */ + readonly canAutofill = computed(() => { + if (this.currentUriIsBlocked()) { + return false; + } + return this.isAutofillList() + ? this.simplifiedItemActionEnabled() + : this.primaryActionAutofill(); + }); + + /** + * Whether to show the "Fill" text on hover. + * Only shown when feature flag is enabled AND this is an autofill list. + */ + readonly showFillTextOnHover = computed( + () => this.simplifiedItemActionEnabled() && this.canAutofill(), + ); + + /** + * Whether to show the launch button. + */ + readonly showLaunchButton = computed(() => + this.simplifiedItemActionEnabled() ? !this.isAutofillList() : !this.showAutofillButton(), + ); + + /** + * Whether to show the "Autofill" option in the more options menu. + * New behavior: show for non-autofill list items. + * Old behavior: show when not hidden by hideAutofillMenuOptions. + */ + readonly showAutofillInMenu = computed(() => + this.simplifiedItemActionEnabled() ? !this.canAutofill() : !this.hideAutofillMenuOptions(), + ); + + /** + * Whether to show the "View" option in the more options menu. + * New behavior: show for autofill list items (since click = autofill). + * Old behavior: show when primary action is autofill. + */ + readonly showViewInMenu = computed(() => + this.simplifiedItemActionEnabled() ? this.isAutofillList() : this.primaryActionAutofill(), + ); /** * Remove the bottom margin from the bit-section in this component * (used for containers at the end of the page where bottom margin is not needed) */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - disableSectionMargin = input(false, { transform: booleanAttribute }); + readonly disableSectionMargin = input(false, { transform: booleanAttribute }); /** * Remove the description margin */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - disableDescriptionMargin = input(false, { transform: booleanAttribute }); + readonly disableDescriptionMargin = input(false, { transform: booleanAttribute }); /** * The tooltip text for the organization icon for ciphers that belong to an organization. @@ -313,9 +363,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit { return collections[0]?.name; } - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - protected autofillShortcutTooltip = signal(undefined); + protected readonly autofillShortcutTooltip = signal(undefined); constructor( private i18nService: I18nService, @@ -340,10 +388,8 @@ export class VaultListItemsContainerComponent implements AfterViewInit { } } - primaryActionOnSelect(cipher: PopupCipherViewLike) { - return this.primaryActionAutofill() && !this.currentURIIsBlocked() - ? this.doAutofill(cipher) - : this.onViewCipher(cipher); + onCipherSelect(cipher: PopupCipherViewLike) { + return this.canAutofill() ? this.doAutofill(cipher) : this.onViewCipher(cipher); } /** diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-password-history/vault-password-history.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.html rename to apps/browser/src/vault/popup/components/vault/vault-password-history/vault-password-history.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault/vault-password-history/vault-password-history.component.spec.ts similarity index 90% rename from apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/vault-password-history/vault-password-history.component.spec.ts index 838ce2e9426..3b8a6db25b1 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-password-history/vault-password-history.component.spec.ts @@ -16,10 +16,10 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; -import { PasswordHistoryV2Component } from "./vault-password-history-v2.component"; +import { PasswordHistoryComponent } from "./vault-password-history.component"; -describe("PasswordHistoryV2Component", () => { - let fixture: ComponentFixture; +describe("PasswordHistoryComponent", () => { + let fixture: ComponentFixture; const params$ = new Subject(); const mockUserId = "acct-1" as UserId; @@ -40,7 +40,7 @@ describe("PasswordHistoryV2Component", () => { getCipher.mockClear(); await TestBed.configureTestingModule({ - imports: [PasswordHistoryV2Component], + imports: [PasswordHistoryComponent], providers: [ { provide: WINDOW, useValue: window }, { provide: PlatformUtilsService, useValue: mock() }, @@ -56,7 +56,7 @@ describe("PasswordHistoryV2Component", () => { ], }).compileComponents(); - fixture = TestBed.createComponent(PasswordHistoryV2Component); + fixture = TestBed.createComponent(PasswordHistoryComponent); fixture.detectChanges(); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts b/apps/browser/src/vault/popup/components/vault/vault-password-history/vault-password-history.component.ts similarity index 94% rename from apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts rename to apps/browser/src/vault/popup/components/vault/vault-password-history/vault-password-history.component.ts index 7b9f358c01c..08e17d61acd 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-password-history/vault-password-history.component.ts @@ -21,8 +21,8 @@ import { PopupRouterCacheService } from "../../../../../platform/popup/view-cach // 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: "vault-password-history-v2", - templateUrl: "vault-password-history-v2.component.html", + selector: "vault-password-history", + templateUrl: "vault-password-history.component.html", imports: [ JslibModule, PopupPageComponent, @@ -32,7 +32,7 @@ import { PopupRouterCacheService } from "../../../../../platform/popup/view-cach NgIf, ], }) -export class PasswordHistoryV2Component implements OnInit { +export class PasswordHistoryComponent implements OnInit { protected cipher: CipherView; constructor( diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html b/apps/browser/src/vault/popup/components/vault/vault-search/vault-search.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html rename to apps/browser/src/vault/popup/components/vault/vault-search/vault-search.component.html diff --git a/apps/browser/src/vault/popup/components/vault/vault-search/vault-search.component.spec.ts b/apps/browser/src/vault/popup/components/vault/vault-search/vault-search.component.spec.ts new file mode 100644 index 00000000000..ef8df2d2c6a --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault/vault-search/vault-search.component.spec.ts @@ -0,0 +1,115 @@ +import { CommonModule } from "@angular/common"; +import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; +import { FormsModule } from "@angular/forms"; +import { BehaviorSubject } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service"; +import { SearchModule } from "@bitwarden/components"; + +import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; +import { VaultPopupLoadingService } from "../../../services/vault-popup-loading.service"; + +import { VaultSearchComponent } from "./vault-search.component"; + +describe("VaultSearchComponent", () => { + let component: VaultSearchComponent; + let fixture: ComponentFixture; + + const searchText$ = new BehaviorSubject(""); + const loading$ = new BehaviorSubject(false); + const applyFilter = jest.fn(); + + const createComponent = () => { + fixture = TestBed.createComponent(VaultSearchComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }; + + beforeEach(async () => { + applyFilter.mockClear(); + + await TestBed.configureTestingModule({ + imports: [VaultSearchComponent, CommonModule, SearchModule, JslibModule, FormsModule], + providers: [ + { + provide: VaultPopupItemsService, + useValue: { + searchText$, + applyFilter, + }, + }, + { + provide: VaultPopupLoadingService, + useValue: { + loading$, + }, + }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + ], + }).compileComponents(); + }); + + it("subscribes to search text from service", () => { + createComponent(); + + searchText$.next("test search"); + fixture.detectChanges(); + + expect(component.searchText).toBe("test search"); + }); + + describe("debouncing behavior", () => { + beforeEach(() => { + 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); + })); + }); +}); 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/vault-search/vault-search.component.ts similarity index 60% rename from apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts rename to apps/browser/src/vault/popup/components/vault/vault-search/vault-search.component.ts index 154cd49c5a3..9ce26a6310d 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/vault-search/vault-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"; @@ -28,10 +24,10 @@ import { VaultPopupLoadingService } from "../../../services/vault-popup-loading. // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ imports: [CommonModule, SearchModule, JslibModule, FormsModule], - selector: "app-vault-v2-search", - templateUrl: "vault-v2-search.component.html", + selector: "app-vault-search", + templateUrl: "vault-search.component.html", }) -export class VaultV2SearchComponent { +export class VaultSearchComponent { searchText: string = ""; private searchText$ = new Subject(); @@ -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/vault.component.html similarity index 62% rename from apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html rename to apps/browser/src/vault/popup/components/vault/vault.component.html index 6382b5fee0e..28abb92b8a9 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault.component.html @@ -1,4 +1,4 @@ - + @@ -8,31 +8,28 @@ - -
- - {{ "yourVaultIsEmpty" | i18n }} - -

- {{ "emptyVaultDescription" | i18n }} -

-
- - {{ "newLogin" | i18n }} - -
-
-
- - @if (skeletonFeatureFlag$ | async) { - - + @if (vaultState === VaultStateEnum.Empty) { + +
+ + {{ "yourVaultIsEmpty" | i18n }} + +

+ {{ "emptyVaultDescription" | i18n }} +

+
+ + {{ "newLogin" | i18n }} + +
+
- } @else { - }
- + @@ -107,31 +104,37 @@
- - - - - - - - - @if (skeletonFeatureFlag$ | async) { - - + @if (vaultState === null) { + + @if (!(loading$ | async)) { + + @if (hasSearchText$ | async) { + + } @else { + + + + + } + } - } @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/vault.component.spec.ts similarity index 62% rename from apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/vault.component.spec.ts index 883d17b61c3..70affd73ef3 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/vault.component.spec.ts @@ -1,7 +1,7 @@ -import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling"; import { ChangeDetectionStrategy, Component, input, NO_ERRORS_SCHEMA } from "@angular/core"; import { TestBed, fakeAsync, flush, tick } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; +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"; @@ -9,7 +9,10 @@ import { BehaviorSubject, Observable, Subject, of } from "rxjs"; import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; import { NudgeType, NudgesService } from "@bitwarden/angular/vault"; -import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { + AutoConfirmExtensionSetupDialogComponent, + AutomaticUserConfirmationService, +} from "@bitwarden/auto-confirm"; import { CurrentAccountComponent } from "@bitwarden/browser/auth/popup/account-switching/current-account.component"; import AutofillService from "@bitwarden/browser/autofill/services/autofill.service"; import { PopOutComponent } from "@bitwarden/browser/platform/popup/components/pop-out.component"; @@ -41,15 +44,16 @@ import { VaultPopupAutofillService } from "../../services/vault-popup-autofill.s import { VaultPopupCopyButtonsService } from "../../services/vault-popup-copy-buttons.service"; import { VaultPopupItemsService } from "../../services/vault-popup-items.service"; import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service"; +import { VaultPopupLoadingService } from "../../services/vault-popup-loading.service"; import { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service"; import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-password-callout.component"; import { AutofillVaultListItemsComponent } from "./autofill-vault-list-items/autofill-vault-list-items.component"; import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component"; -import { NewItemDropdownV2Component } from "./new-item-dropdown/new-item-dropdown-v2.component"; -import { VaultHeaderV2Component } from "./vault-header/vault-header-v2.component"; +import { NewItemDropdownComponent } from "./new-item-dropdown/new-item-dropdown.component"; +import { VaultHeaderComponent } from "./vault-header/vault-header.component"; import { VaultListItemsContainerComponent } from "./vault-list-items-container/vault-list-items-container.component"; -import { VaultV2Component } from "./vault-v2.component"; +import { VaultComponent } from "./vault.component"; @Component({ selector: "popup-header", @@ -62,12 +66,12 @@ export class PopupHeaderStubComponent { } @Component({ - selector: "app-vault-header-v2", + selector: "app-vault-header", standalone: true, template: "", changeDetection: ChangeDetectionStrategy.OnPush, }) -export class VaultHeaderV2StubComponent {} +export class VaultHeaderStubComponent {} @Component({ selector: "app-current-account", @@ -136,6 +140,7 @@ class VaultListItemsContainerStubComponent { const mockDialogRef = { close: jest.fn(), afterClosed: jest.fn().mockReturnValue(of(undefined)), + closed: of(undefined), } as unknown as import("@bitwarden/components").DialogRef; jest @@ -145,11 +150,16 @@ jest jest .spyOn(DecryptionFailureDialogComponent, "open") .mockImplementation((_: DialogService, _params: any) => mockDialogRef as any); + +const autoConfirmDialogSpy = jest + .spyOn(AutoConfirmExtensionSetupDialogComponent, "open") + .mockImplementation((_: DialogService) => mockDialogRef as any); + jest.spyOn(BrowserApi, "isPopupOpen").mockResolvedValue(false); jest.spyOn(BrowserPopupUtils, "openCurrentPagePopout").mockResolvedValue(); -describe("VaultV2Component", () => { - let component: VaultV2Component; +describe("VaultComponent", () => { + let component: VaultComponent; interface FakeAccount { id: string; @@ -165,17 +175,23 @@ describe("VaultV2Component", () => { showDeactivatedOrg$: new BehaviorSubject(false), favoriteCiphers$: new BehaviorSubject([]), remainingCiphers$: new BehaviorSubject([]), + filteredCiphers$: new BehaviorSubject([]), cipherCount$: new BehaviorSubject(0), - loading$: new BehaviorSubject(true), + hasSearchText$: new BehaviorSubject(false), } as Partial; - const filtersSvc = { + const filtersSvc: any = { allFilters$: new Subject(), filters$: new BehaviorSubject({}), filterVisibilityState$: new BehaviorSubject({}), - } as Partial; + numberOfAppliedFilters$: new BehaviorSubject(0), + }; - const accountActive$ = new BehaviorSubject({ id: "user-1" }); + const loadingSvc: any = { + loading$: new BehaviorSubject(false), + }; + + const activeAccount$ = new BehaviorSubject({ id: "user-1" }); const cipherSvc = { failedToDecryptCiphers$: jest.fn().mockReturnValue(of([])), @@ -212,36 +228,35 @@ 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)), }; + const autoConfirmSvc = { + configuration$: jest.fn().mockReturnValue(of({})), + canManageAutoConfirm$: jest.fn().mockReturnValue(of(false)), + upsert: jest.fn().mockResolvedValue(undefined), + autoConfirmUser: jest.fn().mockResolvedValue(undefined), + }; + beforeEach(async () => { jest.clearAllMocks(); await TestBed.configureTestingModule({ - imports: [VaultV2Component, RouterTestingModule], + imports: [VaultComponent, RouterTestingModule], providers: [ + provideNoopAnimations(), { provide: VaultPopupItemsService, useValue: itemsSvc }, { provide: VaultPopupListFiltersService, useValue: filtersSvc }, + { provide: VaultPopupLoadingService, useValue: loadingSvc }, { 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) }, @@ -275,17 +290,21 @@ describe("VaultV2Component", () => { provide: SearchService, useValue: { isCipherSearching$: of(false) }, }, + { + provide: AutomaticUserConfirmationService, + useValue: autoConfirmSvc, + }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); - TestBed.overrideComponent(VaultV2Component, { + TestBed.overrideComponent(VaultComponent, { remove: { imports: [ PopupHeaderComponent, - VaultHeaderV2Component, + VaultHeaderComponent, CurrentAccountComponent, - NewItemDropdownV2Component, + NewItemDropdownComponent, PopOutComponent, BlockedInjectionBanner, AtRiskPasswordCalloutComponent, @@ -299,7 +318,7 @@ describe("VaultV2Component", () => { add: { imports: [ PopupHeaderStubComponent, - VaultHeaderV2StubComponent, + VaultHeaderStubComponent, CurrentAccountStubComponent, NewItemDropdownStubComponent, PopOutStubComponent, @@ -312,7 +331,7 @@ describe("VaultV2Component", () => { }, }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); component = fixture.componentInstance; }); @@ -355,39 +374,46 @@ describe("VaultV2Component", () => { }); it("loading$ is true when items loading or filters missing; false when both ready", () => { - const itemsLoading$ = itemsSvc.loading$ as unknown as BehaviorSubject; + const vaultLoading$ = loadingSvc.loading$ as unknown as BehaviorSubject; const allFilters$ = filtersSvc.allFilters$ as unknown as Subject; const readySubject$ = component["readySubject"] as unknown as BehaviorSubject; const values: boolean[] = []; getObs(component, "loading$").subscribe((v) => values.push(!!v)); - itemsLoading$.next(true); + vaultLoading$.next(true); allFilters$.next({}); - itemsLoading$.next(false); + vaultLoading$.next(false); readySubject$.next(true); expect(values[values.length - 1]).toBe(false); }); - it("ngAfterViewInit waits for allFilters$ then starts scroll position service", fakeAsync(() => { + it("passes popup-page scroll region element to scroll position service", fakeAsync(() => { + const fixture = TestBed.createComponent(VaultComponent); + const component = fixture.componentInstance; + + const readySubject$ = component["readySubject"] as unknown as BehaviorSubject; + const vaultLoading$ = loadingSvc.loading$ as unknown as BehaviorSubject; const allFilters$ = filtersSvc.allFilters$ as unknown as Subject; - (component as any).virtualScrollElement = {} as CdkVirtualScrollableElement; - - component.ngAfterViewInit(); - expect(scrollSvc.start).not.toHaveBeenCalled(); - - allFilters$.next({ any: true }); + fixture.detectChanges(); tick(); - expect(scrollSvc.start).toHaveBeenCalledTimes(1); - expect(scrollSvc.start).toHaveBeenCalledWith((component as any).virtualScrollElement); + const scrollRegion = fixture.nativeElement.querySelector( + '[data-testid="popup-layout-scroll-region"]', + ) as HTMLElement; - flush(); + // Unblock loading + vaultLoading$.next(false); + readySubject$.next(true); + allFilters$.next({}); + tick(); + + expect(scrollSvc.start).toHaveBeenCalledWith(scrollRegion); })); it("showPremiumDialog opens PremiumUpgradeDialogComponent", () => { @@ -395,29 +421,13 @@ describe("VaultV2Component", () => { expect(PremiumUpgradeDialogComponent.open).toHaveBeenCalledTimes(1); }); - it("navigateToImport navigates and opens popout if popup is open", fakeAsync(async () => { - (BrowserApi.isPopupOpen as jest.Mock).mockResolvedValueOnce(true); - + it("navigateToImport navigates to import route", fakeAsync(async () => { const ngRouter = TestBed.inject(Router); jest.spyOn(ngRouter, "navigate").mockResolvedValue(true as any); await component["navigateToImport"](); expect(ngRouter.navigate).toHaveBeenCalledWith(["/import"]); - - expect(BrowserPopupUtils.openCurrentPagePopout).toHaveBeenCalled(); - })); - - it("navigateToImport does not popout when popup is not open", fakeAsync(async () => { - (BrowserApi.isPopupOpen as jest.Mock).mockResolvedValueOnce(false); - - const ngRouter = TestBed.inject(Router); - jest.spyOn(ngRouter, "navigate").mockResolvedValue(true as any); - - await component["navigateToImport"](); - - expect(ngRouter.navigate).toHaveBeenCalledWith(["/import"]); - expect(BrowserPopupUtils.openCurrentPagePopout).not.toHaveBeenCalled(); })); it("ngOnInit dismisses intro carousel and opens decryption dialog for non-deleted failures", fakeAsync(() => { @@ -444,7 +454,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(); @@ -456,6 +466,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); @@ -477,7 +491,7 @@ describe("VaultV2Component", () => { of(type === NudgeType.PremiumUpgrade), ); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); const component = fixture.componentInstance; void component.ngOnInit(); @@ -510,7 +524,7 @@ describe("VaultV2Component", () => { return of(type === NudgeType.EmptyVaultNudge); }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); fixture.detectChanges(); tick(); @@ -527,7 +541,7 @@ describe("VaultV2Component", () => { return of(type === NudgeType.HasVaultItems); }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); fixture.detectChanges(); tick(); @@ -541,15 +555,11 @@ 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); }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); fixture.detectChanges(); tick(); @@ -565,7 +575,7 @@ describe("VaultV2Component", () => { return of(type === NudgeType.PremiumUpgrade); }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); fixture.detectChanges(); tick(); @@ -581,11 +591,214 @@ describe("VaultV2Component", () => { return of(type === NudgeType.PremiumUpgrade); }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); fixture.detectChanges(); tick(); const spotlights = queryAllSpotlights(fixture); expect(spotlights.length).toBe(0); })); + + it("does not render app-autofill-vault-list-items or favorites item container when hasSearchText$ is true", () => { + itemsSvc.hasSearchText$.next(true); + + const fixture = TestBed.createComponent(VaultComponent); + component = fixture.componentInstance; + + const readySubject$ = component["readySubject"]; + const allFilters$ = filtersSvc.allFilters$ as unknown as Subject; + + // Unblock loading + readySubject$.next(true); + allFilters$.next({}); + fixture.detectChanges(); + + const autofillElement = fixture.debugElement.query(By.css("app-autofill-vault-list-items")); + expect(autofillElement).toBeFalsy(); + + const favoritesElement = fixture.debugElement.query(By.css("#favorites")); + expect(favoritesElement).toBeFalsy(); + }); + + it("does render app-autofill-vault-list-items and favorites item container when hasSearchText$ is false", () => { + // Ensure vaultState is null (not Empty, NoResults, or DeactivatedOrg) + itemsSvc.emptyVault$.next(false); + itemsSvc.noFilteredResults$.next(false); + itemsSvc.showDeactivatedOrg$.next(false); + itemsSvc.hasSearchText$.next(false); + loadingSvc.loading$.next(false); + + const fixture = TestBed.createComponent(VaultComponent); + component = fixture.componentInstance; + + const readySubject$ = component["readySubject"]; + const allFilters$ = filtersSvc.allFilters$ as unknown as Subject; + + // Unblock loading + readySubject$.next(true); + allFilters$.next({}); + fixture.detectChanges(); + + const autofillElement = fixture.debugElement.query(By.css("app-autofill-vault-list-items")); + expect(autofillElement).toBeTruthy(); + + const favoritesElement = fixture.debugElement.query(By.css("#favorites")); + expect(favoritesElement).toBeTruthy(); + }); + + it("does set the title for allItems container to allItems when hasSearchText$ and numberOfAppliedFilters$ are false and 0 respectively", () => { + // Ensure vaultState is null (not Empty, NoResults, or DeactivatedOrg) + itemsSvc.emptyVault$.next(false); + itemsSvc.noFilteredResults$.next(false); + itemsSvc.showDeactivatedOrg$.next(false); + itemsSvc.hasSearchText$.next(false); + filtersSvc.numberOfAppliedFilters$.next(0); + loadingSvc.loading$.next(false); + + const fixture = TestBed.createComponent(VaultComponent); + component = fixture.componentInstance; + + const readySubject$ = component["readySubject"]; + const allFilters$ = filtersSvc.allFilters$ as unknown as Subject; + + // Unblock loading + readySubject$.next(true); + allFilters$.next({}); + fixture.detectChanges(); + + const allItemsElement = fixture.debugElement.query(By.css("#allItems")); + const allItemsTitle = allItemsElement.componentInstance.title(); + expect(allItemsTitle).toBe("allItems"); + }); + + it("does set the title for allItems container to searchResults when hasSearchText$ is true", () => { + // Ensure vaultState is null (not Empty, NoResults, or DeactivatedOrg) + itemsSvc.emptyVault$.next(false); + itemsSvc.noFilteredResults$.next(false); + itemsSvc.showDeactivatedOrg$.next(false); + itemsSvc.hasSearchText$.next(true); + loadingSvc.loading$.next(false); + + const fixture = TestBed.createComponent(VaultComponent); + component = fixture.componentInstance; + + const readySubject$ = component["readySubject"]; + const allFilters$ = filtersSvc.allFilters$ as unknown as Subject; + + // Unblock loading + readySubject$.next(true); + allFilters$.next({}); + fixture.detectChanges(); + + const allItemsElement = fixture.debugElement.query(By.css("#allItems")); + const allItemsTitle = allItemsElement.componentInstance.title(); + expect(allItemsTitle).toBe("searchResults"); + }); + + it("does set the title for allItems container to items when numberOfAppliedFilters$ is > 0", fakeAsync(() => { + // Ensure vaultState is null (not Empty, NoResults, or DeactivatedOrg) + itemsSvc.emptyVault$.next(false); + itemsSvc.noFilteredResults$.next(false); + itemsSvc.showDeactivatedOrg$.next(false); + itemsSvc.hasSearchText$.next(false); + filtersSvc.numberOfAppliedFilters$.next(1); + loadingSvc.loading$.next(false); + + const fixture = TestBed.createComponent(VaultComponent); + component = fixture.componentInstance; + + const readySubject$ = component["readySubject"]; + const allFilters$ = filtersSvc.allFilters$ as unknown as Subject; + + // Unblock loading + readySubject$.next(true); + allFilters$.next({}); + fixture.detectChanges(); + + const allItemsElement = fixture.debugElement.query(By.css("#allItems")); + const allItemsTitle = allItemsElement.componentInstance.title(); + expect(allItemsTitle).toBe("items"); + })); + + describe("AutoConfirmExtensionSetupDialog", () => { + beforeEach(() => { + autoConfirmDialogSpy.mockClear(); + }); + + it("opens dialog when canManage is true and showBrowserNotification is undefined", fakeAsync(() => { + autoConfirmSvc.canManageAutoConfirm$.mockReturnValue(of(true)); + autoConfirmSvc.configuration$.mockReturnValue( + of({ + enabled: false, + showSetupDialog: true, + showBrowserNotification: undefined, + }), + ); + + const fixture = TestBed.createComponent(VaultComponent); + const component = fixture.componentInstance; + + void component.ngOnInit(); + tick(); + + expect(autoConfirmDialogSpy).toHaveBeenCalledWith(expect.any(Object)); + })); + + it("does not open dialog when showBrowserNotification is false", fakeAsync(() => { + autoConfirmSvc.canManageAutoConfirm$.mockReturnValue(of(true)); + autoConfirmSvc.configuration$.mockReturnValue( + of({ + enabled: false, + showSetupDialog: true, + showBrowserNotification: false, + }), + ); + + const fixture = TestBed.createComponent(VaultComponent); + const component = fixture.componentInstance; + + void component.ngOnInit(); + tick(); + + expect(autoConfirmDialogSpy).not.toHaveBeenCalled(); + })); + + it("does not open dialog when showBrowserNotification is true", fakeAsync(() => { + autoConfirmSvc.canManageAutoConfirm$.mockReturnValue(of(true)); + autoConfirmSvc.configuration$.mockReturnValue( + of({ + enabled: true, + showSetupDialog: true, + showBrowserNotification: true, + }), + ); + + const fixture = TestBed.createComponent(VaultComponent); + const component = fixture.componentInstance; + + void component.ngOnInit(); + tick(); + + expect(autoConfirmDialogSpy).not.toHaveBeenCalled(); + })); + + it("does not open dialog when canManage is false even if showBrowserNotification is undefined", fakeAsync(() => { + autoConfirmSvc.canManageAutoConfirm$.mockReturnValue(of(false)); + autoConfirmSvc.configuration$.mockReturnValue( + of({ + enabled: false, + showSetupDialog: true, + showBrowserNotification: undefined, + }), + ); + + const fixture = TestBed.createComponent(VaultComponent); + const component = fixture.componentInstance; + + void component.ngOnInit(); + tick(); + + expect(autoConfirmDialogSpy).not.toHaveBeenCalled(); + })); + }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault/vault.component.ts similarity index 76% rename from apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts rename to apps/browser/src/vault/popup/components/vault/vault.component.ts index 30d1d21abfb..281abc5f180 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault.component.ts @@ -1,30 +1,34 @@ import { LiveAnnouncer } from "@angular/cdk/a11y"; -import { CdkVirtualScrollableElement, ScrollingModule } from "@angular/cdk/scrolling"; +import { ScrollingModule } from "@angular/cdk/scrolling"; import { CommonModule } from "@angular/common"; -import { AfterViewInit, Component, DestroyRef, OnDestroy, OnInit, ViewChild } from "@angular/core"; +import { Component, DestroyRef, effect, inject, OnDestroy, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router, RouterModule } from "@angular/router"; import { + BehaviorSubject, combineLatest, distinctUntilChanged, filter, firstValueFrom, - from, map, Observable, shareReplay, switchMap, take, 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, + AutoConfirmState, + AutomaticUserConfirmationService, +} from "@bitwarden/auto-confirm"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; @@ -41,6 +45,8 @@ import { ButtonModule, DialogService, NoItemsModule, + ScrollLayoutService, + ToastService, TypographyModule, } from "@bitwarden/components"; import { @@ -50,8 +56,6 @@ import { } from "@bitwarden/vault"; import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component"; -import { BrowserApi } from "../../../../platform/browser/browser-api"; -import BrowserPopupUtils from "../../../../platform/browser/browser-popup-utils"; 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"; @@ -67,10 +71,10 @@ import { VaultLoadingSkeletonComponent } from "../vault-loading-skeleton/vault-l import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component"; import { - NewItemDropdownV2Component, + NewItemDropdownComponent, NewItemInitialValues, -} from "./new-item-dropdown/new-item-dropdown-v2.component"; -import { VaultHeaderV2Component } from "./vault-header/vault-header-v2.component"; +} from "./new-item-dropdown/new-item-dropdown.component"; +import { VaultHeaderComponent } from "./vault-header/vault-header.component"; import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } from "."; @@ -86,7 +90,7 @@ type VaultState = UnionOfValues; // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-vault", - templateUrl: "vault-v2.component.html", + templateUrl: "vault.component.html", imports: [ BlockedInjectionBanner, PopupPageComponent, @@ -99,9 +103,9 @@ type VaultState = UnionOfValues; AutofillVaultListItemsComponent, VaultListItemsContainerComponent, ButtonModule, - NewItemDropdownV2Component, + NewItemDropdownComponent, ScrollingModule, - VaultHeaderV2Component, + VaultHeaderComponent, AtRiskPasswordCalloutComponent, SpotlightComponent, RouterModule, @@ -112,11 +116,7 @@ type VaultState = UnionOfValues; ], providers: [{ provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }], }) -export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @ViewChild(CdkVirtualScrollableElement) virtualScrollElement?: CdkVirtualScrollableElement; - +export class VaultComponent implements OnInit, OnDestroy { NudgeType = NudgeType; cipherType = CipherType; private activeUserId$ = this.accountService.activeAccount$.pipe(getUserId); @@ -154,18 +154,15 @@ export class VaultV2Component implements OnInit, AfterViewInit, 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 readonly hasSearchText$ = this.vaultPopupItemsService.hasSearchText$; + protected readonly numberOfAppliedFilters$ = + this.vaultPopupListFiltersService.numberOfAppliedFilters$; + protected filteredCiphers$ = this.vaultPopupItemsService.filteredCiphers$; protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$; protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$; protected allFilters$ = this.vaultPopupListFiltersService.allFilters$; @@ -173,38 +170,39 @@ export class VaultV2Component implements OnInit, AfterViewInit, 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 }), ); @@ -212,20 +210,14 @@ export class VaultV2Component implements OnInit, AfterViewInit, 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(), @@ -267,7 +259,8 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { private introCarouselService: IntroCarouselService, private nudgesService: NudgesService, private router: Router, - private vaultProfileService: VaultProfileService, + private autoConfirmService: AutomaticUserConfirmationService, + private toastService: ToastService, private billingAccountService: BillingAccountProfileStateService, private liveAnnouncer: LiveAnnouncer, private i18nService: I18nService, @@ -299,16 +292,21 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { }); } - ngAfterViewInit(): void { - if (this.virtualScrollElement) { - // The filters component can cause the size of the virtual scroll element to change, - // which can cause the scroll position to be land in the wrong spot. To fix this, - // wait until all filters are populated before restoring the scroll position. - this.allFilters$.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe(() => { - this.vaultScrollPositionService.start(this.virtualScrollElement!); + private readonly scrollLayout = inject(ScrollLayoutService); + + private readonly _scrollPositionEffect = effect((onCleanup) => { + const sub = combineLatest([this.scrollLayout.scrollableRef$, this.allFilters$, this.loading$]) + .pipe( + filter(([ref, _filters, loading]) => !!ref && !loading), + take(1), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(([ref]) => { + this.vaultScrollPositionService.start(ref!.nativeElement); }); - } - } + + onCleanup(() => sub.unsubscribe()); + }); async ngOnInit() { this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); @@ -329,6 +327,36 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { }); }); + const autoConfirmState$ = this.autoConfirmService.configuration$(this.activeUserId); + + combineLatest([ + this.autoConfirmService.canManageAutoConfirm$(this.activeUserId), + autoConfirmState$, + ]) + .pipe( + filter(([canManage, state]) => canManage && state.showBrowserNotification === undefined), + take(1), + switchMap(() => AutoConfirmExtensionSetupDialogComponent.open(this.dialogService).closed), + withLatestFrom(autoConfirmState$, this.accountService.activeAccount$.pipe(getUserId)), + switchMap(([result, state, userId]) => { + const newState: AutoConfirmState = { + ...state, + enabled: result ?? false, + showBrowserNotification: !result, + }; + + if (result) { + this.toastService.showToast({ + message: this.i18nService.t("autoConfirmEnabled"), + variant: "success", + }); + } + + return this.autoConfirmService.upsert(userId, newState); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); await this.vaultItemsTransferService.enforceOrganizationDataOwnership(this.activeUserId); this.readySubject.next(true); @@ -340,9 +368,6 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { async navigateToImport() { await this.router.navigate(["/import"]); - if (await BrowserApi.isPopupOpen()) { - await BrowserPopupUtils.openCurrentPagePopout(window); - } } async dismissVaultNudgeSpotlight(type: NudgeType) { diff --git a/apps/browser/src/vault/popup/components/vault/view/view.component.html b/apps/browser/src/vault/popup/components/vault/view/view.component.html new file mode 100644 index 00000000000..a3d65522022 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault/view/view.component.html @@ -0,0 +1,56 @@ + + + + @if (cipher?.isArchived && (archiveFlagEnabled$ | async)) { + + {{ "archived" | i18n }} + + } + + + + + @if (cipher) { + + } + + + @if (!cipher.isDeleted) { + + } + @if (cipher.isDeleted && cipher.permissions.restore) { + + } + + @if ((archiveFlagEnabled$ | async) && cipher.isArchived && !cipher.isDeleted) { + + } + @if ((userCanArchive$ | async) && cipher.canBeArchived) { + + } + @if (canDeleteCipher$ | async) { + + } + + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault/view/view.component.spec.ts similarity index 65% rename from apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/view/view.component.spec.ts index 3d4fdb2e9f9..5c94af0205d 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/view/view.component.spec.ts @@ -1,9 +1,13 @@ -import { ComponentFixture, fakeAsync, flush, TestBed } from "@angular/core/testing"; +import { ComponentFixture, fakeAsync, flush, TestBed, tick } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { ActivatedRoute, Router } from "@angular/router"; import { mock } from "jest-mock-extended"; import { of, Subject } from "rxjs"; +import { CollectionService } from "@bitwarden/admin-console/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AUTOFILL_ID, @@ -11,37 +15,49 @@ import { COPY_USERNAME_ID, COPY_VERIFICATION_CODE_ID, } from "@bitwarden/common/autofill/constants"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { EventType } from "@bitwarden/common/enums"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { TaskService } from "@bitwarden/common/vault/tasks"; import { DialogService, ToastService } from "@bitwarden/components"; -import { CopyCipherFieldService, PasswordRepromptService } from "@bitwarden/vault"; +import { + ArchiveCipherUtilitiesService, + CopyCipherFieldService, + PasswordRepromptService, +} from "@bitwarden/vault"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; import { VaultPopupScrollPositionService } from "../../../services/vault-popup-scroll-position.service"; -import { VaultPopupAutofillService } from "./../../../services/vault-popup-autofill.service"; -import { ViewV2Component } from "./view-v2.component"; +import { ViewComponent } from "./view.component"; // 'qrcode-parser' is used by `BrowserTotpCaptureService` but is an es6 module that jest can't compile. // Mock the entire module here to prevent jest from throwing an error. I wasn't able to find a way to mock the // `BrowserTotpCaptureService` where jest would not load the file in the first place. jest.mock("qrcode-parser", () => {}); -describe("ViewV2Component", () => { - let component: ViewV2Component; - let fixture: ComponentFixture; +describe("ViewComponent", () => { + let component: ViewComponent; + let fixture: ComponentFixture; const params$ = new Subject(); const mockNavigate = jest.fn(); const collect = jest.fn().mockResolvedValue(null); @@ -62,7 +78,10 @@ describe("ViewV2Component", () => { username: "test-username", password: "test-password", totp: "123", + uris: ["https://example.com"], }, + permissions: {}, + card: {}, } as unknown as CipherView; const mockPasswordRepromptService = { @@ -84,6 +103,8 @@ describe("ViewV2Component", () => { softDeleteWithServer: jest.fn().mockResolvedValue(undefined), }; + const cipherArchiveService = mock(); + beforeEach(async () => { mockCipherService.cipherViews$.mockClear(); mockCipherService.deleteWithServer.mockClear(); @@ -97,9 +118,13 @@ describe("ViewV2Component", () => { back.mockClear(); showToast.mockClear(); showPasswordPrompt.mockClear(); + cipherArchiveService.hasArchiveFlagEnabled$ = of(true); + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + cipherArchiveService.archiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData); + cipherArchiveService.unarchiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData); await TestBed.configureTestingModule({ - imports: [ViewV2Component], + imports: [ViewComponent], providers: [ { provide: Router, useValue: { navigate: mockNavigate } }, { provide: CipherService, useValue: mockCipherService }, @@ -131,7 +156,7 @@ describe("ViewV2Component", () => { { provide: CipherAuthorizationService, useValue: { - canDeleteCipher$: jest.fn().mockReturnValue(true), + canDeleteCipher$: jest.fn().mockReturnValue(of(true)), }, }, { @@ -142,6 +167,61 @@ describe("ViewV2Component", () => { provide: PasswordRepromptService, useValue: mockPasswordRepromptService, }, + { + provide: CipherArchiveService, + useValue: cipherArchiveService, + }, + { + provide: OrganizationService, + useValue: mock(), + }, + { + provide: CollectionService, + useValue: mock(), + }, + { + provide: FolderService, + useValue: mock(), + }, + { + provide: TaskService, + useValue: mock(), + }, + { + provide: ApiService, + useValue: mock(), + }, + { + provide: EnvironmentService, + useValue: { + environment$: of({ + getIconsUrl: () => "https://example.com", + }), + }, + }, + { + provide: DomainSettingsService, + useValue: { + showFavicons$: of(true), + }, + }, + { + provide: BillingAccountProfileStateService, + useValue: { + hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)), + }, + }, + { + provide: ArchiveCipherUtilitiesService, + useValue: { + archiveCipher: jest.fn().mockResolvedValue(null), + unarchiveCipher: jest.fn().mockResolvedValue(null), + }, + }, + { + provide: CipherRiskService, + useValue: mock(), + }, ], }) .overrideProvider(DialogService, { @@ -151,9 +231,10 @@ describe("ViewV2Component", () => { }) .compileComponents(); - fixture = TestBed.createComponent(ViewV2Component); + fixture = TestBed.createComponent(ViewComponent); component = fixture.componentInstance; fixture.detectChanges(); + (component as any).showFooter$ = of(true); }); describe("queryParams", () => { @@ -352,6 +433,105 @@ describe("ViewV2Component", () => { })); }); + describe("archive button", () => { + it("shows the archive button when the user can archive and the cipher can be archived", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(true)); + component.cipher = { ...mockCipher, canBeArchived: true } as CipherView; + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeTruthy(); + })); + + it("does not show the archive button when the user cannot archive", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(false)); + component.cipher = { ...mockCipher, canBeArchived: true, isDeleted: false } as CipherView; + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + + it("does not show the archive button when the cipher cannot be archived", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(true)); + component.cipher = { ...mockCipher, archivedDate: new Date(), edit: true } as CipherView; + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + }); + + describe("unarchive button", () => { + it("shows the unarchive button when the cipher is archived", fakeAsync(() => { + component.cipher = { ...mockCipher, isArchived: true, isDeleted: false } as CipherView; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeTruthy(); + })); + + it("does not show the unarchive button when the cipher is not archived", fakeAsync(() => { + component.cipher = { ...mockCipher, archivedDate: undefined } as CipherView; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeFalsy(); + })); + + it("does not show the unarchive button when the cipher is deleted", fakeAsync(() => { + component.cipher = { ...mockCipher, isArchived: true, isDeleted: true } as CipherView; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeFalsy(); + })); + }); + + describe("archive", () => { + beforeEach(() => { + component.cipher = { ...mockCipher, canBeArchived: true } as CipherView; + }); + + it("calls archive service to archive the cipher", async () => { + await component.archive(); + + expect(component["archiveCipherUtilsService"].archiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "122-333-444" }), + true, + ); + }); + }); + + describe("unarchive", () => { + it("calls archive service to unarchive the cipher", async () => { + component.cipher = { ...mockCipher, isArchived: true } as CipherView; + + await component.unarchive(); + + expect(component["archiveCipherUtilsService"].unarchiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "122-333-444" }), + ); + }); + }); + describe("delete", () => { beforeEach(() => { component.cipher = mockCipher; @@ -477,4 +657,44 @@ describe("ViewV2Component", () => { }); }); }); + + describe("archived badge", () => { + it("shows archived badge if the cipher is archived", fakeAsync(() => { + component.cipher = { ...mockCipher, isArchived: true } as CipherView; + mockCipherService.cipherViews$.mockImplementationOnce(() => + of([ + { + ...mockCipher, + isArchived: true, + }, + ]), + ); + + params$.next({ action: "view", cipherId: mockCipher.id }); + + flush(); + + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector("span[bitBadge]"); + expect(badge).toBeTruthy(); + })); + + it("does not show archived badge if the cipher is not archived", () => { + component.cipher = { ...mockCipher, isArchived: false } as CipherView; + mockCipherService.cipherViews$.mockImplementationOnce(() => + of([ + { + ...mockCipher, + archivedDate: new Date(), + }, + ]), + ); + + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector("span[bitBadge]"); + expect(badge).toBeFalsy(); + }); + }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault/view/view.component.ts similarity index 86% rename from apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts rename to apps/browser/src/vault/popup/components/vault/view/view.component.ts index 1dea91c0b9f..d63cd5920a1 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/view/view.component.ts @@ -7,9 +7,9 @@ import { FormsModule } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { firstValueFrom, Observable, switchMap, of, map } from "rxjs"; -import { CollectionView } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -25,6 +25,7 @@ import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; @@ -34,6 +35,7 @@ import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cip import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { AsyncActionsModule, + BadgeModule, ButtonModule, CalloutModule, DialogService, @@ -42,6 +44,7 @@ import { ToastService, } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, ChangeLoginPasswordService, CipherViewComponent, CopyCipherFieldService, @@ -53,16 +56,16 @@ import { sendExtensionMessage } from "../../../../../autofill/utils/index"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; 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"; +import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component"; import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service"; import { BrowserViewPasswordHistoryService } from "../../../services/browser-view-password-history.service"; +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; import { VaultPopupScrollPositionService } from "../../../services/vault-popup-scroll-position.service"; import { closeViewVaultItemPopout, VaultPopoutType } from "../../../utils/vault-popout-window"; - -import { PopupFooterComponent } from "./../../../../../platform/popup/layout/popup-footer.component"; -import { PopupHeaderComponent } from "./../../../../../platform/popup/layout/popup-header.component"; -import { PopupPageComponent } from "./../../../../../platform/popup/layout/popup-page.component"; -import { VaultPopupAutofillService } from "./../../../services/vault-popup-autofill.service"; +import { ROUTES_AFTER_EDIT_DELETION } from "../add-edit/add-edit.component"; /** * The types of actions that can be triggered when loading the view vault item popout via the @@ -79,8 +82,8 @@ type LoadAction = // 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-view-v2", - templateUrl: "view-v2.component.html", + selector: "app-view", + templateUrl: "view.component.html", imports: [ CommonModule, SearchModule, @@ -95,6 +98,7 @@ type LoadAction = AsyncActionsModule, PopOutComponent, CalloutModule, + BadgeModule, ], providers: [ { provide: ViewPasswordHistoryService, useClass: BrowserViewPasswordHistoryService }, @@ -102,7 +106,7 @@ type LoadAction = { provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService }, ], }) -export class ViewV2Component { +export class ViewComponent { private activeUserId: UserId; headerText: string; @@ -112,8 +116,13 @@ export class ViewV2Component { collections$: Observable; loadAction: LoadAction; senderTabId?: number; + routeAfterDeletion?: ROUTES_AFTER_EDIT_DELETION; protected showFooter$: Observable; + protected userCanArchive$ = this.accountService.activeAccount$ + .pipe(getUserId) + .pipe(switchMap((userId) => this.archiveService.userCanArchive$(userId))); + protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$; constructor( private passwordRepromptService: PasswordRepromptService, @@ -131,6 +140,8 @@ export class ViewV2Component { protected cipherAuthorizationService: CipherAuthorizationService, private copyCipherFieldService: CopyCipherFieldService, private popupScrollPositionService: VaultPopupScrollPositionService, + private archiveService: CipherArchiveService, + private archiveCipherUtilsService: ArchiveCipherUtilitiesService, ) { this.subscribeToParams(); } @@ -141,6 +152,9 @@ export class ViewV2Component { switchMap(async (params) => { this.loadAction = params.action; this.senderTabId = params.senderTabId ? parseInt(params.senderTabId, 10) : undefined; + this.routeAfterDeletion = params.routeAfterDeletion + ? params.routeAfterDeletion + : undefined; this.activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(getUserId), @@ -220,7 +234,12 @@ export class ViewV2Component { return false; } void this.router.navigate(["/edit-cipher"], { - queryParams: { cipherId: this.cipher.id, type: this.cipher.type, isNew: false }, + queryParams: { + cipherId: this.cipher.id, + type: this.cipher.type, + isNew: false, + routeAfterDeletion: this.routeAfterDeletion, + }, }); return true; } @@ -272,6 +291,24 @@ export class ViewV2Component { }); }; + archive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.archiveCipher(this.cipher, true); + + if (!cipherResponse) { + return; + } + this.cipher.archivedDate = new Date(cipherResponse.archivedDate); + }; + + unarchive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.unarchiveCipher(this.cipher); + + if (!cipherResponse) { + return; + } + this.cipher.archivedDate = null; + }; + protected deleteCipher() { return this.cipher.isDeleted ? this.cipherService.deleteWithServer(this.cipher.id, this.activeUserId) diff --git a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts index 7ead8576b37..633a3b3295e 100644 --- a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts +++ b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts @@ -1,7 +1,7 @@ import { TestBed } from "@angular/core/testing"; import { RouterStateSnapshot } from "@angular/router"; -import { VaultV2Component } from "../components/vault-v2/vault-v2.component"; +import { VaultComponent } from "../components/vault/vault.component"; import { VaultPopupItemsService } from "../services/vault-popup-items.service"; import { VaultPopupListFiltersService } from "../services/vault-popup-list-filters.service"; @@ -42,7 +42,7 @@ describe("clearVaultStateGuard", () => { const nextState = { url } as RouterStateSnapshot; const result = TestBed.runInInjectionContext(() => - clearVaultStateGuard({} as VaultV2Component, null, null, nextState), + clearVaultStateGuard({} as VaultComponent, null, null, nextState), ); expect(result).toBe(true); @@ -56,7 +56,7 @@ describe("clearVaultStateGuard", () => { const nextState = { url } as RouterStateSnapshot; const result = TestBed.runInInjectionContext(() => - clearVaultStateGuard({} as VaultV2Component, null, null, nextState), + clearVaultStateGuard({} as VaultComponent, null, null, nextState), ); expect(result).toBe(true); @@ -67,7 +67,7 @@ describe("clearVaultStateGuard", () => { it("should not clear vault state when not changing states", () => { const result = TestBed.runInInjectionContext(() => - clearVaultStateGuard({} as VaultV2Component, null, null, null), + clearVaultStateGuard({} as VaultComponent, null, null, null), ); expect(result).toBe(true); diff --git a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts index 2a87db6e903..5258c7cd741 100644 --- a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts +++ b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts @@ -1,7 +1,7 @@ import { inject } from "@angular/core"; import { CanDeactivateFn } from "@angular/router"; -import { VaultV2Component } from "../components/vault-v2/vault-v2.component"; +import { VaultComponent } from "../components/vault/vault.component"; import { VaultPopupItemsService } from "../services/vault-popup-items.service"; import { VaultPopupListFiltersService } from "../services/vault-popup-list-filters.service"; @@ -10,8 +10,8 @@ import { VaultPopupListFiltersService } from "../services/vault-popup-list-filte * This ensures the search and filter state is reset when navigating between different tabs, * except viewing or editing a cipher. */ -export const clearVaultStateGuard: CanDeactivateFn = ( - component: VaultV2Component, +export const clearVaultStateGuard: CanDeactivateFn = ( + component: VaultComponent, currentRoute, currentState, nextState, diff --git a/apps/browser/src/vault/popup/services/browser-cipher-form-generation.service.ts b/apps/browser/src/vault/popup/services/browser-cipher-form-generation.service.ts index ecba9aa1413..9e67953c251 100644 --- a/apps/browser/src/vault/popup/services/browser-cipher-form-generation.service.ts +++ b/apps/browser/src/vault/popup/services/browser-cipher-form-generation.service.ts @@ -7,7 +7,7 @@ import { firstValueFrom } from "rxjs"; import { DialogService } from "@bitwarden/components"; import { CipherFormGenerationService } from "@bitwarden/vault"; -import { VaultGeneratorDialogComponent } from "../components/vault-v2/vault-generator-dialog/vault-generator-dialog.component"; +import { VaultGeneratorDialogComponent } from "../components/vault/vault-generator-dialog/vault-generator-dialog.component"; @Injectable() export class BrowserCipherFormGenerationService implements CipherFormGenerationService { diff --git a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts index 5818c6e32ff..94542009a89 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts @@ -378,8 +378,7 @@ describe("VaultPopupAutofillService", () => { expect(result).toBe(true); expect(mockCipher.login.uris).toHaveLength(1); expect(mockCipher.login.uris[0].uri).toBe(mockCurrentTab.url); - expect(mockCipherService.encrypt).toHaveBeenCalledWith(mockCipher, mockUserId); - expect(mockCipherService.updateWithServer).toHaveBeenCalledWith(mockEncryptedCipher); + expect(mockCipherService.updateWithServer).toHaveBeenCalledWith(mockCipher, mockUserId); }); it("should add a URI to the cipher when there are no existing URIs", async () => { diff --git a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts index 6feeec29efc..025088e029e 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts @@ -426,8 +426,7 @@ export class VaultPopupAutofillService { const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); - const encCipher = await this.cipherService.encrypt(cipher, activeUserId); - await this.cipherService.updateWithServer(encCipher); + await this.cipherService.updateWithServer(cipher, activeUserId); this.messagingService.send("editedCipher"); return true; } catch { diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts index 513e159f7aa..845dfd6f4b1 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts @@ -3,8 +3,9 @@ import { TestBed } from "@angular/core/testing"; import { mock } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom, of, take, timeout } from "rxjs"; -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; @@ -68,7 +69,7 @@ describe("VaultPopupItemsService", () => { const accountServiceMock = mockAccountServiceWith(userId); const configServiceMock = mock(); const cipherArchiveServiceMock = mock(); - cipherArchiveServiceMock.userCanArchive$.mockReturnValue(of(true)); + cipherArchiveServiceMock.hasArchiveFlagEnabled$ = of(true); const restrictedItemTypesService = { restricted$: new BehaviorSubject([]), @@ -322,6 +323,25 @@ describe("VaultPopupItemsService", () => { }); }); + describe("filteredCiphers$", () => { + it("should filter filteredCipher$ down to search term", (done) => { + const cipherList = Object.values(allCiphers); + const searchText = "Login"; + + searchService.searchCiphers.mockImplementation(async () => { + return cipherList.filter((cipher) => { + return cipher.name.includes(searchText); + }); + }); + + service.filteredCiphers$.subscribe((ciphers) => { + // There are 10 ciphers but only 3 with "Login" in the name + expect(ciphers.length).toBe(3); + done(); + }); + }); + }); + describe("favoriteCiphers$", () => { it("should exclude autofill ciphers", (done) => { service.favoriteCiphers$.subscribe((ciphers) => { diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index 321d7936806..93f2734e6b8 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -135,24 +135,23 @@ export class VaultPopupItemsService { shareReplay({ refCount: true, bufferSize: 1 }), ); - private userCanArchive$ = this.activeUserId$.pipe( - switchMap((userId) => { - return this.cipherArchiveService.userCanArchive$(userId); - }), - ); - private _activeCipherList$: Observable = this._allDecryptedCiphers$.pipe( switchMap((ciphers) => - combineLatest([this.organizations$, this.decryptedCollections$, this.userCanArchive$]).pipe( - map(([organizations, collections, canArchive]) => { + combineLatest([ + this.organizations$, + this.decryptedCollections$, + this.cipherArchiveService.hasArchiveFlagEnabled$, + ]).pipe( + map(([organizations, collections, archiveFlag]) => { const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org])); const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col])); return ciphers .filter( (c) => !CipherViewLikeUtils.isDeleted(c) && - (!canArchive || !CipherViewLikeUtils.isArchived(c)), + (!archiveFlag || !CipherViewLikeUtils.isArchived(c)), ) + .map((cipher) => { (cipher as PopupCipherViewLike).collections = cipher.collectionIds?.map( (colId) => collectionMap[colId as CollectionId], @@ -201,6 +200,15 @@ export class VaultPopupItemsService { shareReplay({ refCount: true, bufferSize: 1 }), ); + /** + * List of ciphers that are filtered using filters and search. + * Includes favorite ciphers and ciphers currently suggested for autofill. + * Ciphers are sorted by name. + */ + filteredCiphers$: Observable = this._filteredCipherList$.pipe( + shareReplay({ refCount: false, bufferSize: 1 }), + ); + /** * List of ciphers that can be used for autofill on the current tab. Includes cards and/or identities * if enabled in the vault settings. Ciphers are sorted by type, then by last used date, then by name. diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts index 866c5dd2e89..52703284679 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -3,12 +3,13 @@ import { TestBed, discardPeriodicTasks, fakeAsync, tick } from "@angular/core/te import { FormBuilder } from "@angular/forms"; import { BehaviorSubject, skipWhile } from "rxjs"; -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; import * as vaultFilterSvc from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; @@ -437,7 +438,7 @@ describe("VaultPopupListFiltersService", () => { describe("folders$", () => { it('returns no folders when "No Folder" is the only option', (done) => { - folderViews$.next([{ id: null, name: "No Folder" }]); + folderViews$.next([{ id: "", name: "No Folder" }]); service.folders$.subscribe((folders) => { expect(folders).toEqual([]); @@ -447,7 +448,7 @@ describe("VaultPopupListFiltersService", () => { it('moves "No Folder" to the end of the list', (done) => { folderViews$.next([ - { id: null, name: "No Folder" }, + { id: "", name: "No Folder" }, { id: "2345", name: "Folder 2" }, { id: "1234", name: "Folder 1" }, ]); diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts index 439353cab50..3b220e4719c 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts @@ -14,17 +14,17 @@ import { take, } from "rxjs"; -import { - CollectionService, - CollectionTypes, - CollectionView, -} from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; import { DynamicTreeNode } from "@bitwarden/angular/vault/vault-filter/models/dynamic-tree-node.model"; import { sortDefaultCollections } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { + CollectionView, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -387,7 +387,7 @@ export class VaultPopupListFiltersService { FolderView[], PopupCipherViewLike[], ] => { - if (folders.length === 1 && folders[0].id === null) { + if (folders.length === 1 && !folders[0].id) { // Do not display folder selections when only the "no folder" option is available. return [filters as PopupListFilter, [], cipherViews]; } @@ -396,7 +396,7 @@ export class VaultPopupListFiltersService { folders.sort(Utils.getSortFunction(this.i18nService, "name")); let arrangedFolders = folders; - const noFolder = folders.find((f) => f.id === null); + const noFolder = folders.find((f) => !f.id); if (noFolder) { // Update `name` of the "no folder" option to "Items with no folder" @@ -406,7 +406,7 @@ export class VaultPopupListFiltersService { }; // Move the "no folder" option to the end of the list - arrangedFolders = [...folders.filter((f) => f.id !== null), updatedNoFolder]; + arrangedFolders = [...folders.filter((f) => f.id), updatedNoFolder]; } return [filters as PopupListFilter, arrangedFolders, cipherViews]; }, @@ -545,11 +545,7 @@ export class VaultPopupListFiltersService { // When the organization filter changes and a folder is already selected, // reset the folder filter if the folder does not belong to the new organization filter - if ( - currentFilters.folder && - currentFilters.folder.id !== null && - organization.id !== MY_VAULT_ID - ) { + if (currentFilters.folder && currentFilters.folder.id && organization.id !== MY_VAULT_ID) { // Get all ciphers that belong to the new organization const orgCiphers = this.cipherViews.filter((c) => c.organizationId === organization.id); diff --git a/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.spec.ts index 562375f8f85..af21f664f2d 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.spec.ts @@ -1,4 +1,3 @@ -import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling"; import { fakeAsync, TestBed, tick } from "@angular/core/testing"; import { NavigationEnd, Router } from "@angular/router"; import { Subject, Subscription } from "rxjs"; @@ -66,21 +65,18 @@ describe("VaultPopupScrollPositionService", () => { }); describe("start", () => { - const elementScrolled$ = new Subject(); - const focus = jest.fn(); - const nativeElement = { - scrollTop: 0, - querySelector: jest.fn(() => ({ focus })), - addEventListener: jest.fn(), - style: { - visibility: "", - }, - }; - const virtualElement = { - elementScrolled: () => elementScrolled$, - getElementRef: () => ({ nativeElement }), - scrollTo: jest.fn(), - } as unknown as CdkVirtualScrollableElement; + let scrollElement: HTMLElement; + + beforeEach(() => { + scrollElement = document.createElement("div"); + + (scrollElement as any).scrollTo = jest.fn(function scrollTo(opts: { top?: number }) { + if (opts?.top != null) { + (scrollElement as any).scrollTop = opts.top; + } + }); + (scrollElement as any).scrollTop = 0; + }); afterEach(() => { // remove the actual subscription created by `.subscribe` @@ -89,47 +85,55 @@ describe("VaultPopupScrollPositionService", () => { describe("initial scroll position", () => { beforeEach(() => { - (virtualElement.scrollTo as jest.Mock).mockClear(); - nativeElement.querySelector.mockClear(); + ((scrollElement as any).scrollTo as jest.Mock).mockClear(); }); it("does not scroll when `scrollPosition` is null", () => { service["scrollPosition"] = null; - service.start(virtualElement); + service.start(scrollElement); - expect(virtualElement.scrollTo).not.toHaveBeenCalled(); + expect((scrollElement as any).scrollTo).not.toHaveBeenCalled(); }); - it("scrolls the virtual element to `scrollPosition`", fakeAsync(() => { + it("scrolls the element to `scrollPosition` (async via setTimeout)", fakeAsync(() => { service["scrollPosition"] = 500; - nativeElement.scrollTop = 500; - service.start(virtualElement); + service.start(scrollElement); tick(); - expect(virtualElement.scrollTo).toHaveBeenCalledWith({ behavior: "instant", top: 500 }); + expect((scrollElement as any).scrollTo).toHaveBeenCalledWith({ + behavior: "instant", + top: 500, + }); + expect((scrollElement as any).scrollTop).toBe(500); })); }); describe("scroll listener", () => { it("unsubscribes from any existing subscription", () => { - service.start(virtualElement); + service.start(scrollElement); expect(unsubscribe).toHaveBeenCalled(); }); - it("subscribes to `elementScrolled`", fakeAsync(() => { - virtualElement.measureScrollOffset = jest.fn(() => 455); + it("stores scrollTop on subsequent scroll events (skips first)", fakeAsync(() => { + service["scrollPosition"] = null; - service.start(virtualElement); + service.start(scrollElement); - elementScrolled$.next(null); // first subscription is skipped by `skip(1)` - elementScrolled$.next(null); + // First scroll event is intentionally ignored (equivalent to old skip(1)). + (scrollElement as any).scrollTop = 111; + scrollElement.dispatchEvent(new Event("scroll")); + tick(); + + expect(service["scrollPosition"]).toBeNull(); + + // Second scroll event should persist. + (scrollElement as any).scrollTop = 455; + scrollElement.dispatchEvent(new Event("scroll")); tick(); - expect(virtualElement.measureScrollOffset).toHaveBeenCalledTimes(1); - expect(virtualElement.measureScrollOffset).toHaveBeenCalledWith("top"); expect(service["scrollPosition"]).toBe(455); })); }); diff --git a/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.ts b/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.ts index 5bfe0ec9331..7261fdd6633 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.ts @@ -1,8 +1,7 @@ -import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling"; import { inject, Injectable } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; -import { filter, skip, Subscription } from "rxjs"; +import { filter, fromEvent, Subscription } from "rxjs"; @Injectable({ providedIn: "root", @@ -31,24 +30,25 @@ export class VaultPopupScrollPositionService { } /** Scrolls the user to the stored scroll position and starts tracking scroll of the page. */ - start(virtualScrollElement: CdkVirtualScrollableElement) { + start(scrollElement: HTMLElement) { if (this.hasScrollPosition()) { // Use `setTimeout` to scroll after rendering is complete setTimeout(() => { - virtualScrollElement.scrollTo({ top: this.scrollPosition!, behavior: "instant" }); + scrollElement.scrollTo({ top: this.scrollPosition!, behavior: "instant" }); }); } this.scrollSubscription?.unsubscribe(); // Skip the first scroll event to avoid settings the scroll from the above `scrollTo` call - this.scrollSubscription = virtualScrollElement - ?.elementScrolled() - .pipe(skip(1)) - .subscribe(() => { - const offset = virtualScrollElement.measureScrollOffset("top"); - this.scrollPosition = offset; - }); + let skipped = false; + this.scrollSubscription = fromEvent(scrollElement, "scroll").subscribe(() => { + if (!skipped) { + skipped = true; + return; + } + this.scrollPosition = scrollElement.scrollTop; + }); } /** Stops the scroll listener from updating the stored location. */ diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.html b/apps/browser/src/vault/popup/settings/appearance.component.html similarity index 84% rename from apps/browser/src/vault/popup/settings/appearance-v2.component.html rename to apps/browser/src/vault/popup/settings/appearance.component.html index b58316a8d64..d87c0640f52 100644 --- a/apps/browser/src/vault/popup/settings/appearance-v2.component.html +++ b/apps/browser/src/vault/popup/settings/appearance.component.html @@ -50,16 +50,18 @@ - + {{ "showQuickCopyActions" | i18n }} - - - - {{ "clickToAutofill" | i18n }} - - + @if (!simplifiedItemActionEnabled()) { + + + + {{ "clickToAutofill" | i18n }} + + + } diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/appearance.component.spec.ts similarity index 77% rename from apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts rename to apps/browser/src/vault/popup/settings/appearance.component.spec.ts index 9e1beab5787..465b78e232d 100644 --- a/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/appearance.component.spec.ts @@ -1,10 +1,12 @@ import { Component, Input } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { mock } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -20,7 +22,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co import { PopupSizeService } from "../../../platform/popup/layout/popup-size.service"; import { VaultPopupCopyButtonsService } from "../services/vault-popup-copy-buttons.service"; -import { AppearanceV2Component } from "./appearance-v2.component"; +import { AppearanceComponent } from "./appearance.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,9 +51,9 @@ class MockPopupPageComponent { @Input() loading: boolean; } -describe("AppearanceV2Component", () => { - let component: AppearanceV2Component; - let fixture: ComponentFixture; +describe("AppearanceComponent", () => { + let component: AppearanceComponent; + let fixture: ComponentFixture; const showFavicons$ = new BehaviorSubject(true); const enableBadgeCounter$ = new BehaviorSubject(true); @@ -59,7 +61,7 @@ describe("AppearanceV2Component", () => { const enableRoutingAnimation$ = new BehaviorSubject(true); const enableCompactMode$ = new BehaviorSubject(false); const showQuickCopyActions$ = new BehaviorSubject(false); - const clickItemsToAutofillVaultView$ = new BehaviorSubject(false); + const featureFlag$ = new BehaviorSubject(false); const setSelectedTheme = jest.fn().mockResolvedValue(undefined); const setShowFavicons = jest.fn().mockResolvedValue(undefined); const setEnableBadgeCounter = jest.fn().mockResolvedValue(undefined); @@ -78,11 +80,20 @@ describe("AppearanceV2Component", () => { setShowFavicons.mockClear(); setEnableBadgeCounter.mockClear(); setEnableRoutingAnimation.mockClear(); + setClickItemsToAutofillVaultView.mockClear(); + + const configService = mock(); + configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => { + if (flag === FeatureFlag.PM31039ItemActionInExtension) { + return featureFlag$.asObservable(); + } + return of(false); + }); await TestBed.configureTestingModule({ - imports: [AppearanceV2Component], + imports: [AppearanceComponent], providers: [ - { provide: ConfigService, useValue: mock() }, + { provide: ConfigService, useValue: configService }, { provide: PlatformUtilsService, useValue: mock() }, { provide: MessagingService, useValue: mock() }, { provide: I18nService, useValue: { t: (key: string) => key } }, @@ -114,13 +125,13 @@ describe("AppearanceV2Component", () => { { provide: VaultSettingsService, useValue: { - clickItemsToAutofillVaultView$, + clickItemsToAutofillVaultView$: of(false), setClickItemsToAutofillVaultView, }, }, ], }) - .overrideComponent(AppearanceV2Component, { + .overrideComponent(AppearanceComponent, { remove: { imports: [PopupHeaderComponent, PopupPageComponent], }, @@ -130,7 +141,7 @@ describe("AppearanceV2Component", () => { }) .compileComponents(); - fixture = TestBed.createComponent(AppearanceV2Component); + fixture = TestBed.createComponent(AppearanceComponent); component = fixture.componentInstance; fixture.detectChanges(); }); @@ -193,11 +204,40 @@ describe("AppearanceV2Component", () => { expect(mockWidthService.setWidth).toHaveBeenCalledWith("wide"); }); + }); - it("updates the click items to autofill vault view setting", () => { - component.appearanceForm.controls.clickItemsToAutofillVaultView.setValue(true); + describe("PM31039ItemActionInExtension feature flag", () => { + describe("when set to OFF", () => { + it("should show clickItemsToAutofillVaultView checkbox", () => { + featureFlag$.next(false); + fixture.detectChanges(); - expect(setClickItemsToAutofillVaultView).toHaveBeenCalledWith(true); + const checkbox = fixture.debugElement.query( + By.css('input[formControlName="clickItemsToAutofillVaultView"]'), + ); + expect(checkbox).not.toBeNull(); + }); + + it("should update the clickItemsToAutofillVaultView setting when changed", () => { + featureFlag$.next(false); + fixture.detectChanges(); + + component.appearanceForm.controls.clickItemsToAutofillVaultView.setValue(true); + + expect(setClickItemsToAutofillVaultView).toHaveBeenCalledWith(true); + }); + }); + + describe("when set to ON", () => { + it("should hide clickItemsToAutofillVaultView checkbox", () => { + featureFlag$.next(true); + fixture.detectChanges(); + + const checkbox = fixture.debugElement.query( + By.css('input[formControlName="clickItemsToAutofillVaultView"]'), + ); + expect(checkbox).toBeNull(); + }); }); }); }); diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts b/apps/browser/src/vault/popup/settings/appearance.component.ts similarity index 91% rename from apps/browser/src/vault/popup/settings/appearance-v2.component.ts rename to apps/browser/src/vault/popup/settings/appearance.component.ts index e6515ae7461..47aa1804efc 100644 --- a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/appearance.component.ts @@ -2,14 +2,16 @@ // @ts-strict-ignore import { CommonModule } from "@angular/common"; import { Component, DestroyRef, inject, OnInit } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; @@ -36,7 +38,7 @@ import { VaultPopupCopyButtonsService } from "../services/vault-popup-copy-butto // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - templateUrl: "./appearance-v2.component.html", + templateUrl: "./appearance.component.html", imports: [ CommonModule, JslibModule, @@ -52,11 +54,18 @@ import { VaultPopupCopyButtonsService } from "../services/vault-popup-copy-butto PermitCipherDetailsPopoverComponent, ], }) -export class AppearanceV2Component implements OnInit { +export class AppearanceComponent implements OnInit { private compactModeService = inject(PopupCompactModeService); private copyButtonsService = inject(VaultPopupCopyButtonsService); private popupSizeService = inject(PopupSizeService); private i18nService = inject(I18nService); + private configService = inject(ConfigService); + + /** Signal for the feature flag that controls simplified item action behavior */ + protected readonly simplifiedItemActionEnabled = toSignal( + this.configService.getFeatureFlag$(FeatureFlag.PM31039ItemActionInExtension), + { initialValue: false }, + ); appearanceForm = this.formBuilder.group({ enableFavicon: false, @@ -79,7 +88,7 @@ export class AppearanceV2Component implements OnInit { protected readonly widthOptions: Option[] = [ { label: this.i18nService.t("default"), value: "default" }, { label: this.i18nService.t("wide"), value: "wide" }, - { label: this.i18nService.t("extraWide"), value: "extra-wide" }, + { label: this.i18nService.t("narrow"), value: "narrow" }, ]; constructor( diff --git a/apps/browser/src/vault/popup/settings/archive.component.html b/apps/browser/src/vault/popup/settings/archive.component.html index a7b23dc5122..5024a22ff16 100644 --- a/apps/browser/src/vault/popup/settings/archive.component.html +++ b/apps/browser/src/vault/popup/settings/archive.component.html @@ -5,6 +5,21 @@ + @if (showSubscriptionEndedMessaging$ | async) { + +
+ +

{{ "premiumSubscriptionEnded" | i18n }}

+
+

+ {{ "archivePremiumRestart" | i18n }} +

+ +
+ } + @if (archivedCiphers$ | async; as archivedItems) { @if (archivedItems.length) { @@ -27,9 +42,23 @@
{{ cipher.name }} - @if (CipherViewLikeUtils.hasAttachments(cipher)) { - - } +
+ @if (cipher.organizationId) { + + } + @if (CipherViewLikeUtils.hasAttachments(cipher)) { + + } +
{{ CipherViewLikeUtils.subtitle(cipher) }} @@ -45,9 +74,20 @@ - + @if (userHasPremium$ | async) { + + } + @if (canAssignCollections$ | async) { + + } diff --git a/apps/browser/src/vault/popup/settings/archive.component.spec.ts b/apps/browser/src/vault/popup/settings/archive.component.spec.ts new file mode 100644 index 00000000000..bbb61b57a84 --- /dev/null +++ b/apps/browser/src/vault/popup/settings/archive.component.spec.ts @@ -0,0 +1,233 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { provideNoopAnimations } from "@angular/platform-browser/animations"; +import { Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { PopupRouterCacheService } from "@bitwarden/browser/platform/popup/view-cache/popup-router-cache.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +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 { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { ArchiveComponent } from "./archive.component"; + +// 'qrcode-parser' is used by `BrowserTotpCaptureService` but is an es6 module that jest can't compile. +// Mock the entire module here to prevent jest from throwing an error. I wasn't able to find a way to mock the +// `BrowserTotpCaptureService` where jest would not load the file in the first place. +jest.mock("qrcode-parser", () => {}); + +describe("ArchiveComponent", () => { + let component: ArchiveComponent; + let fixture: ComponentFixture; + + let hasOrganizations: jest.Mock; + let decryptedCollections$: jest.Mock; + let navigate: jest.Mock; + let showPasswordPrompt: jest.Mock; + let userHasPremium$: jest.Mock; + let archivedCiphers$: jest.Mock; + + beforeEach(async () => { + navigate = jest.fn(); + showPasswordPrompt = jest.fn().mockResolvedValue(true); + hasOrganizations = jest.fn().mockReturnValue(of(false)); + decryptedCollections$ = jest.fn().mockReturnValue(of([])); + userHasPremium$ = jest.fn().mockReturnValue(of(false)); + archivedCiphers$ = jest.fn().mockReturnValue(of([{ id: "cipher-1" }])); + + await TestBed.configureTestingModule({ + imports: [ArchiveComponent], + providers: [ + provideNoopAnimations(), + { provide: Router, useValue: { navigate } }, + { + provide: AccountService, + useValue: { activeAccount$: new BehaviorSubject({ id: "user-id" }) }, + }, + { provide: PasswordRepromptService, useValue: { showPasswordPrompt } }, + { + provide: OrganizationService, + useValue: { hasOrganizations, organizations$: () => of([]) }, + }, + { provide: CollectionService, useValue: { decryptedCollections$ } }, + { provide: DialogService, useValue: mock() }, + { provide: CipherService, useValue: mock() }, + { + provide: CipherArchiveService, + useValue: { + userHasPremium$, + archivedCiphers$, + userCanArchive$: jest.fn().mockReturnValue(of(true)), + showSubscriptionEndedMessaging$: jest.fn().mockReturnValue(of(false)), + }, + }, + { provide: ToastService, useValue: mock() }, + { provide: PopupRouterCacheService, useValue: mock() }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: LogService, useValue: mock() }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { + provide: EnvironmentService, + useValue: { + environment$: of({ + getIconsUrl: () => "https://icons.example.com", + }), + }, + }, + { + provide: DomainSettingsService, + useValue: { + showFavicons$: of(true), + }, + }, + { + provide: CipherAuthorizationService, + useValue: { + canDeleteCipher$: jest.fn().mockReturnValue(of(true)), + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ArchiveComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("canAssignCollections$", () => { + it("emits true when user has organizations and editable collections", (done) => { + hasOrganizations.mockReturnValue(of(true)); + decryptedCollections$.mockReturnValue(of([{ id: "col-1", readOnly: false }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it("emits false when user has no organizations", (done) => { + hasOrganizations.mockReturnValue(of(false)); + decryptedCollections$.mockReturnValue(of([{ id: "col-1", readOnly: false }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + + it("emits false when all collections are read-only", (done) => { + hasOrganizations.mockReturnValue(of(true)); + decryptedCollections$.mockReturnValue(of([{ id: "col-1", readOnly: true }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + }); + + describe("conditionallyNavigateToAssignCollections", () => { + const mockCipher = { + id: "cipher-1", + reprompt: 0, + } as CipherViewLike; + + it("navigates to assign-collections when reprompt is not required", async () => { + await component.conditionallyNavigateToAssignCollections(mockCipher); + + expect(navigate).toHaveBeenCalledWith(["/assign-collections"], { + queryParams: { cipherId: "cipher-1" }, + }); + }); + + it("prompts for password when reprompt is required", async () => { + const cipherWithReprompt = { ...mockCipher, reprompt: 1 }; + + await component.conditionallyNavigateToAssignCollections( + cipherWithReprompt as CipherViewLike, + ); + + expect(showPasswordPrompt).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(["/assign-collections"], { + queryParams: { cipherId: "cipher-1" }, + }); + }); + + it("does not navigate when password prompt is cancelled", async () => { + const cipherWithReprompt = { ...mockCipher, reprompt: 1 }; + showPasswordPrompt.mockResolvedValueOnce(false); + + await component.conditionallyNavigateToAssignCollections( + cipherWithReprompt as CipherViewLike, + ); + + expect(showPasswordPrompt).toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + }); + }); + + describe("clone menu option", () => { + const getBitMenuPanel = () => document.querySelector(".bit-menu-panel"); + + it("is shown when user has premium", async () => { + userHasPremium$.mockReturnValue(of(true)); + + const testFixture = TestBed.createComponent(ArchiveComponent); + testFixture.detectChanges(); + await testFixture.whenStable(); + + const menuTrigger = testFixture.debugElement.query(By.css('button[aria-haspopup="menu"]')); + expect(menuTrigger).toBeTruthy(); + (menuTrigger.nativeElement as HTMLButtonElement).click(); + testFixture.detectChanges(); + + const menuPanel = getBitMenuPanel(); + expect(menuPanel).toBeTruthy(); + + const menuButtons = menuPanel?.querySelectorAll("button[bitMenuItem]"); + const cloneButtonFound = Array.from(menuButtons || []).some( + (btn) => btn.textContent?.trim() === "clone", + ); + + expect(cloneButtonFound).toBe(true); + }); + + it("is not shown when user does not have premium", async () => { + userHasPremium$.mockReturnValue(of(false)); + + const testFixture = TestBed.createComponent(ArchiveComponent); + testFixture.detectChanges(); + await testFixture.whenStable(); + + const menuTrigger = testFixture.debugElement.query(By.css('button[aria-haspopup="menu"]')); + expect(menuTrigger).toBeTruthy(); + (menuTrigger.nativeElement as HTMLButtonElement).click(); + testFixture.detectChanges(); + + const menuPanel = getBitMenuPanel(); + expect(menuPanel).toBeTruthy(); + + const menuButtons = menuPanel?.querySelectorAll("button[bitMenuItem]"); + const cloneButtonFound = Array.from(menuButtons || []).some( + (btn) => btn.textContent?.trim() === "clone", + ); + + expect(cloneButtonFound).toBe(false); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/settings/archive.component.ts b/apps/browser/src/vault/popup/settings/archive.component.ts index b1c78444a3f..336d9be6d16 100644 --- a/apps/browser/src/vault/popup/settings/archive.component.ts +++ b/apps/browser/src/vault/popup/settings/archive.component.ts @@ -1,9 +1,13 @@ import { CommonModule } from "@angular/common"; import { Component, inject } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; -import { firstValueFrom, map, Observable, startWith, switchMap } from "rxjs"; +import { combineLatest, firstValueFrom, map, Observable, startWith, switchMap } from "rxjs"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -25,16 +29,20 @@ import { SectionHeaderComponent, ToastService, TypographyModule, + CardComponent, + ButtonComponent, } from "@bitwarden/components"; import { CanDeleteCipherDirective, DecryptionFailureDialogComponent, + OrgIconDirective, PasswordRepromptService, } from "@bitwarden/vault"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; +import { ROUTES_AFTER_EDIT_DELETION } from "../components/vault/add-edit/add-edit.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -55,6 +63,9 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co SectionComponent, SectionHeaderComponent, TypographyModule, + OrgIconDirective, + CardComponent, + ButtonComponent, ], }) export class ArchiveComponent { @@ -67,13 +78,38 @@ export class ArchiveComponent { private i18nService = inject(I18nService); private cipherArchiveService = inject(CipherArchiveService); private passwordRepromptService = inject(PasswordRepromptService); + private organizationService = inject(OrganizationService); + private collectionService = inject(CollectionService); private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); + private readonly orgMap = toSignal( + this.userId$.pipe( + switchMap((userId) => + this.organizationService.organizations$(userId).pipe( + map((orgs) => { + const map = new Map(); + for (const org of orgs) { + map.set(org.id, org); + } + return map; + }), + ), + ), + ), + ); + + private readonly collections = toSignal( + this.userId$.pipe(switchMap((userId) => this.collectionService.decryptedCollections$(userId))), + ); + protected archivedCiphers$ = this.userId$.pipe( switchMap((userId) => this.cipherArchiveService.archivedCiphers$(userId)), ); + protected userCanArchive$ = this.userId$.pipe( + switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)), + ); protected CipherViewLikeUtils = CipherViewLikeUtils; protected loading$ = this.archivedCiphers$.pipe( @@ -81,13 +117,43 @@ export class ArchiveComponent { startWith(true), ); + protected canAssignCollections$ = this.userId$.pipe( + switchMap((userId) => { + return combineLatest([ + this.organizationService.hasOrganizations(userId), + this.collectionService.decryptedCollections$(userId), + ]).pipe( + map(([hasOrgs, collections]) => { + const canEditCollections = collections.some((c) => !c.readOnly); + return hasOrgs && canEditCollections; + }), + ); + }), + ); + + protected showSubscriptionEndedMessaging$ = this.userId$.pipe( + switchMap((userId) => this.cipherArchiveService.showSubscriptionEndedMessaging$(userId)), + ); + + protected userHasPremium$ = this.userId$.pipe( + switchMap((userId) => this.cipherArchiveService.userHasPremium$(userId)), + ); + + async navigateToPremium() { + await this.router.navigate(["/premium"]); + } + async view(cipher: CipherViewLike) { if (!(await this.canInteract(cipher))) { return; } await this.router.navigate(["/view-cipher"], { - queryParams: { cipherId: cipher.id, type: cipher.type }, + queryParams: { + cipherId: cipher.id, + type: cipher.type, + routeAfterDeletion: ROUTES_AFTER_EDIT_DELETION.archive, + }, }); } @@ -97,7 +163,11 @@ export class ArchiveComponent { } await this.router.navigate(["/edit-cipher"], { - queryParams: { cipherId: cipher.id, type: cipher.type }, + queryParams: { + cipherId: cipher.id, + type: cipher.type, + routeAfterDeletion: ROUTES_AFTER_EDIT_DELETION.archive, + }, }); } @@ -173,6 +243,17 @@ export class ArchiveComponent { }); } + /** Prompts for password when necessary then navigates to the assign collections route */ + async conditionallyNavigateToAssignCollections(cipher: CipherViewLike) { + if (cipher.reprompt && !(await this.passwordRepromptService.showPasswordPrompt())) { + return; + } + + await this.router.navigate(["/assign-collections"], { + queryParams: { cipherId: cipher.id }, + }); + } + /** * Check if the user is able to interact with the cipher * (password re-prompt / decryption failure checks). @@ -189,4 +270,22 @@ export class ArchiveComponent { return this.passwordRepromptService.passwordRepromptCheck(cipher); } + + /** + * Get the organization tier type for the given cipher. + */ + orgTierType({ organizationId }: CipherViewLike) { + return this.orgMap()?.get(organizationId as string)?.productTierType; + } + + /** + * Get the organization icon tooltip for the given cipher. + */ + orgIconTooltip({ collectionIds }: CipherViewLike) { + if (collectionIds.length !== 1) { + return this.i18nService.t("nCollections", collectionIds.length); + } + + return this.collections()?.find((c) => c.id === collectionIds[0])?.name; + } } diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.html b/apps/browser/src/vault/popup/settings/folders.component.html similarity index 100% rename from apps/browser/src/vault/popup/settings/folders-v2.component.html rename to apps/browser/src/vault/popup/settings/folders.component.html diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/folders.component.spec.ts similarity index 93% rename from apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts rename to apps/browser/src/vault/popup/settings/folders.component.spec.ts index 3cb5503ed89..678e6d3f10e 100644 --- a/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/folders.component.spec.ts @@ -19,7 +19,7 @@ import { AddEditFolderDialogComponent } from "@bitwarden/vault"; import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; -import { FoldersV2Component } from "./folders-v2.component"; +import { FoldersComponent } from "./folders.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -48,9 +48,9 @@ class MockPopupFooterComponent { @Input() pageTitle: string = ""; } -describe("FoldersV2Component", () => { - let component: FoldersV2Component; - let fixture: ComponentFixture; +describe("FoldersComponent", () => { + let component: FoldersComponent; + let fixture: ComponentFixture; const folderViews$ = new BehaviorSubject([]); const open = jest.spyOn(AddEditFolderDialogComponent, "open"); const mockDialogService = { open: jest.fn() }; @@ -59,7 +59,7 @@ describe("FoldersV2Component", () => { open.mockClear(); await TestBed.configureTestingModule({ - imports: [FoldersV2Component], + imports: [FoldersComponent], providers: [ { provide: PlatformUtilsService, useValue: mock() }, { provide: ConfigService, useValue: mock() }, @@ -69,7 +69,7 @@ describe("FoldersV2Component", () => { { provide: AccountService, useValue: mockAccountServiceWith("UserId" as UserId) }, ], }) - .overrideComponent(FoldersV2Component, { + .overrideComponent(FoldersComponent, { remove: { imports: [PopupHeaderComponent, PopupFooterComponent], }, @@ -80,7 +80,7 @@ describe("FoldersV2Component", () => { .overrideProvider(DialogService, { useValue: mockDialogService }) .compileComponents(); - fixture = TestBed.createComponent(FoldersV2Component); + fixture = TestBed.createComponent(FoldersComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.ts b/apps/browser/src/vault/popup/settings/folders.component.ts similarity index 96% rename from apps/browser/src/vault/popup/settings/folders-v2.component.ts rename to apps/browser/src/vault/popup/settings/folders.component.ts index 20a816e7297..b70c17bd6a5 100644 --- a/apps/browser/src/vault/popup/settings/folders-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/folders.component.ts @@ -25,7 +25,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - templateUrl: "./folders-v2.component.html", + templateUrl: "./folders.component.html", imports: [ CommonModule, JslibModule, @@ -39,7 +39,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co AsyncActionsModule, ], }) -export class FoldersV2Component { +export class FoldersComponent { folders$: Observable; NoFoldersIcon = NoFolders; diff --git a/apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.html b/apps/browser/src/vault/popup/settings/more-from-bitwarden-page.component.html similarity index 100% rename from apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.html rename to apps/browser/src/vault/popup/settings/more-from-bitwarden-page.component.html diff --git a/apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.ts b/apps/browser/src/vault/popup/settings/more-from-bitwarden-page.component.ts similarity index 97% rename from apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.ts rename to apps/browser/src/vault/popup/settings/more-from-bitwarden-page.component.ts index 0b896547008..01537cbd62e 100644 --- a/apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/more-from-bitwarden-page.component.ts @@ -19,7 +19,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - templateUrl: "more-from-bitwarden-page-v2.component.html", + templateUrl: "more-from-bitwarden-page.component.html", imports: [ CommonModule, JslibModule, @@ -30,7 +30,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co ItemModule, ], }) -export class MoreFromBitwardenPageV2Component { +export class MoreFromBitwardenPageComponent { protected familySponsorshipAvailable$: Observable; protected isFreeFamilyPolicyEnabled$: Observable; protected hasSingleEnterpriseOrg$: Observable; diff --git a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts index bad6011b2d8..edebdab062f 100644 --- a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts @@ -115,15 +115,22 @@ export class TrashListItemsContainerComponent { } async restore(cipher: PopupCipherViewLike) { + let toastMessage; try { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); await this.cipherService.restoreWithServer(cipher.id as string, activeUserId); + if (cipher.archivedDate) { + toastMessage = this.i18nService.t("archivedItemRestored"); + } else { + toastMessage = this.i18nService.t("restoredItem"); + } + await this.router.navigate(["/trash"]); this.toastService.showToast({ variant: "success", title: null, - message: this.i18nService.t("restoredItem"), + message: toastMessage, }); } catch (e) { this.logService.error(e); diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html b/apps/browser/src/vault/popup/settings/vault-settings.component.html similarity index 86% rename from apps/browser/src/vault/popup/settings/vault-settings-v2.component.html rename to apps/browser/src/vault/popup/settings/vault-settings.component.html index d5b94df5008..c84188af863 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html +++ b/apps/browser/src/vault/popup/settings/vault-settings.component.html @@ -13,7 +13,7 @@ - @@ -52,9 +55,7 @@ > {{ "archiveNoun" | i18n }} - @if (!userHasArchivedItems()) { - - } + diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/vault-settings.component.spec.ts similarity index 91% rename from apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts rename to apps/browser/src/vault/popup/settings/vault-settings.component.spec.ts index 15ddb7507fd..a948b811fd4 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/vault-settings.component.spec.ts @@ -19,7 +19,7 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; -import { VaultSettingsV2Component } from "./vault-settings-v2.component"; +import { VaultSettingsComponent } from "./vault-settings.component"; @Component({ selector: "popup-header", @@ -47,9 +47,9 @@ class MockPopOutComponent { readonly show = input(true); } -describe("VaultSettingsV2Component", () => { - let component: VaultSettingsV2Component; - let fixture: ComponentFixture; +describe("VaultSettingsComponent", () => { + let component: VaultSettingsComponent; + let fixture: ComponentFixture; let router: Router; let mockCipherArchiveService: jest.Mocked; @@ -90,11 +90,11 @@ describe("VaultSettingsV2Component", () => { mockCipherArchiveService.hasArchiveFlagEnabled$ = mockHasArchiveFlagEnabled$.asObservable(); await TestBed.configureTestingModule({ - imports: [VaultSettingsV2Component], + imports: [VaultSettingsComponent], providers: [ provideRouter([ - { path: "archive", component: VaultSettingsV2Component }, - { path: "premium", component: VaultSettingsV2Component }, + { path: "archive", component: VaultSettingsComponent }, + { path: "premium", component: VaultSettingsComponent }, ]), { provide: SyncService, useValue: mock() }, { provide: ToastService, useValue: mock() }, @@ -117,7 +117,7 @@ describe("VaultSettingsV2Component", () => { }, ], }) - .overrideComponent(VaultSettingsV2Component, { + .overrideComponent(VaultSettingsComponent, { remove: { imports: [PopupHeaderComponent, PopupPageComponent, PopOutComponent], }, @@ -127,7 +127,7 @@ describe("VaultSettingsV2Component", () => { }) .compileComponents(); - fixture = TestBed.createComponent(VaultSettingsV2Component); + fixture = TestBed.createComponent(VaultSettingsComponent); component = fixture.componentInstance; router = TestBed.inject(Router); jest.spyOn(router, "navigate"); @@ -195,10 +195,10 @@ describe("VaultSettingsV2Component", () => { expect(component["userHasArchivedItems"]()).toBe(false); }); - it("hides premium badge when user has archived items", () => { + it("shows premium badge when user has archived items but cannot archive", () => { setArchiveState(false, [{ id: "cipher1" } as CipherView]); - expect(component["premiumBadgeComponent"]()).toBeUndefined(); + expect(component["premiumBadgeComponent"]()).toBeTruthy(); expect(component["userHasArchivedItems"]()).toBe(true); }); }); diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts b/apps/browser/src/vault/popup/settings/vault-settings.component.ts similarity index 92% rename from apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts rename to apps/browser/src/vault/popup/settings/vault-settings.component.ts index c1d90d678cb..f79cef56155 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/vault-settings.component.ts @@ -15,8 +15,6 @@ import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstraction import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { BadgeComponent, ItemModule, ToastOptions, ToastService } from "@bitwarden/components"; -import { BrowserApi } from "../../../platform/browser/browser-api"; -import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; 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"; @@ -25,7 +23,7 @@ import { BrowserPremiumUpgradePromptService } from "../services/browser-premium- // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - templateUrl: "vault-settings-v2.component.html", + templateUrl: "vault-settings.component.html", imports: [ CommonModule, JslibModule, @@ -41,7 +39,7 @@ import { BrowserPremiumUpgradePromptService } from "../services/browser-premium- { provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService }, ], }) -export class VaultSettingsV2Component implements OnInit, OnDestroy { +export class VaultSettingsComponent implements OnInit, OnDestroy { private readonly premiumBadgeComponent = viewChild(PremiumBadgeComponent); lastSync = "--"; @@ -90,9 +88,6 @@ export class VaultSettingsV2Component implements OnInit, OnDestroy { async import() { await this.router.navigate(["/import"]); - if (await BrowserApi.isPopupOpen()) { - await BrowserPopupUtils.openCurrentPagePopout(window); - } } async sync() { diff --git a/apps/browser/src/vault/popup/views/popup-cipher.view.ts b/apps/browser/src/vault/popup/views/popup-cipher.view.ts index 6f85e7b6eb4..7d035ceb6df 100644 --- a/apps/browser/src/vault/popup/views/popup-cipher.view.ts +++ b/apps/browser/src/vault/popup/views/popup-cipher.view.ts @@ -1,4 +1,4 @@ -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherListView } from "@bitwarden/sdk-internal"; diff --git a/apps/browser/store/locales/ru/copy.resx b/apps/browser/store/locales/ru/copy.resx index 0da1e70f897..3337942f2af 100644 --- a/apps/browser/store/locales/ru/copy.resx +++ b/apps/browser/store/locales/ru/copy.resx @@ -127,44 +127,44 @@ Признан лучшим менеджером паролей по версии PCMag, WIRED, The Verge, CNET, G2 и других! ЗАЩИТИТЕ СВОЮ ЦИФРОВУЮ ЖИЗНЬ -Защитите свою цифровую жизнь и защитите её от утечек данных, создавая уникальные, надёжные пароли для каждой учетной записи. Сохраните всё в зашифрованном сквозным шифрованием хранилище паролей, доступ к которому есть только у вас. +Обезопасьте свою цифровую жизнь, защитив её от утечек данных с помощью уникальных, надёжных паролей для каждой учётной записи. Сохраните всё в зашифрованном сквозным шифрованием хранилище паролей, доступ к которому есть только у вас. ДОСТУП К СВОИМ ДАННЫМ В ЛЮБОМ МЕСТЕ, В ЛЮБОЕ ВРЕМЯ, НА ЛЮБОМ УСТРОЙСТВЕ -Легко управляйте, храните, защищайте и делитесь неограниченным количеством паролей на неограниченном количестве устройств без ограничений. +Без труда управляйте паролями, храните их, защищайте и делитесь ими в неограниченном количестве на любом числе устройств. -КАЖДЫЙ ДОЛЖЕН ИМЕТЬ ИНСТРУМЕНТЫ ДЛЯ БЕЗОПАСНОСТИ В СЕТИ -Используйте Bitwarden бесплатно без рекламы или продажи данных. Bitwarden считает, что каждый должен иметь возможность оставаться в безопасности в сети. Премиум-планы предлагают доступ к расширенным функциям. +У КАЖДОГО ДОЛЖНЫ БЫТЬ СРЕДСТВА БЕЗОПАСНОСТИ В СЕТИ +Используйте Bitwarden бесплатно без рекламы и не опасаясь, что ваши данные будут проданы. Компания Bitwarden считает, что у каждого должна быть возможность оставаться в безопасности в сети. Если же нужно больше функций, то обратите внимание на Премиум-планы. РАСШИРЯЙТЕ ВОЗМОЖНОСТИ СВОИХ КОМАНД С ПОМОЩЬЮ BITWARDEN -Планы для Teams и Enterprise включают профессиональные бизнес-функции. Вот несколько примеров: интеграция SSO, собственный хостинг, интеграция каталогов и SCIM, глобальные политики, доступ через API, журналы событий и многое другое. +Планы Teams и Enterprise включают профессиональные бизнес-функции. Среди них интеграция SSO, собственный хостинг, интеграция каталогов и SCIM, глобальные политики, доступ через API, журналы событий и многое другое. Используйте Bitwarden для защиты своих сотрудников и обмена конфиденциальной информацией с коллегами. Дополнительные причины выбрать Bitwarden: -Шифрование мирового класса -Пароли защищены усовершенствованным сквозным шифрованием (AES-256, использование salt и PBKDF2 SHA-256), поэтому ваши данные остаются в безопасности и конфиденциальности. +Шифрование мирового уровня +Пароли защищены усовершенствованным сквозным шифрованием (AES-256, хеширование с солью, PBKDF2 SHA-256), поэтому ваши конфиденциальные данные надёжно защищены. Сторонние аудиты -Bitwarden регулярно проводит комплексные сторонние аудиты безопасности с известными фирмами по безопасности. Эти ежегодные аудиты включают оценку исходного кода и тестирование на проникновение по IP-адресам Bitwarden, серверам и веб-приложениям. +Bitwarden регулярно проводит комплексные сторонние аудиты с известными фирмами по безопасности. Эти ежегодные аудиты включают оценку исходного кода и тестирование на проникновение по IP-адресам Bitwarden, серверам и веб-приложениям. -Расширенная 2FA -Защитите свой вход с помощью стороннего аутентификатора, кодов, отправленных по электронной почте, или учетных данных FIDO2 WebAuthn, таких как аппаратный ключ безопасности или ключ доступа. +Расширенная двухфакторная аутентификация (2FA) +Вход может быть защищён с помощью стороннего аутентификатора, кодов по электронной почте или учётных данных FIDO2 WebAuthn, таких как аппаратный ключ безопасности или ключ доступа. Bitwarden Send Передавайте данные другим без посредников, сохраняя сквозное шифрование и ограничивая раскрытие информации. Встроенный генератор паролей -Создавайте длинные, сложные и уникальные пароли и имена пользователей для каждого посещаемого вами сайта. Интегрируйтесь с поставщиками псевдонимов электронной почты для дополнительной конфиденциальности. +Создавайте длинные, сложные и уникальные пароли и логины для каждого посещаемого сайта. Интегрируйтесь с поставщиками псевдонимов электронной почты для дополнительной конфиденциальности. Многоязычный перевод -Bitwarden переведён на более чем 60 языков, с помощью мирового сообщества через Crowdin. +Bitwarden переведён на более чем 60 языков с помощью мирового сообщества через Crowdin. Кроссплатформенные приложения -Защищайте и делитесь конфиденциальными данными в вашем хранилище Bitwarden из любого браузера, мобильного устройства или настольной ОС и т. д. +Защищайте и делитесь конфиденциальными данными в вашем хранилище Bitwarden из любого браузера, мобильного устройства, настольной ОС и др. Bitwarden защищает не только пароли -Решения со сквозным шифрованием для управления учётными данными от Bitwarden позволяют организациям защищать всё, включая секреты разработчиков и ключи доступа. Посетите Bitwarden.com, чтобы узнать больше о Bitwarden Secrets Manager и Bitwarden Passwordless.dev! +Решения со сквозным шифрованием для управления учётными данными от Bitwarden позволяют организациям защищать всё, включая секреты разработчиков и ключи доступа. Посетите сайт Bitwarden.com, чтобы узнать больше о Bitwarden Secrets Manager и Bitwarden Passwordless.dev! diff --git a/apps/browser/webpack.base.js b/apps/browser/webpack.base.js index 4bc2a90c4ff..c2b45897857 100644 --- a/apps/browser/webpack.base.js +++ b/apps/browser/webpack.base.js @@ -109,6 +109,7 @@ module.exports.buildConfig = function buildConfig(params) { }, { test: /\.[cm]?js$/, + exclude: /\.wasm\.js$/, use: [ { loader: "babel-loader", diff --git a/apps/cli/package.json b/apps/cli/package.json index 5174e324586..6c27267054f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/cli", "description": "A secure and free password manager for all of your devices.", - "version": "2025.12.1", + "version": "2026.1.0", "keywords": [ "bitwarden", "password", @@ -64,12 +64,12 @@ }, "dependencies": { "@koa/multer": "4.0.0", - "@koa/router": "14.0.0", + "@koa/router": "15.2.0", "big-integer": "1.6.52", "browser-hrtime": "1.1.8", "chalk": "4.1.2", "commander": "14.0.0", - "core-js": "3.47.0", + "core-js": "3.48.0", "form-data": "4.0.4", "https-proxy-agent": "7.0.6", "inquirer": "8.2.6", @@ -81,14 +81,14 @@ "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", + "open": "8.4.2", "papaparse": "5.5.3", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", "semver": "7.7.3", - "tldts": "7.0.19", + "tldts": "7.0.22", "zxcvbn": "4.4.2" } } diff --git a/apps/cli/src/admin-console/commands/confirm.command.spec.ts b/apps/cli/src/admin-console/commands/confirm.command.spec.ts new file mode 100644 index 00000000000..69eb1cf6f39 --- /dev/null +++ b/apps/cli/src/admin-console/commands/confirm.command.spec.ts @@ -0,0 +1,250 @@ +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { + OrganizationUserApiService, + OrganizationUserDetailsResponse, +} from "@bitwarden/admin-console/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { KeyService } from "@bitwarden/key-management"; + +import { Response } from "../../models/response"; + +import { ConfirmCommand } from "./confirm.command"; + +describe("ConfirmCommand", () => { + let command: ConfirmCommand; + let apiService: jest.Mocked; + let keyService: jest.Mocked; + let encryptService: jest.Mocked; + let organizationUserApiService: jest.Mocked; + let accountService: jest.Mocked; + let i18nService: jest.Mocked; + + const userId = "test-user-id" as UserId; + const organizationId = "bf61e571-fb70-4113-b305-b331004d0f19"; + const organizationUserId = "6aa431fa-7ea1-4852-907e-b36b0030a87d"; + const mockOrgKey = {} as OrgKey; + const mockPublicKey = "mockPublicKey"; + + beforeEach(() => { + apiService = mock(); + keyService = mock(); + encryptService = mock(); + organizationUserApiService = mock(); + accountService = mock(); + i18nService = mock(); + + command = new ConfirmCommand( + apiService, + keyService, + encryptService, + organizationUserApiService, + accountService, + i18nService, + ); + + // Default mocks + accountService.activeAccount$ = of({ id: userId } as any); + keyService.orgKeys$ = jest.fn().mockReturnValue(of({ [organizationId]: mockOrgKey })); + i18nService.t.mockReturnValue("My Items"); + encryptService.encryptString.mockResolvedValue({ encryptedString: "encrypted" } as any); + encryptService.encapsulateKeyUnsigned.mockResolvedValue({ encryptedString: "key" } as any); + apiService.getUserPublicKey.mockResolvedValue({ publicKey: mockPublicKey } as any); + organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(); + }); + + describe("run", () => { + it("should return bad request for unknown object", async () => { + const response = await command.run("unknown-object", organizationUserId, { + organizationid: organizationId, + }); + + expect(response).toBeInstanceOf(Response); + expect(response.success).toBe(false); + expect(response.message).toBe("Unknown object."); + }); + + it("should return bad request when organizationId is missing", async () => { + const response = await command.run("org-member", organizationUserId, {}); + + expect(response).toBeInstanceOf(Response); + expect(response.success).toBe(false); + expect(response.message).toBe("--organizationid required."); + }); + + it("should return bad request when id is not a GUID", async () => { + const response = await command.run("org-member", "not-a-guid", { + organizationid: organizationId, + }); + + expect(response).toBeInstanceOf(Response); + expect(response.success).toBe(false); + expect(response.message).toContain("is not a GUID"); + }); + + it("should return bad request when organizationId is not a GUID", async () => { + const response = await command.run("org-member", organizationUserId, { + organizationid: "not-a-guid", + }); + + expect(response).toBeInstanceOf(Response); + expect(response.success).toBe(false); + expect(response.message).toContain("is not a GUID"); + }); + }); + + describe("confirmOrganizationMember - status validation", () => { + it("should reject user with Invited status", async () => { + const invitedUser = { + id: organizationUserId, + userId: null, + status: OrganizationUserStatusType.Invited, + } as unknown as OrganizationUserDetailsResponse; + + organizationUserApiService.getOrganizationUser.mockResolvedValue(invitedUser); + + const response = await command.run("org-member", organizationUserId, { + organizationid: organizationId, + }); + + expect(response).toBeInstanceOf(Response); + expect(response.success).toBe(false); + expect(response.message).toContain( + "User must accept the invitation before they can be confirmed.", + ); + }); + + it("should reject user with Confirmed status", async () => { + const confirmedUser = { + id: organizationUserId, + userId: userId, + status: OrganizationUserStatusType.Confirmed, + } as unknown as OrganizationUserDetailsResponse; + + organizationUserApiService.getOrganizationUser.mockResolvedValue(confirmedUser); + + const response = await command.run("org-member", organizationUserId, { + organizationid: organizationId, + }); + + expect(response).toBeInstanceOf(Response); + expect(response.success).toBe(false); + expect(response.message).toContain("User is already confirmed."); + }); + + it("should reject user with Revoked status", async () => { + const revokedUser = { + id: organizationUserId, + userId: userId, + status: OrganizationUserStatusType.Revoked, + } as unknown as OrganizationUserDetailsResponse; + + organizationUserApiService.getOrganizationUser.mockResolvedValue(revokedUser); + + const response = await command.run("org-member", organizationUserId, { + organizationid: organizationId, + }); + + expect(response).toBeInstanceOf(Response); + expect(response.success).toBe(false); + expect(response.message).toContain("User is revoked and cannot be confirmed."); + }); + + it("should reject user with unexpected status", async () => { + const invalidUser = { + id: organizationUserId, + userId: userId, + status: 999 as OrganizationUserStatusType, // Invalid status + } as unknown as OrganizationUserDetailsResponse; + + organizationUserApiService.getOrganizationUser.mockResolvedValue(invalidUser); + + const response = await command.run("org-member", organizationUserId, { + organizationid: organizationId, + }); + + expect(response).toBeInstanceOf(Response); + expect(response.success).toBe(false); + expect(response.message).toContain("User is not in a valid state to be confirmed."); + }); + + it("should successfully confirm user with Accepted status", async () => { + const acceptedUser = { + id: organizationUserId, + userId: userId, + status: OrganizationUserStatusType.Accepted, + } as unknown as OrganizationUserDetailsResponse; + + organizationUserApiService.getOrganizationUser.mockResolvedValue(acceptedUser); + + const response = await command.run("org-member", organizationUserId, { + organizationid: organizationId, + }); + + expect(response).toBeInstanceOf(Response); + expect(response.success).toBe(true); + expect(apiService.getUserPublicKey).toHaveBeenCalledWith(userId); + expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith( + organizationId, + organizationUserId.toLowerCase(), + expect.objectContaining({ + key: "key", + defaultUserCollectionName: "encrypted", + }), + ); + }); + }); + + describe("error handling", () => { + it("should return error when organization key is not found", async () => { + keyService.orgKeys$ = jest.fn().mockReturnValue(of({})); + + const response = await command.run("org-member", organizationUserId, { + organizationid: organizationId, + }); + + expect(response).toBeInstanceOf(Response); + expect(response.success).toBe(false); + expect(response.message).toContain("No encryption key for this organization"); + }); + + it("should return error when organization user is not found", async () => { + organizationUserApiService.getOrganizationUser.mockResolvedValue(null); + + const response = await command.run("org-member", organizationUserId, { + organizationid: organizationId, + }); + + expect(response).toBeInstanceOf(Response); + expect(response.success).toBe(false); + expect(response.message).toContain("Member id does not exist for this organization"); + }); + + it("should return error when API call fails", async () => { + const acceptedUser = { + id: organizationUserId, + userId: userId, + status: OrganizationUserStatusType.Accepted, + } as unknown as OrganizationUserDetailsResponse; + + organizationUserApiService.getOrganizationUser.mockResolvedValue(acceptedUser); + organizationUserApiService.postOrganizationUserConfirm.mockRejectedValue( + new Error("API Error"), + ); + + const response = await command.run("org-member", organizationUserId, { + organizationid: organizationId, + }); + + expect(response).toBeInstanceOf(Response); + expect(response.success).toBe(false); + }); + }); +}); diff --git a/apps/cli/src/admin-console/commands/confirm.command.ts b/apps/cli/src/admin-console/commands/confirm.command.ts index 4458054e244..a387df25443 100644 --- a/apps/cli/src/admin-console/commands/confirm.command.ts +++ b/apps/cli/src/admin-console/commands/confirm.command.ts @@ -5,8 +5,10 @@ import { firstValueFrom, map, switchMap } from "rxjs"; import { OrganizationUserApiService, OrganizationUserConfirmRequest, + OrganizationUserDetailsResponse, } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationUserStatusType } 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 { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; @@ -72,6 +74,9 @@ export class ConfirmCommand { if (orgUser == null) { throw new Error("Member id does not exist for this organization."); } + + this.validateOrganizationUserStatus(orgUser); + const publicKeyResponse = await this.apiService.getUserPublicKey(orgUser.userId); const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey); const key = await this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey); @@ -94,6 +99,24 @@ export class ConfirmCommand { const encrypted = await this.encryptService.encryptString(defaultCollectionName, orgKey); return encrypted.encryptedString; } + + private validateOrganizationUserStatus(orgUser: OrganizationUserDetailsResponse): void { + if (orgUser.status === OrganizationUserStatusType.Invited) { + throw new Error("User must accept the invitation before they can be confirmed."); + } + + if (orgUser.status === OrganizationUserStatusType.Confirmed) { + throw new Error("User is already confirmed."); + } + + if (orgUser.status === OrganizationUserStatusType.Revoked) { + throw new Error("User is revoked and cannot be confirmed."); + } + + if (orgUser.status !== OrganizationUserStatusType.Accepted) { + throw new Error("User is not in a valid state to be confirmed."); + } + } } class Options { diff --git a/apps/cli/src/admin-console/models/response/collection.response.ts b/apps/cli/src/admin-console/models/response/collection.response.ts index a0d1ce1047d..4c56fdcd84a 100644 --- a/apps/cli/src/admin-console/models/response/collection.response.ts +++ b/apps/cli/src/admin-console/models/response/collection.response.ts @@ -1,4 +1,4 @@ -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { CollectionWithIdExport } from "@bitwarden/common/models/export/collection-with-id.export"; import { BaseResponse } from "../../../models/response/base.response"; diff --git a/apps/cli/src/admin-console/models/response/organization-collection.response.ts b/apps/cli/src/admin-console/models/response/organization-collection.response.ts index a0d62b4c7b6..4b5c9a08f2b 100644 --- a/apps/cli/src/admin-console/models/response/organization-collection.response.ts +++ b/apps/cli/src/admin-console/models/response/organization-collection.response.ts @@ -1,4 +1,4 @@ -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { SelectionReadOnly } from "../selection-read-only"; diff --git a/apps/cli/src/commands/edit.command.ts b/apps/cli/src/commands/edit.command.ts index d95e8333dca..dbcb0489187 100644 --- a/apps/cli/src/commands/edit.command.ts +++ b/apps/cli/src/commands/edit.command.ts @@ -138,10 +138,8 @@ export class EditCommand { ); } - const encCipher = await this.cipherService.encrypt(cipherView, activeUserId); try { - const updatedCipher = await this.cipherService.updateWithServer(encCipher); - const decCipher = await this.cipherService.decrypt(updatedCipher, activeUserId); + const decCipher = await this.cipherService.updateWithServer(cipherView, activeUserId); const res = new CipherResponse(decCipher); return Response.success(res); } catch (e) { diff --git a/apps/cli/src/commands/get.command.ts b/apps/cli/src/commands/get.command.ts index 35816b56fb2..db070344628 100644 --- a/apps/cli/src/commands/get.command.ts +++ b/apps/cli/src/commands/get.command.ts @@ -1,12 +1,11 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { filter, firstValueFrom, map, switchMap } from "rxjs"; -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index ff210cf222d..2430035e34a 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -1,16 +1,15 @@ import { firstValueFrom, map } from "rxjs"; +import { OrganizationUserApiService, CollectionService } from "@bitwarden/admin-console/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { - OrganizationUserApiService, - CollectionService, CollectionData, Collection, CollectionDetailsResponse as ApiCollectionDetailsResponse, CollectionResponse as ApiCollectionResponse, -} from "@bitwarden/admin-console/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +} from "@bitwarden/common/admin-console/models/collections"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { EventType } from "@bitwarden/common/enums"; diff --git a/apps/cli/src/commands/restore.command.ts b/apps/cli/src/commands/restore.command.ts index d8cefdfce5d..8c0fc1fbbe1 100644 --- a/apps/cli/src/commands/restore.command.ts +++ b/apps/cli/src/commands/restore.command.ts @@ -46,7 +46,9 @@ export class RestoreCommand { return Response.notFound(); } - if (cipher.archivedDate && isArchivedVaultEnabled) { + // Determine if restoring from archive or trash + // When a cipher is archived and deleted, restore from the trash first + if (cipher.archivedDate && cipher.deletedDate == null && isArchivedVaultEnabled) { return this.restoreArchivedCipher(cipher, activeUserId); } else { return this.restoreDeletedCipher(cipher, activeUserId); diff --git a/apps/cli/src/commands/serve.command.ts b/apps/cli/src/commands/serve.command.ts index 5bf19333f35..92632981154 100644 --- a/apps/cli/src/commands/serve.command.ts +++ b/apps/cli/src/commands/serve.command.ts @@ -1,7 +1,7 @@ import http from "node:http"; import net from "node:net"; -import * as koaRouter from "@koa/router"; +import { Router } from "@koa/router"; import { OptionValues } from "commander"; import * as koa from "koa"; import * as koaBodyParser from "koa-bodyparser"; @@ -29,7 +29,7 @@ export class ServeCommand { ); const server = new koa(); - const router = new koaRouter(); + const router = new Router(); process.env.BW_SERVE = "true"; process.env.BW_NOINTERACTION = "true"; diff --git a/apps/cli/src/oss-serve-configurator.ts b/apps/cli/src/oss-serve-configurator.ts index e0385534cb7..fbf3c778725 100644 --- a/apps/cli/src/oss-serve-configurator.ts +++ b/apps/cli/src/oss-serve-configurator.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import * as koaMulter from "@koa/multer"; -import * as koaRouter from "@koa/router"; +import { Router } from "@koa/router"; import * as koa from "koa"; import { firstValueFrom, map } from "rxjs"; @@ -218,7 +218,7 @@ export class OssServeConfigurator { ); } - async configureRouter(router: koaRouter) { + async configureRouter(router: Router) { router.get("/generate", async (ctx, next) => { const response = await this.generateCommand.run(ctx.request.query); this.processResponse(ctx.response, response); diff --git a/apps/cli/src/register-oss-programs.ts b/apps/cli/src/register-oss-programs.ts index 71d7aaa0d52..f0b0475c808 100644 --- a/apps/cli/src/register-oss-programs.ts +++ b/apps/cli/src/register-oss-programs.ts @@ -18,5 +18,5 @@ export async function registerOssPrograms(serviceContainer: ServiceContainer) { await vaultProgram.register(); const sendProgram = new SendProgram(serviceContainer); - sendProgram.register(); + await sendProgram.register(); } diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index d98b5f0a861..b5a2b1b8196 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -39,6 +39,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { MasterPasswordApiService as MasterPasswordApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; +import { SendTokenService, DefaultSendTokenService } from "@bitwarden/common/auth/send-access"; import { AccountServiceImplementation, getUserId, @@ -91,6 +92,8 @@ import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin. import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service"; +import { SendPasswordService } from "@bitwarden/common/key-management/sends/abstractions/send-password.service"; +import { DefaultSendPasswordService } from "@bitwarden/common/key-management/sends/services/default-send-password.service"; import { DefaultVaultTimeoutService, DefaultVaultTimeoutSettingsService, @@ -147,11 +150,13 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service" import { UserId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service"; import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; 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"; @@ -254,6 +259,7 @@ export class ServiceContainer { twoFactorApiService: TwoFactorApiService; hibpApiService: HibpApiService; environmentService: EnvironmentService; + cipherSdkService: CipherSdkService; cipherService: CipherService; folderService: InternalFolderService; organizationUserApiService: OrganizationUserApiService; @@ -303,6 +309,8 @@ export class ServiceContainer { userVerificationApiService: UserVerificationApiService; organizationApiService: OrganizationApiServiceAbstraction; sendApiService: SendApiService; + sendTokenService: SendTokenService; + sendPasswordService: SendPasswordService; devicesApiService: DevicesApiServiceAbstraction; deviceTrustService: DeviceTrustServiceAbstraction; authRequestService: AuthRequestService; @@ -439,7 +447,13 @@ export class ServiceContainer { this.derivedStateProvider, ); - this.securityStateService = new DefaultSecurityStateService(this.stateProvider); + this.accountCryptographicStateService = new DefaultAccountCryptographicStateService( + this.stateProvider, + ); + + this.securityStateService = new DefaultSecurityStateService( + this.accountCryptographicStateService, + ); this.environmentService = new DefaultEnvironmentService( this.stateProvider, @@ -493,6 +507,7 @@ export class ServiceContainer { this.accountService, this.stateProvider, this.kdfConfigService, + this.accountCryptographicStateService, ); const pinStateService = new PinStateService(this.stateProvider); @@ -605,6 +620,7 @@ export class ServiceContainer { this.keyGenerationService, this.sendStateProvider, this.encryptService, + this.configService, ); this.cipherFileUploadService = new CipherFileUploadService( @@ -618,6 +634,8 @@ export class ServiceContainer { this.sendService, ); + this.sendPasswordService = new DefaultSendPasswordService(this.cryptoFunctionService); + this.searchService = new SearchService(this.logService, this.i18nService, this.stateProvider); this.collectionService = new DefaultCollectionService( @@ -635,10 +653,6 @@ export class ServiceContainer { this.accountService, ); - this.accountCryptographicStateService = new DefaultAccountCryptographicStateService( - this.stateProvider, - ); - const sdkClientFactory = flagEnabled("sdk") ? new DefaultSdkClientFactory() : new NoopSdkClientFactory(); @@ -668,6 +682,12 @@ export class ServiceContainer { customUserAgent, ); + this.sendTokenService = new DefaultSendTokenService( + this.globalStateProvider, + this.sdkService, + this.sendPasswordService, + ); + this.keyConnectorService = new KeyConnectorService( this.accountService, this.masterPasswordService, @@ -794,6 +814,8 @@ export class ServiceContainer { this.logService, ); + this.cipherSdkService = new DefaultCipherSdkService(this.sdkService, this.logService); + this.cipherService = new CipherService( this.keyService, this.domainSettingsService, @@ -809,6 +831,7 @@ export class ServiceContainer { this.logService, this.cipherEncryptionService, this.messagingService, + this.cipherSdkService, ); this.cipherArchiveService = new DefaultCipherArchiveService( @@ -1058,7 +1081,6 @@ export class ServiceContainer { this.containerService.attachToGlobal(global); await this.i18nService.init(); this.twoFactorService.init(); - this.encryptService.init(this.configService); // If a user has a BW_SESSION key stored in their env (not process.env.BW_SESSION), // this should set the user key to unlock the vault on init. diff --git a/apps/cli/src/tools/send/commands/create.command.spec.ts b/apps/cli/src/tools/send/commands/create.command.spec.ts new file mode 100644 index 00000000000..d3702689812 --- /dev/null +++ b/apps/cli/src/tools/send/commands/create.command.spec.ts @@ -0,0 +1,386 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { UserId } from "@bitwarden/user-core"; + +import { SendCreateCommand } from "./create.command"; + +describe("SendCreateCommand", () => { + let command: SendCreateCommand; + + const sendService = mock(); + const environmentService = mock(); + const sendApiService = mock(); + const accountProfileService = mock(); + const accountService = mock(); + + const activeAccount = { + id: "user-id" as UserId, + ...mockAccountInfoWith({ + email: "user@example.com", + name: "User", + }), + }; + + beforeEach(() => { + jest.clearAllMocks(); + + accountService.activeAccount$ = of(activeAccount); + accountProfileService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + environmentService.environment$ = of({ + getWebVaultUrl: () => "https://vault.bitwarden.com", + } as any); + + command = new SendCreateCommand( + sendService, + environmentService, + sendApiService, + accountProfileService, + accountService, + ); + }); + + describe("authType inference", () => { + const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + describe("with CLI flags", () => { + it("should set authType to Email when emails are provided via CLI", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + email: ["test@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", emails: "test@example.com", authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + expect(sendService.encrypt).toHaveBeenCalledWith( + expect.objectContaining({ + type: SendType.Text, + }), + null, + undefined, + ); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("test@example.com"); + }); + + it("should set authType to Password when password is provided via CLI", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + password: "testPassword123", + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.Password } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + expect(sendService.encrypt).toHaveBeenCalledWith( + expect.any(Object), + null as any, + "testPassword123", + ); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Password); + }); + + it("should set authType to None when neither emails nor password provided", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = {}; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + expect(sendService.encrypt).toHaveBeenCalledWith(expect.any(Object), null, undefined); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should return error when both emails and password provided via CLI", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + email: ["test@example.com"], + password: "testPassword123", + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + }); + + describe("with JSON input", () => { + it("should set authType to Email when emails provided in JSON", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: ["test@example.com", "another@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { + id: "send-id", + emails: "test@example.com,another@example.com", + authType: AuthType.Email, + } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("test@example.com,another@example.com"); + }); + + it("should set authType to Password when password provided in JSON", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + password: "jsonPassword123", + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.Password } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Password); + }); + + it("should return error when both emails and password provided in JSON", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: ["test@example.com"], + password: "jsonPassword123", + }; + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + }); + + describe("with mixed CLI and JSON input", () => { + it("should return error when CLI emails combined with JSON password", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + password: "jsonPassword123", + }; + + const cmdOptions = { + email: ["cli@example.com"], + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + + it("should return error when CLI password combined with JSON emails", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: ["json@example.com"], + }; + + const cmdOptions = { + password: "cliPassword123", + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + + it("should use CLI value when JSON has different value of same type", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: ["json@example.com"], + }; + + const cmdOptions = { + email: ["cli@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", emails: "cli@example.com", authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("cli@example.com"); + }); + }); + + describe("edge cases", () => { + it("should set authType to None when emails array is empty", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: [] as string[], + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should set authType to None when password is empty string", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + password: "", + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should set authType to None when password is whitespace only", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + password: " ", + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + }); + }); +}); diff --git a/apps/cli/src/tools/send/commands/create.command.ts b/apps/cli/src/tools/send/commands/create.command.ts index 7803f6f94d4..ad4ff9c4e18 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -9,16 +9,16 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { NodeUtils } from "@bitwarden/node/node-utils"; import { Response } from "../../../models/response"; import { CliUtils } from "../../../utils"; import { SendTextResponse } from "../models/send-text.response"; import { SendResponse } from "../models/send.response"; - export class SendCreateCommand { constructor( private sendService: SendService, @@ -81,12 +81,24 @@ export class SendCreateCommand { const emails = req.emails ?? options.emails ?? undefined; const maxAccessCount = req.maxAccessCount ?? options.maxAccessCount; - if (emails !== undefined && password !== undefined) { + const hasEmails = emails != null && emails.length > 0; + const hasPassword = password != null && password.trim().length > 0; + + if (hasEmails && hasPassword) { return Response.badRequest("--password and --emails are mutually exclusive."); } req.key = null; req.maxAccessCount = maxAccessCount; + req.emails = emails; + + if (hasEmails) { + req.authType = AuthType.Email; + } else if (hasPassword) { + req.authType = AuthType.Password; + } else { + req.authType = AuthType.None; + } const hasPremium$ = this.accountService.activeAccount$.pipe( switchMap(({ id }) => this.accountProfileService.hasPremiumFromAnySource$(id)), @@ -136,11 +148,6 @@ export class SendCreateCommand { const sendView = SendResponse.toView(req); const [encSend, fileData] = await this.sendService.encrypt(sendView, fileBuffer, password); - // Add dates from template - encSend.deletionDate = sendView.deletionDate; - encSend.expirationDate = sendView.expirationDate; - encSend.emails = emails && emails.join(","); - await this.sendApiService.save([encSend, fileData]); const newSend = await this.sendService.getFromState(encSend.id); const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); diff --git a/apps/cli/src/tools/send/commands/edit.command.spec.ts b/apps/cli/src/tools/send/commands/edit.command.spec.ts new file mode 100644 index 00000000000..5bac63d3821 --- /dev/null +++ b/apps/cli/src/tools/send/commands/edit.command.spec.ts @@ -0,0 +1,400 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { UserId } from "@bitwarden/user-core"; + +import { Response } from "../../../models/response"; +import { SendResponse } from "../models/send.response"; + +import { SendEditCommand } from "./edit.command"; +import { SendGetCommand } from "./get.command"; + +describe("SendEditCommand", () => { + let command: SendEditCommand; + + const sendService = mock(); + const getCommand = mock(); + const sendApiService = mock(); + const accountProfileService = mock(); + const accountService = mock(); + + const activeAccount = { + id: "user-id" as UserId, + ...mockAccountInfoWith({ + email: "user@example.com", + name: "User", + }), + }; + + const mockSendId = "send-123"; + const mockSendView = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + text: { text: "test content", hidden: false }, + deletionDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + } as SendView; + + const mockSend = { + id: mockSendId, + type: SendType.Text, + decrypt: jest.fn().mockResolvedValue(mockSendView), + }; + + const encodeRequest = (data: any) => Buffer.from(JSON.stringify(data)).toString("base64"); + + beforeEach(() => { + jest.clearAllMocks(); + + accountService.activeAccount$ = of(activeAccount); + accountProfileService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + sendService.getFromState.mockResolvedValue(mockSend as any); + getCommand.run.mockResolvedValue(Response.success(new SendResponse(mockSendView)) as any); + + command = new SendEditCommand( + sendService, + getCommand, + sendApiService, + accountProfileService, + accountService, + ); + }); + + describe("authType inference", () => { + describe("with CLI flags", () => { + it("should set authType to Email when emails are provided via CLI", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + email: ["test@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, emails: "test@example.com", authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("test@example.com"); + }); + + it("should set authType to Password when password is provided via CLI", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + password: "testPassword123", + }; + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.Password } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Password); + }); + + it("should set authType to None when neither emails nor password provided", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = {}; + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should return error when both emails and password provided via CLI", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + email: ["test@example.com"], + password: "testPassword123", + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + }); + + describe("with JSON input", () => { + it("should set authType to Email when emails provided in JSON", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: ["test@example.com", "another@example.com"], + }; + const requestJson = encodeRequest(requestData); + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + }); + + it("should set authType to Password when password provided in JSON", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + password: "jsonPassword123", + }; + const requestJson = encodeRequest(requestData); + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.Password } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Password); + }); + + it("should return error when both emails and password provided in JSON", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: ["test@example.com"], + password: "jsonPassword123", + }; + const requestJson = encodeRequest(requestData); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + }); + + describe("with mixed CLI and JSON input", () => { + it("should return error when CLI emails combined with JSON password", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + password: "jsonPassword123", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + email: ["cli@example.com"], + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + + it("should return error when CLI password combined with JSON emails", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: ["json@example.com"], + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + password: "cliPassword123", + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + + it("should prioritize CLI value when JSON has different value of same type", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: ["json@example.com"], + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + email: ["cli@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, emails: "cli@example.com", authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("cli@example.com"); + }); + }); + + describe("edge cases", () => { + it("should set authType to None when emails array is empty", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: [] as string[], + }; + const requestJson = encodeRequest(requestData); + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should set authType to None when password is empty string", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + password: "", + }; + const requestJson = encodeRequest(requestData); + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should handle send not found", async () => { + sendService.getFromState.mockResolvedValue(null); + + const requestData = { + id: "nonexistent-id", + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + }); + + it("should handle type mismatch", async () => { + const requestData = { + id: mockSendId, + type: SendType.File, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("Cannot change a Send's type"); + }); + }); + }); + + describe("validation", () => { + it("should return error when requestJson is empty", async () => { + // Set BW_SERVE to prevent readStdin call + process.env.BW_SERVE = "true"; + + const response = await command.run("", {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("`requestJson` was not provided."); + + delete process.env.BW_SERVE; + }); + + it("should return error when id is not provided", async () => { + const requestData = { + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("`itemid` was not provided."); + }); + }); +}); diff --git a/apps/cli/src/tools/send/commands/edit.command.ts b/apps/cli/src/tools/send/commands/edit.command.ts index bf53c8a5cb9..0709a33b88f 100644 --- a/apps/cli/src/tools/send/commands/edit.command.ts +++ b/apps/cli/src/tools/send/commands/edit.command.ts @@ -5,9 +5,10 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { Response } from "../../../models/response"; import { CliUtils } from "../../../utils"; @@ -53,14 +54,30 @@ export class SendEditCommand { req.id = normalizedOptions.itemId || req.id; if (normalizedOptions.emails) { req.emails = normalizedOptions.emails; - req.password = undefined; - } else if (normalizedOptions.password) { - req.emails = undefined; + } + if (normalizedOptions.password) { req.password = normalizedOptions.password; - } else if (req.password && (typeof req.password !== "string" || req.password === "")) { + } + if (req.password && (typeof req.password !== "string" || req.password === "")) { req.password = undefined; } + // Infer authType based on emails/password (mutually exclusive) + const hasEmails = req.emails != null && req.emails.length > 0; + const hasPassword = req.password != null && req.password.trim() !== ""; + + if (hasEmails && hasPassword) { + return Response.badRequest("--password and --emails are mutually exclusive."); + } + + if (hasEmails) { + req.authType = AuthType.Email; + } else if (hasPassword) { + req.authType = AuthType.Password; + } else { + req.authType = AuthType.None; + } + if (!req.id) { return Response.error("`itemid` was not provided."); } @@ -90,10 +107,6 @@ export class SendEditCommand { try { const [encSend, encFileData] = await this.sendService.encrypt(sendView, null, req.password); - // Add dates from template - encSend.deletionDate = sendView.deletionDate; - encSend.expirationDate = sendView.expirationDate; - await this.sendApiService.save([encSend, encFileData]); } catch (e) { return Response.error(e); diff --git a/apps/cli/src/tools/send/commands/receive.command.spec.ts b/apps/cli/src/tools/send/commands/receive.command.spec.ts new file mode 100644 index 00000000000..fe982905059 --- /dev/null +++ b/apps/cli/src/tools/send/commands/receive.command.spec.ts @@ -0,0 +1,560 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { SendTokenService, SendAccessToken } 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 { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { KeyService } from "@bitwarden/key-management"; + +import { Response } from "../../../models/response"; + +import { SendReceiveCommand } from "./receive.command"; + +describe("SendReceiveCommand", () => { + let command: SendReceiveCommand; + + const keyService = mock(); + const encryptService = mock(); + const cryptoFunctionService = mock(); + const platformUtilsService = mock(); + const environmentService = mock(); + const sendApiService = mock(); + const apiService = mock(); + const sendTokenService = mock(); + const configService = mock(); + + const testUrl = "https://send.bitwarden.com/#/send/abc123/key456"; + const testSendId = "abc123"; + + beforeEach(() => { + jest.clearAllMocks(); + + environmentService.environment$ = of({ + getUrls: () => ({ + api: "https://api.bitwarden.com", + webVault: "https://vault.bitwarden.com", + }), + } as any); + + platformUtilsService.isDev.mockReturnValue(false); + + keyService.makeSendKey.mockResolvedValue({} as any); + + cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32)); + + command = new SendReceiveCommand( + keyService, + encryptService, + cryptoFunctionService, + platformUtilsService, + environmentService, + sendApiService, + apiService, + sendTokenService, + configService, + ); + }); + + describe("URL parsing", () => { + it("should return error for invalid URL", async () => { + const response = await command.run("not-a-valid-url", {}); + + expect(response.success).toBe(false); + expect(response.message).toContain("Failed to parse"); + }); + + it("should return error when URL is missing send ID or key", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + const response = await command.run("https://send.bitwarden.com/#/send/", {}); + + expect(response.success).toBe(false); + expect(response.message).toContain("not a valid Send url"); + }); + }); + + describe("V1 Flow (Feature Flag Off)", () => { + beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(false); + }); + + it("should successfully access unprotected Send", async () => { + const mockSendAccess = { + id: testSendId, + type: SendType.Text, + text: { text: "secret message" }, + }; + + sendApiService.postSendAccess.mockResolvedValue({} as any); + + jest.spyOn(command as any, "sendRequest").mockResolvedValue(mockSendAccess); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(true); + }); + + it("should successfully access password-protected Send with --password option", async () => { + const mockSendAccess = { + id: testSendId, + type: SendType.Text, + text: { text: "secret message" }, + }; + + sendApiService.postSendAccess.mockResolvedValue({} as any); + jest.spyOn(command as any, "sendRequest").mockResolvedValue(mockSendAccess); + + const response = await command.run(testUrl, { password: "test-password" }); + + expect(response.success).toBe(true); + expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledWith( + "test-password", + expect.any(Uint8Array), + "sha256", + 100000, + ); + }); + + it("should return error for incorrect password in non-interactive mode", async () => { + process.env.BW_NOINTERACTION = "true"; + + const error = new ErrorResponse( + { + statusCode: 401, + message: "Unauthorized", + }, + 401, + ); + + sendApiService.postSendAccess.mockRejectedValue(error); + + const response = await command.run(testUrl, { password: "wrong-password" }); + + expect(response.success).toBe(false); + expect(response.message).toContain("Incorrect or missing password"); + + delete process.env.BW_NOINTERACTION; + }); + + it("should return 404 for non-existent Send", async () => { + const error = new ErrorResponse( + { + statusCode: 404, + message: "Not found", + }, + 404, + ); + + sendApiService.postSendAccess.mockRejectedValue(error); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(false); + }); + }); + + describe("V2 Flow (Feature Flag On)", () => { + beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(true); + }); + + describe("Unprotected Sends", () => { + it("should successfully access Send with cached token", async () => { + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken)); + sendApiService.postSendAccessV2.mockResolvedValue({} as any); + jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success()); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(true); + expect(sendTokenService.tryGetSendAccessToken$).toHaveBeenCalledWith(testSendId); + }); + + it("should handle expired token and determine auth type", async () => { + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "password_hash_b64_required", + }, + } as any), + ); + + // Mock password auth flow + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.getSendAccessToken$.mockReturnValue(of(mockToken)); + jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success()); + + const response = await command.run(testUrl, { password: "test-password" }); + + expect(response.success).toBe(true); + }); + }); + + describe("Password Authentication (V2)", () => { + it("should successfully authenticate with password", async () => { + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "password_hash_b64_required", + }, + } as any), + ); + + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.getSendAccessToken$.mockReturnValue(of(mockToken)); + sendApiService.postSendAccessV2.mockResolvedValue({} as any); + jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success()); + + const response = await command.run(testUrl, { password: "correct-password" }); + + expect(response.success).toBe(true); + expect(sendTokenService.getSendAccessToken$).toHaveBeenCalledWith( + testSendId, + expect.objectContaining({ + kind: "password", + passwordHashB64: expect.any(String), + }), + ); + }); + + it("should return error for invalid password", async () => { + process.env.BW_NOINTERACTION = "true"; + + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "password_hash_b64_required", + }, + } as any), + ); + + sendTokenService.getSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_grant", + send_access_error_type: "password_hash_b64_invalid", + }, + } as any), + ); + + const response = await command.run(testUrl, { password: "wrong-password" }); + + expect(response.success).toBe(false); + expect(response.message).toContain("Invalid password"); + + delete process.env.BW_NOINTERACTION; + }); + + it("should work with --passwordenv option", async () => { + process.env.TEST_SEND_PASSWORD = "env-password"; + process.env.BW_NOINTERACTION = "true"; + + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "password_hash_b64_required", + }, + } as any), + ); + + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.getSendAccessToken$.mockReturnValue(of(mockToken)); + jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success()); + + const response = await command.run(testUrl, { passwordenv: "TEST_SEND_PASSWORD" }); + + expect(response.success).toBe(true); + + delete process.env.TEST_SEND_PASSWORD; + delete process.env.BW_NOINTERACTION; + }); + }); + + describe("Email OTP Authentication (V2)", () => { + it("should return error in non-interactive mode for email OTP", async () => { + process.env.BW_NOINTERACTION = "true"; + + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "email_required", + }, + } as any), + ); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(false); + expect(response.message).toContain("Email verification required"); + expect(response.message).toContain("interactive mode"); + + delete process.env.BW_NOINTERACTION; + }); + + it("should handle email submission and OTP prompt flow", async () => { + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "email_required", + }, + } as any), + ); + + sendTokenService.getSendAccessToken$.mockReturnValueOnce( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "email_and_otp_required_otp_sent", + }, + } as any), + ); + + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.getSendAccessToken$.mockReturnValueOnce(of(mockToken)); + + // We can't easily test the interactive prompts, but we can verify the token service calls + // would be made in the right order + expect(sendTokenService.getSendAccessToken$).toBeDefined(); + }); + + it("should handle invalid email error", async () => { + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "email_required", + }, + } as any), + ); + + sendTokenService.getSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_grant", + send_access_error_type: "email_invalid", + }, + } as any), + ); + + // In a real scenario with interactive prompts, this would retry + // For unit tests, we verify the error is recognized + expect(sendTokenService.getSendAccessToken$).toBeDefined(); + }); + + it("should handle invalid OTP error", async () => { + sendTokenService.getSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_grant", + send_access_error_type: "otp_invalid", + }, + } as any), + ); + + // Verify OTP validation would be handled + expect(sendTokenService.getSendAccessToken$).toBeDefined(); + }); + }); + + describe("File Downloads (V2)", () => { + it("should successfully download file Send with V2 API", async () => { + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken)); + + const mockSendResponse = { + id: testSendId, + type: SendType.File, + file: { + id: "file-123", + fileName: "test.pdf", + size: 1024, + }, + }; + + sendApiService.postSendAccessV2.mockResolvedValue(mockSendResponse as any); + sendApiService.getSendFileDownloadDataV2.mockResolvedValue({ + url: "https://example.com/download", + } as any); + + encryptService.decryptFileData.mockResolvedValue(new ArrayBuffer(1024) as any); + jest.spyOn(command as any, "saveAttachmentToFile").mockResolvedValue(Response.success()); + + await command.run(testUrl, { output: "./test.pdf" }); + + expect(sendApiService.getSendFileDownloadDataV2).toHaveBeenCalledWith( + expect.any(Object), + mockToken, + "https://api.bitwarden.com", + ); + }); + }); + + describe("Invalid Send ID", () => { + it("should return 404 for invalid Send ID", async () => { + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_grant", + send_access_error_type: "send_id_invalid", + }, + } as any), + ); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(false); + }); + }); + + describe("Text Send Output", () => { + it("should output text to stdout for text Sends", async () => { + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken)); + + const secretText = "This is a secret message"; + + sendApiService.postSendAccessV2.mockResolvedValue({} as any); + + // Mock the entire accessSendWithToken to avoid encryption issues + jest.spyOn(command as any, "accessSendWithToken").mockImplementation(async () => { + process.stdout.write(secretText); + return Response.success(); + }); + + const stdoutSpy = jest.spyOn(process.stdout, "write").mockImplementation(() => true); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(true); + expect(stdoutSpy).toHaveBeenCalledWith(secretText); + + stdoutSpy.mockRestore(); + }); + + it("should return JSON object when --obj flag is used", async () => { + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken)); + + const mockDecryptedView = { + id: testSendId, + type: SendType.Text, + text: { text: "secret message" }, + }; + + sendApiService.postSendAccessV2.mockResolvedValue({} as any); + + // Mock the entire accessSendWithToken to avoid encryption issues + jest.spyOn(command as any, "accessSendWithToken").mockImplementation(async () => { + const sendAccessResponse = new SendAccessResponse(mockDecryptedView as any); + const res = new Response(); + res.success = true; + res.data = sendAccessResponse as any; + return res; + }); + + const response = await command.run(testUrl, { obj: true }); + + expect(response.success).toBe(true); + expect(response.data).toBeDefined(); + expect(response.data.constructor.name).toBe("SendAccessResponse"); + }); + }); + }); + + describe("API URL Resolution", () => { + it("should resolve send.bitwarden.com to api.bitwarden.com", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + const sendUrl = "https://send.bitwarden.com/#/send/abc123/key456"; + sendApiService.postSendAccess.mockResolvedValue({} as any); + jest.spyOn(command as any, "sendRequest").mockResolvedValue({ + type: SendType.Text, + text: { text: "test" }, + }); + + await command.run(sendUrl, {}); + + const apiUrl = await (command as any).getApiUrl(new URL(sendUrl)); + expect(apiUrl).toBe("https://api.bitwarden.com"); + }); + + it("should handle custom domain URLs", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + const customUrl = "https://custom.example.com/#/send/abc123/key456"; + sendApiService.postSendAccess.mockResolvedValue({} as any); + jest.spyOn(command as any, "sendRequest").mockResolvedValue({ + type: SendType.Text, + text: { text: "test" }, + }); + + await command.run(customUrl, {}); + + const apiUrl = await (command as any).getApiUrl(new URL(customUrl)); + expect(apiUrl).toBe("https://custom.example.com/api"); + }); + }); + + describe("Feature Flag Routing", () => { + it("should route to V1 flow when feature flag is off", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + sendApiService.postSendAccess.mockResolvedValue({} as any); + const v1Spy = jest.spyOn(command as any, "attemptV1Access"); + jest.spyOn(command as any, "sendRequest").mockResolvedValue({ + type: SendType.Text, + text: { text: "test" }, + }); + + await command.run(testUrl, {}); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith(FeatureFlag.SendEmailOTP); + expect(v1Spy).toHaveBeenCalled(); + }); + + it("should route to V2 flow when feature flag is on", async () => { + configService.getFeatureFlag.mockResolvedValue(true); + + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken)); + + const v2Spy = jest.spyOn(command as any, "attemptV2Access"); + jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success()); + + await command.run(testUrl, {}); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith(FeatureFlag.SendEmailOTP); + expect(v2Spy).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/cli/src/tools/send/commands/receive.command.ts b/apps/cli/src/tools/send/commands/receive.command.ts index a412f7c1667..9496855a7a5 100644 --- a/apps/cli/src/tools/send/commands/receive.command.ts +++ b/apps/cli/src/tools/send/commands/receive.command.ts @@ -5,19 +5,36 @@ import * as inquirer from "inquirer"; import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { + SendTokenService, + SendAccessToken, + emailRequired, + emailAndOtpRequired, + otpInvalid, + passwordHashB64Required, + passwordHashB64Invalid, + sendIdInvalid, + SendHashedPasswordB64, + SendOtp, + GetSendAccessTokenError, + SendAccessDomainCredentials, +} 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 { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-access"; import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { KeyService } from "@bitwarden/key-management"; import { NodeUtils } from "@bitwarden/node/node-utils"; @@ -38,6 +55,8 @@ export class SendReceiveCommand extends DownloadCommand { private environmentService: EnvironmentService, private sendApiService: SendApiService, apiService: ApiService, + private sendTokenService: SendTokenService, + private configService: ConfigService, ) { super(encryptService, apiService); } @@ -62,58 +81,13 @@ export class SendReceiveCommand extends DownloadCommand { } const keyArray = Utils.fromUrlB64ToArray(key); - this.sendAccessRequest = new SendAccessRequest(); - let password = options.password; - if (password == null || password === "") { - if (options.passwordfile) { - password = await NodeUtils.readFirstLine(options.passwordfile); - } else if (options.passwordenv && process.env[options.passwordenv]) { - password = process.env[options.passwordenv]; - } - } + const sendEmailOtpEnabled = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); - if (password != null && password !== "") { - this.sendAccessRequest.password = await this.getUnlockedPassword(password, keyArray); - } - - const response = await this.sendRequest(apiUrl, id, keyArray); - - if (response instanceof Response) { - // Error scenario - return response; - } - - if (options.obj != null) { - return Response.success(new SendAccessResponse(response)); - } - - switch (response.type) { - case SendType.Text: - // Write to stdout and response success so we get the text string only to stdout - process.stdout.write(response?.text?.text); - return Response.success(); - case SendType.File: { - const downloadData = await this.sendApiService.getSendFileDownloadData( - response, - this.sendAccessRequest, - apiUrl, - ); - - const decryptBufferFn = async (resp: globalThis.Response) => { - const encBuf = await EncArrayBuffer.fromResponse(resp); - return this.encryptService.decryptFileData(encBuf, this.decKey); - }; - - return await this.saveAttachmentToFile( - downloadData.url, - response?.file?.fileName, - decryptBufferFn, - options.output, - ); - } - default: - return Response.success(new SendAccessResponse(response)); + if (sendEmailOtpEnabled) { + return await this.attemptV2Access(apiUrl, id, keyArray, options); + } else { + return await this.attemptV1Access(apiUrl, id, keyArray, options); } } @@ -146,6 +120,350 @@ export class SendReceiveCommand extends DownloadCommand { return Utils.fromBufferToB64(passwordHash); } + private async attemptV1Access( + apiUrl: string, + id: string, + keyArray: Uint8Array, + options: OptionValues, + ): Promise { + this.sendAccessRequest = new SendAccessRequest(); + + let password = options.password; + if (password == null || password === "") { + if (options.passwordfile) { + password = await NodeUtils.readFirstLine(options.passwordfile); + } else if (options.passwordenv && process.env[options.passwordenv]) { + password = process.env[options.passwordenv]; + } + } + + if (password != null && password !== "") { + this.sendAccessRequest.password = await this.getUnlockedPassword(password, keyArray); + } + + const response = await this.sendRequest(apiUrl, id, keyArray); + + if (response instanceof Response) { + return response; + } + + if (options.obj != null) { + return Response.success(new SendAccessResponse(response)); + } + + switch (response.type) { + case SendType.Text: + process.stdout.write(response?.text?.text); + return Response.success(); + case SendType.File: { + const downloadData = await this.sendApiService.getSendFileDownloadData( + response, + this.sendAccessRequest, + apiUrl, + ); + + const decryptBufferFn = async (resp: globalThis.Response) => { + const encBuf = await EncArrayBuffer.fromResponse(resp); + return this.encryptService.decryptFileData(encBuf, this.decKey); + }; + + return await this.saveAttachmentToFile( + downloadData.url, + response?.file?.fileName, + decryptBufferFn, + options.output, + ); + } + default: + return Response.success(new SendAccessResponse(response)); + } + } + + private async attemptV2Access( + apiUrl: string, + id: string, + keyArray: Uint8Array, + options: OptionValues, + ): Promise { + let authType: AuthType = AuthType.None; + + const currentResponse = await this.getTokenWithRetry(id); + + if (currentResponse instanceof SendAccessToken) { + return await this.accessSendWithToken(currentResponse, keyArray, apiUrl, options); + } + + if (currentResponse.kind === "expected_server") { + const error = currentResponse.error; + + if (emailRequired(error)) { + authType = AuthType.Email; + } else if (passwordHashB64Required(error)) { + authType = AuthType.Password; + } else if (sendIdInvalid(error)) { + return Response.notFound(); + } + } else { + return this.handleError(currentResponse); + } + + // Handle authentication based on type + if (authType === AuthType.Email) { + if (!this.canInteract) { + return Response.badRequest("Email verification required. Run in interactive mode."); + } + return await this.handleEmailOtpAuth(id, keyArray, apiUrl, options); + } else if (authType === AuthType.Password) { + return await this.handlePasswordAuth(id, keyArray, apiUrl, options); + } + + // The auth layer will immediately return a token for Sends with AuthType.None + // If this code is reached, something has gone wrong + if (authType === AuthType.None) { + return Response.error("Could not determine authentication requirements"); + } + + return Response.error("Authentication failed"); + } + + private async getTokenWithRetry( + sendId: string, + credentials?: SendAccessDomainCredentials, + ): Promise { + let expiredAttempts = 0; + + while (expiredAttempts < 3) { + const response = credentials + ? await firstValueFrom(this.sendTokenService.getSendAccessToken$(sendId, credentials)) + : await firstValueFrom(this.sendTokenService.tryGetSendAccessToken$(sendId)); + + if (response instanceof SendAccessToken) { + return response; + } + + if (response.kind === "expired") { + expiredAttempts++; + continue; + } + + // Not expired, return the response for caller to handle + return response; + } + + // After 3 expired attempts, return an error response + return { + kind: "unknown", + error: "Send access token has expired and could not be refreshed", + }; + } + + private handleError(error: GetSendAccessTokenError): Response { + if (error.kind === "unexpected_server") { + return Response.error("Server error: " + JSON.stringify(error.error)); + } + + return Response.error("Error: " + JSON.stringify(error.error)); + } + + private async promptForOtp(sendId: string, email: string): Promise { + const otpAnswer = await inquirer.createPromptModule({ output: process.stderr })({ + type: "input", + name: "otp", + message: "Enter the verification code sent to your email:", + }); + return otpAnswer.otp; + } + + private async promptForEmail(): Promise { + const emailAnswer = await inquirer.createPromptModule({ output: process.stderr })({ + type: "input", + name: "email", + message: "Enter your email address:", + validate: (input: string) => { + if (!input || !input.includes("@")) { + return "Please enter a valid email address"; + } + return true; + }, + }); + return emailAnswer.email; + } + + private async handleEmailOtpAuth( + sendId: string, + keyArray: Uint8Array, + apiUrl: string, + options: OptionValues, + ): Promise { + const email = await this.promptForEmail(); + + const emailResponse = await this.getTokenWithRetry(sendId, { + kind: "email", + email: email, + }); + + if (emailResponse instanceof SendAccessToken) { + /* + At this point emailResponse should only be expected to be a GetSendAccessTokenError type, + but TS must have a logical branch in case it is a SendAccessToken type. If a valid token is + returned by the method above, something has gone wrong. + */ + + return Response.error("Unexpected server response"); + } + + if (emailResponse.kind === "expected_server") { + const error = emailResponse.error; + + if (emailAndOtpRequired(error)) { + const promptResponse = await this.promptForOtp(sendId, email); + + // Use retry helper for expired token handling + const otpResponse = await this.getTokenWithRetry(sendId, { + kind: "email_otp", + email: email, + otp: promptResponse, + }); + + if (otpResponse instanceof SendAccessToken) { + return await this.accessSendWithToken(otpResponse, keyArray, apiUrl, options); + } + + if (otpResponse.kind === "expected_server") { + const error = otpResponse.error; + + if (otpInvalid(error)) { + return Response.badRequest("Invalid email or verification code"); + } + + /* + If the following evaluates to true, it means that the email address provided was not + configured to be used for email OTP for this Send. + + To avoid leaking information that would allow email enumeration, instead return an + error indicating that some component of the email OTP challenge was invalid. + */ + if (emailAndOtpRequired(error)) { + return Response.badRequest("Invalid email or verification code"); + } + } + return this.handleError(otpResponse); + } + } + return this.handleError(emailResponse); + } + + private async handlePasswordAuth( + sendId: string, + keyArray: Uint8Array, + apiUrl: string, + options: OptionValues, + ): Promise { + let password = options.password; + + if (password == null || password === "") { + if (options.passwordfile) { + password = await NodeUtils.readFirstLine(options.passwordfile); + } else if (options.passwordenv && process.env[options.passwordenv]) { + password = process.env[options.passwordenv]; + } + } + + if ((password == null || password === "") && this.canInteract) { + const answer = await inquirer.createPromptModule({ output: process.stderr })({ + type: "password", + name: "password", + message: "Send password:", + }); + password = answer.password; + } + + if (!password) { + return Response.badRequest("Password required"); + } + + const passwordHashB64 = await this.getUnlockedPassword(password, keyArray); + + // Use retry helper for expired token handling + const response = await this.getTokenWithRetry(sendId, { + kind: "password", + passwordHashB64: passwordHashB64 as SendHashedPasswordB64, + }); + + if (response instanceof SendAccessToken) { + return await this.accessSendWithToken(response, keyArray, apiUrl, options); + } + + if (response.kind === "expected_server") { + const error = response.error; + + if (passwordHashB64Invalid(error)) { + return Response.badRequest("Invalid password"); + } + } else if (response.kind === "unexpected_server") { + return Response.error("Server error: " + JSON.stringify(response.error)); + } else if (response.kind === "unknown") { + return Response.error("Error: " + response.error); + } + + return Response.error("Authentication failed"); + } + + private async accessSendWithToken( + accessToken: SendAccessToken, + keyArray: Uint8Array, + apiUrl: string, + options: OptionValues, + ): Promise { + try { + const sendResponse = await this.sendApiService.postSendAccessV2(accessToken, apiUrl); + + const sendAccess = new SendAccess(sendResponse); + this.decKey = await this.keyService.makeSendKey(keyArray); + const decryptedView = await sendAccess.decrypt(this.decKey); + + if (options.obj != null) { + return Response.success(new SendAccessResponse(decryptedView)); + } + + switch (decryptedView.type) { + case SendType.Text: + process.stdout.write(decryptedView?.text?.text); + return Response.success(); + + case SendType.File: { + const downloadData = await this.sendApiService.getSendFileDownloadDataV2( + decryptedView, + accessToken, + apiUrl, + ); + + const decryptBufferFn = async (resp: globalThis.Response) => { + const encBuf = await EncArrayBuffer.fromResponse(resp); + return this.encryptService.decryptFileData(encBuf, this.decKey); + }; + + return await this.saveAttachmentToFile( + downloadData.url, + decryptedView?.file?.fileName, + decryptBufferFn, + options.output, + ); + } + + default: + return Response.success(new SendAccessResponse(decryptedView)); + } + } catch (e) { + if (e instanceof ErrorResponse) { + if (e.statusCode === 404) { + return Response.notFound(); + } + } + return Response.error(e); + } + } + private async sendRequest( url: string, id: string, diff --git a/apps/cli/src/tools/send/commands/template.command.ts b/apps/cli/src/tools/send/commands/template.command.ts index c1c2c97b03d..09213ac5fa8 100644 --- a/apps/cli/src/tools/send/commands/template.command.ts +++ b/apps/cli/src/tools/send/commands/template.command.ts @@ -1,4 +1,4 @@ -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { Response } from "../../../models/response"; import { TemplateResponse } from "../../../models/response/template.response"; diff --git a/apps/cli/src/tools/send/models/send-access.response.ts b/apps/cli/src/tools/send/models/send-access.response.ts index 07877bfb548..7bd54801307 100644 --- a/apps/cli/src/tools/send/models/send-access.response.ts +++ b/apps/cli/src/tools/send/models/send-access.response.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseResponse } from "../../../models/response/base.response"; diff --git a/apps/cli/src/tools/send/models/send.response.ts b/apps/cli/src/tools/send/models/send.response.ts index a0c1d3f83c6..c8182cbfaf8 100644 --- a/apps/cli/src/tools/send/models/send.response.ts +++ b/apps/cli/src/tools/send/models/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 { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { 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"; @@ -54,6 +55,7 @@ export class SendResponse implements BaseResponse { view.emails = send.emails ?? []; view.disabled = send.disabled; view.hideEmail = send.hideEmail; + view.authType = send.authType; return view; } @@ -92,6 +94,7 @@ export class SendResponse implements BaseResponse { emails?: Array; disabled: boolean; hideEmail: boolean; + authType: AuthType; constructor(o?: SendView, webVaultUrl?: string) { if (o == null) { @@ -116,8 +119,10 @@ export class SendResponse implements BaseResponse { this.deletionDate = o.deletionDate; this.expirationDate = o.expirationDate; this.passwordSet = o.password != null; + this.emails = o.emails ?? []; this.disabled = o.disabled; this.hideEmail = o.hideEmail; + this.authType = o.authType; if (o.type === SendType.Text && o.text != null) { this.text = new SendTextResponse(o.text); diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index 33bf4518ccd..e40cea4daa9 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -6,8 +6,9 @@ import * as path from "path"; import * as chalk from "chalk"; import { program, Command, Option, OptionValues } from "commander"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseProgram } from "../../base-program"; import { Response } from "../../models/response"; @@ -31,13 +32,16 @@ import { parseEmail } from "./util"; const writeLn = CliUtils.writeLn; export class SendProgram extends BaseProgram { - register() { - program.addCommand(this.sendCommand()); + async register() { + const emailAuthEnabled = await this.serviceContainer.configService.getFeatureFlag( + FeatureFlag.SendEmailOTP, + ); + program.addCommand(this.sendCommand(emailAuthEnabled)); // receive is accessible both at `bw receive` and `bw send receive` program.addCommand(this.receiveCommand()); } - private sendCommand(): Command { + private sendCommand(emailAuthEnabled: boolean): Command { return new Command("send") .argument("", "The data to Send. Specify as a filepath with the --file option") .description( @@ -59,9 +63,7 @@ export class SendProgram extends BaseProgram { new Option( "--email ", "optional emails to access this Send. Can also be specified in JSON.", - ) - .argParser(parseEmail) - .hideHelp(), + ).argParser(parseEmail), ) .option("-a, --maxAccessCount ", "The amount of max possible accesses.") .option("--hidden", "Hide in web by default. Valid only if --file is not set.") @@ -78,11 +80,18 @@ export class SendProgram extends BaseProgram { .addCommand(this.templateCommand()) .addCommand(this.getCommand()) .addCommand(this.receiveCommand()) - .addCommand(this.createCommand()) - .addCommand(this.editCommand()) + .addCommand(this.createCommand(emailAuthEnabled)) + .addCommand(this.editCommand(emailAuthEnabled)) .addCommand(this.removePasswordCommand()) .addCommand(this.deleteCommand()) .action(async (data: string, options: OptionValues) => { + if (options.email) { + if (!emailAuthEnabled) { + this.processResponse(Response.error("The --email feature is not currently available.")); + return; + } + } + const encodedJson = this.makeSendJson(data, options); let response: Response; @@ -124,6 +133,8 @@ export class SendProgram extends BaseProgram { this.serviceContainer.environmentService, this.serviceContainer.sendApiService, this.serviceContainer.apiService, + this.serviceContainer.sendTokenService, + this.serviceContainer.configService, ); const response = await cmd.run(url, options); this.processResponse(response); @@ -199,7 +210,7 @@ export class SendProgram extends BaseProgram { }); } - private createCommand(): Command { + private createCommand(emailAuthEnabled: any): Command { return new Command("create") .argument("[encodedJson]", "JSON object to upload. Can also be piped in through stdin.") .description("create a Send") @@ -215,6 +226,14 @@ export class SendProgram extends BaseProgram { .action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => { // subcommands inherit flags from their parent; they cannot override them const { fullObject = false, email = undefined, password = undefined } = args.parent.opts(); + + if (email) { + if (!emailAuthEnabled) { + this.processResponse(Response.error("The --email feature is not currently available.")); + return; + } + } + const mergedOptions = { ...options, fullObject: fullObject, @@ -227,7 +246,7 @@ export class SendProgram extends BaseProgram { }); } - private editCommand(): Command { + private editCommand(emailAuthEnabled: any): Command { return new Command("edit") .argument( "[encodedJson]", @@ -243,6 +262,14 @@ export class SendProgram extends BaseProgram { }) .action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => { await this.exitIfLocked(); + const { email = undefined, password = undefined } = args.parent.opts(); + if (email) { + if (!emailAuthEnabled) { + this.processResponse(Response.error("The --email feature is not currently available.")); + return; + } + } + const getCmd = new SendGetCommand( this.serviceContainer.sendService, this.serviceContainer.environmentService, @@ -259,8 +286,6 @@ export class SendProgram extends BaseProgram { this.serviceContainer.accountService, ); - // subcommands inherit flags from their parent; they cannot override them - const { email = undefined, password = undefined } = args.parent.opts(); const mergedOptions = { ...options, email, @@ -328,6 +353,7 @@ export class SendProgram extends BaseProgram { file: sendFile, text: sendText, type: type, + emails: options.email ?? undefined, }); return Buffer.from(JSON.stringify(template), "utf8").toString("base64"); diff --git a/apps/cli/src/utils.ts b/apps/cli/src/utils.ts index e321adbfd5e..72746cb9b71 100644 --- a/apps/cli/src/utils.ts +++ b/apps/cli/src/utils.ts @@ -1,12 +1,10 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import * as fs from "fs"; import * as path from "path"; import * as inquirer from "inquirer"; import * as JSZip from "jszip"; -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; diff --git a/apps/cli/src/vault/archive.command.ts b/apps/cli/src/vault/archive.command.ts index 5ced2282c6d..0f634f78fb3 100644 --- a/apps/cli/src/vault/archive.command.ts +++ b/apps/cli/src/vault/archive.command.ts @@ -99,9 +99,6 @@ export class ArchiveCommand { errorMessage: "Item is in the trash, the item must be restored before archiving.", }; } - case cipher.organizationId != null: { - return { canArchive: false, errorMessage: "Cannot archive items in an organization." }; - } default: return { canArchive: true }; } diff --git a/apps/cli/src/vault/create.command.ts b/apps/cli/src/vault/create.command.ts index d826766dc65..e1a91966afd 100644 --- a/apps/cli/src/vault/create.command.ts +++ b/apps/cli/src/vault/create.command.ts @@ -103,10 +103,11 @@ export class CreateCommand { return Response.error("Creating this item type is restricted by organizational policy."); } - const cipher = await this.cipherService.encrypt(CipherExport.toView(req), activeUserId); - const newCipher = await this.cipherService.createWithServer(cipher); - const decCipher = await this.cipherService.decrypt(newCipher, activeUserId); - const res = new CipherResponse(decCipher); + const newCipher = await this.cipherService.createWithServer( + CipherExport.toView(req), + activeUserId, + ); + const res = new CipherResponse(newCipher); return Response.success(res); } catch (e) { return Response.error(e); diff --git a/apps/desktop/custom-appx-manifest.xml b/apps/desktop/custom-appx-manifest.xml new file mode 100644 index 00000000000..166b852588b --- /dev/null +++ b/apps/desktop/custom-appx-manifest.xml @@ -0,0 +1,111 @@ + + + + + + + + ${displayName} + ${publisherDisplayName} + A secure and free password manager for all of your devices. + assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index d4a5ccf7aca..7154c42ac89 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -47,6 +47,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.18" @@ -324,6 +333,23 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "autofill_provider" +version = "0.0.0" +dependencies = [ + "base64", + "desktop_core", + "futures", + "serde", + "serde_json", + "serde_with", + "tokio", + "tracing", + "tracing-oslog", + "tracing-subscriber", + "uniffi", +] + [[package]] name = "autotype" version = "0.0.0" @@ -351,9 +377,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.7.3" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "basic-toml" @@ -386,9 +412,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "bitwarden-russh" @@ -486,9 +512,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "camino" @@ -603,6 +629,18 @@ dependencies = [ "windows", ] +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -799,6 +837,41 @@ dependencies = [ "syn", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "der" version = "0.7.10" @@ -810,6 +883,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +dependencies = [ + "powerfmt", + "serde", +] + [[package]] name = "desktop_core" version = "0.0.0" @@ -835,6 +918,7 @@ dependencies = [ "oo7", "pin-project", "rand 0.9.2", + "rsa", "scopeguard", "secmem-proc", "security-framework", @@ -988,6 +1072,12 @@ version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecdsa" version = "0.16.9" @@ -1410,6 +1500,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.3" @@ -1425,7 +1521,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown", + "hashbrown 0.15.3", ] [[package]] @@ -1476,6 +1572,30 @@ dependencies = [ "windows", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -1562,6 +1682,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -1583,6 +1709,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.9.0" @@ -1590,7 +1727,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.3", + "serde", ] [[package]] @@ -1731,34 +1869,18 @@ checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.25" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" - -[[package]] -name = "macos_provider" -version = "0.0.0" -dependencies = [ - "desktop_core", - "futures", - "serde", - "serde_json", - "tokio", - "tracing", - "tracing-oslog", - "tracing-subscriber", - "uniffi", -] +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "matchers" @@ -2019,6 +2141,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-integer" version = "0.1.46" @@ -2258,9 +2386,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -2268,15 +2396,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] @@ -2317,7 +2445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.9.0", ] [[package]] @@ -2443,6 +2571,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2624,6 +2758,26 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex-automata" version = "0.4.9" @@ -2768,6 +2922,30 @@ dependencies = [ "sdd", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2914,12 +3092,45 @@ dependencies = [ ] [[package]] -name = "serial_test" -version = "3.2.0" +name = "serde_with" +version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" dependencies = [ - "futures", + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.9.0", + "schemars 0.9.0", + "schemars 1.2.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serial_test" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555" +dependencies = [ + "futures-executor", + "futures-util", "log", "once_cell", "parking_lot", @@ -2929,9 +3140,9 @@ dependencies = [ [[package]] name = "serial_test_derive" -version = "3.2.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" dependencies = [ "proc-macro2", "quote", @@ -2951,9 +3162,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", @@ -3011,9 +3222,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "smawk" @@ -3084,6 +3295,9 @@ dependencies = [ "bcrypt-pbkdf", "ed25519-dalek", "num-bigint-dig", + "p256", + "p384", + "p521", "rand_core 0.6.4", "rsa", "sec1", @@ -3095,6 +3309,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ssh_agent" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64", + "ssh-key", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -3232,6 +3455,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -3299,7 +3553,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" dependencies = [ - "indexmap", + "indexmap 2.9.0", "serde", "serde_spanned", "toml_datetime 0.7.0", @@ -3329,7 +3583,7 @@ version = "0.22.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" dependencies = [ - "indexmap", + "indexmap 2.9.0", "toml_datetime 0.6.9", "winnow", ] @@ -3351,9 +3605,9 @@ checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -3362,9 +3616,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -3373,9 +3627,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -3406,9 +3660,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 86eb507a6c1..09a4d603327 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -1,15 +1,16 @@ [workspace] resolver = "2" members = [ + "autofill_provider", "autotype", "bitwarden_chromium_import_helper", "chromium_importer", "core", - "macos_provider", "napi", "process_isolation", "proxy", - "windows_plugin_authenticator" + "ssh_agent", + "windows_plugin_authenticator", ] [workspace.package] @@ -27,7 +28,7 @@ ashpd = "=0.12.0" base64 = "=0.22.1" bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "a641316227227f8777fdf56ac9fa2d6b5f7fe662" } byteorder = "=1.5.0" -bytes = "=1.11.0" +bytes = "=1.11.1" cbc = "=0.1.2" chacha20poly1305 = "=0.10.1" core-foundation = "=0.10.1" @@ -58,15 +59,16 @@ security-framework = "=3.5.1" security-framework-sys = "=2.15.0" serde = "=1.0.209" serde_json = "=1.0.127" -sha2 = "=0.10.8" +serde_with = "=3.14.1" +sha2 = "=0.10.9" ssh-encoding = "=0.2.0" ssh-key = { version = "=0.6.7", default-features = false } sysinfo = "=0.37.2" thiserror = "=2.0.17" tokio = "=1.48.0" tokio-util = "=0.7.17" -tracing = "=0.1.41" -tracing-subscriber = { version = "=0.3.20", features = [ +tracing = "=0.1.44" +tracing-subscriber = { version = "=0.3.22", features = [ "fmt", "env-filter", "tracing-log", diff --git a/apps/desktop/desktop_native/macos_provider/.gitignore b/apps/desktop/desktop_native/autofill_provider/.gitignore similarity index 100% rename from apps/desktop/desktop_native/macos_provider/.gitignore rename to apps/desktop/desktop_native/autofill_provider/.gitignore diff --git a/apps/desktop/desktop_native/macos_provider/Cargo.toml b/apps/desktop/desktop_native/autofill_provider/Cargo.toml similarity index 82% rename from apps/desktop/desktop_native/macos_provider/Cargo.toml rename to apps/desktop/desktop_native/autofill_provider/Cargo.toml index 8a34460268a..b90fd5d1171 100644 --- a/apps/desktop/desktop_native/macos_provider/Cargo.toml +++ b/apps/desktop/desktop_native/autofill_provider/Cargo.toml @@ -1,30 +1,32 @@ [package] -name = "macos_provider" +name = "autofill_provider" edition = { workspace = true } license = { workspace = true } version = { workspace = true } publish = { workspace = true } +[lib] +crate-type = ["lib", "staticlib", "cdylib"] +bench = false + [[bin]] name = "uniffi-bindgen" path = "uniffi-bindgen.rs" -[lib] -crate-type = ["staticlib", "cdylib"] -bench = false - [dependencies] -uniffi = { workspace = true, features = ["cli"] } - -[target.'cfg(target_os = "macos")'.dependencies] +base64 = { workspace = true } desktop_core = { path = "../core" } futures = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +serde_with = { workspace = true, features = ["base64"] } tokio = { workspace = true, features = ["sync"] } tracing = { workspace = true } -tracing-subscriber = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] tracing-oslog = "=0.3.0" +tracing-subscriber = { workspace = true } +uniffi = { workspace = true, features = ["cli"] } [build-dependencies] uniffi = { workspace = true, features = ["build"] } diff --git a/apps/desktop/desktop_native/macos_provider/README.md b/apps/desktop/desktop_native/autofill_provider/README.md similarity index 84% rename from apps/desktop/desktop_native/macos_provider/README.md rename to apps/desktop/desktop_native/autofill_provider/README.md index 1d4c1902465..7f1312a0700 100644 --- a/apps/desktop/desktop_native/macos_provider/README.md +++ b/apps/desktop/desktop_native/autofill_provider/README.md @@ -1,8 +1,15 @@ +# Autofill Provider + +A library for native autofill providers to interact with a host Bitwarden desktop app. + +In this desktop context, "native autofill providers" are operating system frameworks or APIs (like the macOS [autofill framework](https://developer.apple.com/documentation/Security/password-autofill)) that allow Bitwarden to provide provide user credentials for things like autofill, passkey operations, etc. + # Explainer: Mac OS Native Passkey Provider This document describes the changes introduced in https://github.com/bitwarden/clients/pull/13963, where we introduce the MacOS Native Passkey Provider. It gives the high level explanation of the architecture and some of the quirks and additional good to know context. ## The high level + MacOS has native APIs (similar to iOS) to allow Credential Managers to provide credentials to the MacOS autofill system (in the PR referenced above, we only provide passkeys). We’ve written a Swift-based native autofill-extension. It’s bundled in the app-bundle in PlugIns, similar to the safari-extension. @@ -12,7 +19,7 @@ This swift extension currently communicates with our Electron app through IPC ba Footnotes: * We're not using the IPC framework as the implementation pre-dates the IPC framework. -* Alternatives like XPC or CFMessagePort may have better support for when the app is sandboxed. +* Alternatives like XPC or CFMessagePort may have better support for when the app is sandboxed. Electron receives the messages and passes it to Angular (through the electron-renderer event system). @@ -22,7 +29,7 @@ Our existing fido2 services in the renderer respond to events, displaying UI as We utilize the same FIDO2 implementation and interface that is already present for our browser authentication. It was designed by @coroiu with multiple ‘ui environments' in mind. -Therefore, a lot of the plumbing is implemented in /autofill/services/desktop-fido2-user-interface.service.ts, which implements the interface that our fido2 authenticator/client expects to drive UI related behaviors. +Therefore, a lot of the plumbing is implemented in /autofill/services/desktop-fido2-user-interface.service.ts, which implements the interface that our fido2 authenticator/client expects to drive UI related behaviors. We’ve also implemented a couple FIDO2 UI components to handle registration/sign in flows, but also improved the “modal mode” of the desktop app. diff --git a/apps/desktop/desktop_native/macos_provider/build.sh b/apps/desktop/desktop_native/autofill_provider/build.sh similarity index 56% rename from apps/desktop/desktop_native/macos_provider/build.sh rename to apps/desktop/desktop_native/autofill_provider/build.sh index 2f7a2d03541..6807ef1fbc8 100755 --- a/apps/desktop/desktop_native/macos_provider/build.sh +++ b/apps/desktop/desktop_native/autofill_provider/build.sh @@ -2,8 +2,12 @@ cd "$(dirname "$0")" -rm -r BitwardenMacosProviderFFI.xcframework -rm -r tmp +if [ -d "BitwardenMacosProviderFFI.xcframework" ]; then + rm -r "BitwardenMacosProviderFFI.xcframework" +fi +if [ -d "tmp" ]; then + rm -r "tmp" +fi mkdir -p ./tmp/target/universal-darwin/release/ @@ -11,17 +15,17 @@ mkdir -p ./tmp/target/universal-darwin/release/ rustup target add aarch64-apple-darwin rustup target add x86_64-apple-darwin -cargo build --package macos_provider --target aarch64-apple-darwin --release -cargo build --package macos_provider --target x86_64-apple-darwin --release +cargo build --package autofill_provider --target aarch64-apple-darwin --release +cargo build --package autofill_provider --target x86_64-apple-darwin --release # Create universal libraries -lipo -create ../target/aarch64-apple-darwin/release/libmacos_provider.a \ - ../target/x86_64-apple-darwin/release/libmacos_provider.a \ - -output ./tmp/target/universal-darwin/release/libmacos_provider.a +lipo -create ../target/aarch64-apple-darwin/release/libautofill_provider.a \ + ../target/x86_64-apple-darwin/release/libautofill_provider.a \ + -output ./tmp/target/universal-darwin/release/libautofill_provider.a # Generate swift bindings cargo run --bin uniffi-bindgen --features uniffi/cli generate \ - ../target/aarch64-apple-darwin/release/libmacos_provider.dylib \ + ../target/aarch64-apple-darwin/release/libautofill_provider.dylib \ --library \ --language swift \ --no-format \ @@ -38,7 +42,7 @@ cat ./tmp/bindings/*.modulemap > ./tmp/Headers/module.modulemap # Build xcframework xcodebuild -create-xcframework \ - -library ./tmp/target/universal-darwin/release/libmacos_provider.a \ + -library ./tmp/target/universal-darwin/release/libautofill_provider.a \ -headers ./tmp/Headers \ -output ./BitwardenMacosProviderFFI.xcframework diff --git a/apps/desktop/desktop_native/autofill_provider/src/assertion.rs b/apps/desktop/desktop_native/autofill_provider/src/assertion.rs new file mode 100644 index 00000000000..16d2f81fd61 --- /dev/null +++ b/apps/desktop/desktop_native/autofill_provider/src/assertion.rs @@ -0,0 +1,184 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +#[cfg(not(target_os = "macos"))] +use crate::TimedCallback; +use crate::{BitwardenError, Callback, Position, UserVerification}; + +/// Request to assert a credential. +#[cfg_attr(target_os = "macos", derive(uniffi::Record))] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyAssertionRequest { + /// Relying Party ID for the request. + pub rp_id: String, + + /// SHA-256 hash of the `clientDataJSON` for the assertion request. + pub client_data_hash: Vec, + + /// User verification preference. + pub user_verification: UserVerification, + + /// List of allowed credential IDs. + pub allowed_credentials: Vec>, + + /// Coordinates of the center of the WebAuthn client's window, relative to + /// the top-left point on the screen. + /// # Operating System Differences + /// + /// ## macOS + /// Note that macOS APIs gives points relative to the bottom-left point on the + /// screen by default, so the y-coordinate will be flipped. + /// + /// ## Windows + /// On Windows, this must be logical pixels, not physical pixels. + pub window_xy: Position, + + /// Byte string representing the native OS window handle for the WebAuthn client. + /// # Operating System Differences + /// + /// ## macOS + /// Unused. + /// + /// ## Windows + /// On Windows, this is a HWND. + #[cfg(not(target_os = "macos"))] + pub client_window_handle: Vec, + + /// Native context required for callbacks to the OS. Format differs on the OS. + /// # Operating System Differences + /// + /// ## macOS + /// Unused. + /// + /// ## Windows + /// On Windows, this is a base64-string representing the following data: + /// `request transaction id (GUID, 16 bytes) || SHA-256(pluginOperationRequest)` + #[cfg(not(target_os = "macos"))] + pub context: String, + // TODO(PM-30510): Implement support for extensions + // pub extension_input: Vec, +} + +/// Request to assert a credential without user interaction. +#[cfg_attr(target_os = "macos", derive(uniffi::Record))] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyAssertionWithoutUserInterfaceRequest { + /// Relying Party ID. + pub rp_id: String, + + /// The allowed credential ID for the request. + pub credential_id: Vec, + + /// The user name for the credential that was previously given to the OS. + #[cfg(target_os = "macos")] + pub user_name: String, + + /// The user ID for the credential that was previously given to the OS. + #[cfg(target_os = "macos")] + pub user_handle: Vec, + + /// The app-specific local identifier for the credential, in our case, the + /// cipher ID. + #[cfg(target_os = "macos")] + pub record_identifier: Option, + + /// SHA-256 hash of the `clientDataJSON` for the assertion request. + pub client_data_hash: Vec, + + /// User verification preference. + pub user_verification: UserVerification, + + /// Coordinates of the center of the WebAuthn client's window, relative to + /// the top-left point on the screen. + /// # Operating System Differences + /// + /// ## macOS + /// Note that macOS APIs gives points relative to the bottom-left point on the + /// screen by default, so the y-coordinate will be flipped. + /// + /// ## Windows + /// On Windows, this must be logical pixels, not physical pixels. + pub window_xy: Position, + + /// Byte string representing the native OS window handle for the WebAuthn client. + /// # Operating System Differences + /// + /// ## macOS + /// Unused. + /// + /// ## Windows + /// On Windows, this is a HWND. + #[cfg(not(target_os = "macos"))] + pub client_window_handle: Vec, + + /// Native context required for callbacks to the OS. Format differs on the OS. + /// # Operating System Differences + /// + /// ## macOS + /// Unused. + /// + /// ## Windows + /// On Windows, this is `request transaction id () || SHA-256(pluginOperationRequest)`. + #[cfg(not(target_os = "macos"))] + pub context: String, +} + +/// Response for a passkey assertion request. +#[cfg_attr(target_os = "macos", derive(uniffi::Record))] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyAssertionResponse { + /// Relying Party ID. + pub rp_id: String, + + /// The user ID for the credential that was previously given to the OS. + pub user_handle: Vec, + + /// The signature for the WebAuthn attestation response. + pub signature: Vec, + + /// SHA-256 hash of the `clientDataJSON` used in the assertion. + pub client_data_hash: Vec, + + /// The WebAuthn authenticator data structure. + pub authenticator_data: Vec, + + /// The ID for the attested credential. + pub credential_id: Vec, +} + +/// Callback to process a response to passkey assertion request. +#[cfg_attr(target_os = "macos", uniffi::export(with_foreign))] +pub trait PreparePasskeyAssertionCallback: Send + Sync { + /// Function to call if a successful response is returned. + fn on_complete(&self, credential: PasskeyAssertionResponse); + + /// Function to call if an error response is returned. + fn on_error(&self, error: BitwardenError); +} + +impl Callback for Arc { + fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> { + let credential = serde_json::from_value(credential)?; + PreparePasskeyAssertionCallback::on_complete(self.as_ref(), credential); + Ok(()) + } + + fn error(&self, error: BitwardenError) { + PreparePasskeyAssertionCallback::on_error(self.as_ref(), error); + } +} + +#[cfg(not(target_os = "macos"))] +impl PreparePasskeyAssertionCallback for TimedCallback { + fn on_complete(&self, credential: PasskeyAssertionResponse) { + self.send(Ok(credential)); + } + + fn on_error(&self, error: BitwardenError) { + self.send(Err(error)); + } +} diff --git a/apps/desktop/desktop_native/autofill_provider/src/lib.rs b/apps/desktop/desktop_native/autofill_provider/src/lib.rs new file mode 100644 index 00000000000..567fc9b3ac5 --- /dev/null +++ b/apps/desktop/desktop_native/autofill_provider/src/lib.rs @@ -0,0 +1,754 @@ +#![allow(clippy::disallowed_macros)] // uniffi macros trip up clippy's evaluation +mod assertion; +mod lock_status; +mod registration; +mod window_handle_query; + +#[cfg(target_os = "macos")] +use std::sync::Once; +use std::{ + collections::HashMap, + error::Error, + fmt::Display, + path::PathBuf, + sync::{ + atomic::AtomicU32, + mpsc::{self, Receiver, RecvTimeoutError, Sender}, + Arc, Mutex, + }, + time::{Duration, Instant}, +}; + +pub use assertion::{ + PasskeyAssertionRequest, PasskeyAssertionResponse, PasskeyAssertionWithoutUserInterfaceRequest, + PreparePasskeyAssertionCallback, +}; +use futures::FutureExt; +pub use lock_status::LockStatusResponse; +pub use registration::{ + PasskeyRegistrationRequest, PasskeyRegistrationResponse, PreparePasskeyRegistrationCallback, +}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use tracing::{error, info}; +#[cfg(target_os = "macos")] +use tracing_subscriber::{ + filter::{EnvFilter, LevelFilter}, + layer::SubscriberExt, + util::SubscriberInitExt, +}; +pub use window_handle_query::WindowHandleQueryResponse; + +use crate::{ + lock_status::{GetLockStatusCallback, LockStatusRequest}, + window_handle_query::{GetWindowHandleQueryCallback, WindowHandleQueryRequest}, +}; + +#[cfg(target_os = "macos")] +uniffi::setup_scaffolding!(); + +#[cfg(target_os = "macos")] +static INIT: Once = Once::new(); + +/// User verification preference for WebAuthn requests. +#[cfg_attr(target_os = "macos", derive(uniffi::Enum))] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum UserVerification { + Preferred, + Required, + Discouraged, +} + +/// Coordinates representing a point on the screen. +#[cfg_attr(target_os = "macos", derive(uniffi::Record))] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Position { + pub x: i32, + pub y: i32, +} + +#[cfg_attr(target_os = "macos", derive(uniffi::Error))] +#[derive(Debug, Serialize, Deserialize)] +pub enum BitwardenError { + Internal(String), + Disconnected, +} + +impl Display for BitwardenError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Internal(msg) => write!(f, "Internal error occurred: {msg}"), + Self::Disconnected => { + write!(f, "Client is disconnected from autofill IPC service") + } + } + } +} + +impl Error for BitwardenError {} + +// These methods are named differently than the actual Uniffi traits (without +// the `on_` prefix) to avoid ambiguous trait implementations in the generated +// code. +trait Callback: Send + Sync { + fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error>; + fn error(&self, error: BitwardenError); +} + +/// Store the connection status between the credential provider extension +/// and the desktop application's IPC server. +#[cfg_attr(target_os = "macos", derive(uniffi::Enum))] +#[derive(Debug)] +pub enum ConnectionStatus { + Connected, + Disconnected, +} + +/// A client to send and receive messages to the autofill service on the desktop +/// client. +/// +/// # Usage +/// +/// In order to accommodate desktop app startup delays and non-blocking +/// requirements for native providers, this initialization of the client is +/// non-blocking. When calling [`AutofillProviderClient::connect()`], the +/// connection is not established immediately, but may be established later in +/// the background or may fail to be established. +/// +/// Before calling [`AutofillProviderClient::connect()`], first check whether +/// the desktop app is running with [`AutofillProviderClient::is_available`], +/// and attempt to start it if it is not running. Then, attempt to connect, retrying as necessary. +/// Before calling any other methods, check the connection status using +/// [`AutofillProviderClient::get_connection_status()`]. +/// +/// # Examples +/// +/// ```no_run +/// use std::{sync::Arc, time::Duration}; +/// +/// use autofill_provider::{AutofillProviderClient, ConnectionStatus, TimedCallback}; +/// +/// fn establish_connection() -> Option { +/// if !AutofillProviderClient::is_available() { +/// // Start application +/// } +/// let max_attempts = 20; +/// let delay = Duration::from_millis(300); +/// +/// for attempt in 0..=max_attempts { +/// let client = AutofillProviderClient::connect(); +/// if attempt != 0 { +/// // Use whatever sleep method is appropriate +/// std::thread::sleep(delay + Duration::from_millis(100 * attempt)); +/// } +/// if let ConnectionStatus::Connected = client.get_connection_status() { +/// return Some(client); +/// } +/// }; +/// None +/// } +/// +/// if let Some(client) = establish_connection() { +/// // use client here +/// } +/// ``` +#[cfg_attr(target_os = "macos", derive(uniffi::Object))] +pub struct AutofillProviderClient { + to_server_send: tokio::sync::mpsc::Sender, + + // We need to keep track of the callbacks so we can call them when we receive a response + response_callbacks_counter: AtomicU32, + #[allow(clippy::type_complexity)] + response_callbacks_queue: Arc, Instant)>>>, + + // Flag to track connection status - atomic for thread safety without locks + connection_status: Arc, +} + +/// Store native desktop status information to use for IPC communication +/// between the application and the credential provider. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NativeStatus { + key: String, + value: String, +} + +// In our callback management, 0 is a reserved sequence number indicating that a message does not +// have a callback. +const NO_CALLBACK_INDICATOR: u32 = 0; + +#[cfg(not(test))] +static IPC_PATH: &str = "af"; +#[cfg(test)] +static IPC_PATH: &str = "af-test"; + +// These methods are not currently needed in macOS and/or cannot be exported via FFI +impl AutofillProviderClient { + /// Whether the client is immediately available for connection. + pub fn is_available() -> bool { + desktop_core::ipc::path(IPC_PATH).exists() + } + + /// Request the desktop client's lock status. + pub fn get_lock_status(&self, callback: Arc) { + self.send_message(LockStatusRequest {}, Some(Box::new(callback))); + } + + /// Requests details about the desktop client's native window. + pub fn get_window_handle(&self, callback: Arc) { + self.send_message( + WindowHandleQueryRequest::default(), + Some(Box::new(callback)), + ); + } + + fn connect_to_path(path: PathBuf) -> Self { + let (from_server_send, mut from_server_recv) = tokio::sync::mpsc::channel(32); + let (to_server_send, to_server_recv) = tokio::sync::mpsc::channel(32); + + let client = AutofillProviderClient { + to_server_send, + response_callbacks_counter: AtomicU32::new(1), /* Start at 1 since 0 is reserved for + * "no callback" scenarios */ + response_callbacks_queue: Arc::new(Mutex::new(HashMap::new())), + connection_status: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let queue = client.response_callbacks_queue.clone(); + let connection_status = client.connection_status.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Can't create runtime"); + + rt.spawn( + desktop_core::ipc::client::connect(path.clone(), from_server_send, to_server_recv) + .map(move |r| { + if let Err(err) = r { + tracing::error!( + ?path, + "Failed to connect to autofill IPC server: {err}" + ); + } + }), + ); + + rt.block_on(async move { + while let Some(message) = from_server_recv.recv().await { + match serde_json::from_str::(&message) { + Ok(SerializedMessage::Command(CommandMessage::Connected)) => { + info!("Connected to server"); + connection_status.store(true, std::sync::atomic::Ordering::Relaxed); + } + Ok(SerializedMessage::Command(CommandMessage::Disconnected)) => { + info!("Disconnected from server"); + connection_status.store(false, std::sync::atomic::Ordering::Relaxed); + } + Ok(SerializedMessage::Message { + sequence_number, + value, + }) => match queue.lock().expect("not poisoned").remove(&sequence_number) { + Some((cb, request_start_time)) => { + info!( + "Time to process request: {:?}", + request_start_time.elapsed() + ); + match value { + Ok(value) => { + if let Err(e) = cb.complete(value) { + error!(error = %e, "Error deserializing message"); + } + } + Err(e) => { + error!(error = ?e, "Error processing message"); + cb.error(e); + } + } + } + None => { + error!(sequence_number, "No callback found for sequence number"); + } + }, + Err(e) => { + error!(error = %e, %message, "Error deserializing message"); + } + }; + } + }); + }); + + client + } +} + +#[cfg_attr(target_os = "macos", uniffi::export)] +impl AutofillProviderClient { + /// Asynchronously initiates a connection to the autofill service on the desktop client. + /// + /// See documentation at the top-level of [this struct][AutofillProviderClient] for usage + /// information. + #[cfg_attr(target_os = "macos", uniffi::constructor)] + pub fn connect() -> Self { + tracing::trace!("Autofill provider attempting to connect to Electron IPC..."); + let path = desktop_core::ipc::path(IPC_PATH); + Self::connect_to_path(path) + } + + /// Send a one-way key-value message to the desktop client. + pub fn send_native_status(&self, key: String, value: String) { + let status = NativeStatus { key, value }; + self.send_message(status, None); + } + + /// Send a request to create a new passkey to the desktop client. + pub fn prepare_passkey_registration( + &self, + request: PasskeyRegistrationRequest, + callback: Arc, + ) { + self.send_message(request, Some(Box::new(callback))); + } + + /// Send a request to assert a passkey to the desktop client. + pub fn prepare_passkey_assertion( + &self, + request: PasskeyAssertionRequest, + callback: Arc, + ) { + self.send_message(request, Some(Box::new(callback))); + } + + /// Send a request to assert a passkey, without prompting the user, to the desktop client. + pub fn prepare_passkey_assertion_without_user_interface( + &self, + request: PasskeyAssertionWithoutUserInterfaceRequest, + callback: Arc, + ) { + self.send_message(request, Some(Box::new(callback))); + } + + /// Return the status this client's connection to the desktop client. + pub fn get_connection_status(&self) -> ConnectionStatus { + let is_connected = self + .connection_status + .load(std::sync::atomic::Ordering::Relaxed); + if is_connected { + ConnectionStatus::Connected + } else { + ConnectionStatus::Disconnected + } + } +} + +#[cfg(target_os = "macos")] +#[uniffi::export] +pub fn initialize_logging() { + INIT.call_once(|| { + let filter = EnvFilter::builder() + // Everything logs at `INFO` + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(); + + tracing_subscriber::registry() + .with(filter) + .with(tracing_oslog::OsLogger::new( + "com.bitwarden.desktop.autofill-extension", + "default", + )) + .init(); + }); +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "command", rename_all = "camelCase")] +enum CommandMessage { + Connected, + Disconnected, +} + +#[derive(Serialize, Deserialize)] +#[serde(untagged, rename_all = "camelCase")] +enum SerializedMessage { + Command(CommandMessage), + Message { + sequence_number: u32, + value: Result, + }, +} + +impl AutofillProviderClient { + fn add_callback(&self, callback: Box) -> u32 { + let sequence_number = self + .response_callbacks_counter + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + + self.response_callbacks_queue + .lock() + .expect("response callbacks queue mutex should not be poisoned") + .insert(sequence_number, (callback, Instant::now())); + + sequence_number + } + + fn send_message( + &self, + message: impl Serialize + DeserializeOwned, + callback: Option>, + ) { + if let ConnectionStatus::Disconnected = self.get_connection_status() { + if let Some(callback) = callback { + callback.error(BitwardenError::Disconnected); + } + return; + } + let sequence_number = if let Some(callback) = callback { + self.add_callback(callback) + } else { + NO_CALLBACK_INDICATOR + }; + + if let Err(e) = send_message_helper(sequence_number, message, &self.to_server_send) { + // Make sure we remove the callback from the queue if we can't send the message + if sequence_number != NO_CALLBACK_INDICATOR { + if let Some((callback, _)) = self + .response_callbacks_queue + .lock() + .expect("response callbacks queue mutex should not be poisoned") + .remove(&sequence_number) + { + callback.error(BitwardenError::Internal(format!( + "Error sending message: {e}" + ))); + } + } + } + } +} + +// Wrapped in Result<> to allow using ? for clarity. +fn send_message_helper( + sequence_number: u32, + message: impl Serialize + DeserializeOwned, + tx: &tokio::sync::mpsc::Sender, +) -> Result<(), BitwardenError> { + let value = serde_json::to_value(message).map_err(|err| { + BitwardenError::Internal(format!("Could not represent message as JSON: {err}")) + })?; + let message = SerializedMessage::Message { + sequence_number, + value: Ok(value), + }; + let json = serde_json::to_string(&message).map_err(|err| { + BitwardenError::Internal(format!("Could not serialize message as JSON: {err}")) + })?; + // The OS calls us serially, and we only need 1-3 concurrent requests + // (passkey request, cancellation, maybe user verification). + // So it's safe to send on this thread since there should always be enough + // room in the receiver buffer to send. + tx.blocking_send(json) + .map_err(|_| BitwardenError::Disconnected)?; + Ok(()) +} + +/// Types of errors for callbacks. +#[derive(Debug)] +pub enum CallbackError { + Timeout, + Cancelled, +} + +impl Display for CallbackError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Timeout => f.write_str("Callback timed out"), + Self::Cancelled => f.write_str("Callback cancelled"), + } + } +} +impl std::error::Error for CallbackError {} + +type CallbackResponse = Result; + +/// An implementation of a callback handler that can take a deadline. +pub struct TimedCallback { + tx: Arc>>>>, + rx: Arc>>>, +} + +impl Default for TimedCallback { + fn default() -> Self { + Self::new() + } +} + +impl TimedCallback { + /// Instantiates a new callback handler. + pub fn new() -> Self { + let (tx, rx) = mpsc::channel(); + Self { + tx: Arc::new(Mutex::new(Some(tx))), + rx: Arc::new(Mutex::new(rx)), + } + } + + /// Block the current thread until either a response is received, or the + /// specified timeout has passed. + /// + /// # Examples + /// + /// ```no_run + /// use std::{sync::Arc, time::Duration}; + /// + /// use autofill_provider::{AutofillProviderClient, TimedCallback}; + /// + /// let client = AutofillProviderClient::connect(); + /// let callback = Arc::new(TimedCallback::new()); + /// client.get_lock_status(callback.clone()); + /// match callback.wait_for_response(Duration::from_secs(3), None) { + /// Ok(Ok(response)) => Ok(response), + /// Ok(Err(err)) => Err(format!("GetLockStatus() call failed: {err}")), + /// Err(_) => Err(format!("GetLockStatus() call timed out")), + /// }.unwrap(); + /// ``` + pub fn wait_for_response( + &self, + timeout: Duration, + cancellation_token: Option>, + ) -> Result, CallbackError> { + let (tx, rx) = mpsc::channel(); + if let Some(cancellation_token) = cancellation_token { + let tx2 = tx.clone(); + let cancellation_token = Mutex::new(cancellation_token); + std::thread::spawn(move || { + if let Ok(()) = cancellation_token + .lock() + .expect("not poisoned") + .recv_timeout(timeout) + { + tracing::debug!("Forwarding cancellation"); + _ = tx2.send(Err(CallbackError::Cancelled)); + } + }); + } + let response_rx = self.rx.clone(); + std::thread::spawn(move || { + if let Ok(response) = response_rx + .lock() + .expect("not poisoned") + .recv_timeout(timeout) + { + _ = tx.send(Ok(response)); + } + }); + match rx.recv_timeout(timeout) { + Ok(Ok(response)) => Ok(response), + Ok(err @ Err(CallbackError::Cancelled)) => { + tracing::debug!("Received cancellation, dropping."); + err + } + Ok(err @ Err(CallbackError::Timeout)) => { + tracing::warn!("Request timed out, dropping."); + err + } + Err(RecvTimeoutError::Timeout) => Err(CallbackError::Timeout), + Err(_) => Err(CallbackError::Cancelled), + } + } + + fn send(&self, response: Result) { + match self.tx.lock().expect("not poisoned").take() { + Some(tx) => { + if tx.send(response).is_err() { + tracing::error!("Windows provider channel closed before receiving IPC response from Electron"); + } + } + None => { + tracing::error!("Callback channel used before response: multi-threading issue?"); + } + } + } +} + +impl PreparePasskeyRegistrationCallback for TimedCallback { + fn on_complete(&self, credential: PasskeyRegistrationResponse) { + self.send(Ok(credential)); + } + + fn on_error(&self, error: BitwardenError) { + self.send(Err(error)); + } +} + +#[cfg(test)] +mod tests { + //! For debugging test failures, it may be useful to enable tracing to see + //! the request flow more easily. You can do that by adding the following + //! line to the beginning of the `#[test]` function you're working on: + //! + //! ```no_run + //! tracing_subscriber::fmt::init(); + //! ``` + //! + //! After that, you can set `RUST_LOG=debug` and run `cargo test` to see the traces. + + use std::{ + path::PathBuf, + sync::{atomic::AtomicU32, Arc}, + time::Duration, + }; + + use desktop_core::ipc::server::MessageType; + use serde_json::{json, Value}; + use tokio::sync::mpsc; + use tracing::Level; + + use crate::{ + AutofillProviderClient, BitwardenError, ConnectionStatus, LockStatusRequest, + SerializedMessage, TimedCallback, IPC_PATH, + }; + + /// Generates a path for a server and client to connect with. + /// + /// [`AutofillProviderClient`] is currently hardcoded to use sockets from the filesystem. + /// In order for paths not to conflict between tests, we use a counter and add it to the path + /// name. + fn get_server_path() -> PathBuf { + static SERVER_COUNTER: AtomicU32 = AtomicU32::new(0); + let counter = SERVER_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let name = format!("{}-{}", IPC_PATH, counter); + desktop_core::ipc::path(&name) + } + + /// Sets up an in-memory server based on the passed handler and returns a client to the server. + fn get_client< + F: Fn(Result) -> Result + Send + 'static, + >( + handler: F, + ) -> AutofillProviderClient { + let (signal_tx, signal_rx) = std::sync::mpsc::channel(); + let path = get_server_path(); + let server_path = path.clone(); + + // Start server thread + std::thread::spawn(move || { + let _span = tracing::span!(Level::DEBUG, "server").entered(); + tracing::info!("Starting server thread"); + let (tx, mut rx) = mpsc::channel(8); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_io() + .build() + .unwrap(); + rt.block_on(async move { + tracing::debug!(?server_path, "Starting server"); + let server = desktop_core::ipc::server::Server::start(&server_path, tx).unwrap(); + + // Signal to main thread that the server is ready to process messages. + tracing::debug!("Server started"); + signal_tx.send(()).unwrap(); + + // Handle incoming messages + tracing::debug!("Waiting for messages"); + while let Some(data) = rx.recv().await { + tracing::debug!("Received {data:?}"); + match data.kind { + MessageType::Connected => {} + MessageType::Disconnected => {} + MessageType::Message => { + // Deserialize and handle messages using the given handler function. + let msg: SerializedMessage = + serde_json::from_str(&data.message.unwrap()).unwrap(); + + if let SerializedMessage::Message { + sequence_number, + value, + } = msg + { + let response = serde_json::to_string(&SerializedMessage::Message { + sequence_number, + value: handler(value), + }) + .unwrap(); + server.send(response).unwrap(); + } + } + } + } + }); + }); + + // Wait for server to startup and client to connect to server before returning client to + // test method. + let _span = tracing::span!(Level::DEBUG, "client"); + tracing::debug!("Waiting for server..."); + signal_rx.recv_timeout(Duration::from_millis(1000)).unwrap(); + + // This starts a background task to connect to the server. + tracing::debug!("Starting client..."); + let client = AutofillProviderClient::connect_to_path(path.to_path_buf()); + + // The client connects to the server asynchronously in a background + // thread, so wait for client to report itself as Connected so that test + // methods don't have to do this everytime. + // Note, this has the potential to be flaky on a very busy server, but that's unavoidable + // with the current API. + tracing::debug!("Client connecting..."); + for _ in 0..20 { + if let ConnectionStatus::Connected = client.get_connection_status() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + + assert!(matches!( + client.get_connection_status(), + ConnectionStatus::Connected + )); + + client + } + + #[test] + fn test_client_throws_error_on_method_call_when_disconnected() { + // There is no server running at this path, so this client should always be disconnected. + let client = AutofillProviderClient::connect_to_path(get_server_path()); + + // use an arbitrary request to test whether the client is disconnected. + let callback = Arc::new(TimedCallback::new()); + client.get_lock_status(callback.clone()); + let response = callback + .wait_for_response(Duration::from_millis(10), None) + .unwrap(); + + assert!(matches!(response, Err(BitwardenError::Disconnected))); + } + + #[test] + fn test_client_parses_get_lock_status_response_when_valid_json_is_returned() { + // The server should expect a lock status request and return a valid response. + let handler = |value: Result| { + let value = value.unwrap(); + if let Ok(LockStatusRequest {}) = serde_json::from_value(value.clone()) { + Ok(json!({"isUnlocked": true})) + } else { + Err(BitwardenError::Internal(format!( + "Expected LockStatusRequest, received: {value:?}" + ))) + } + }; + + // send a lock status request + let client = get_client(handler); + let callback = Arc::new(TimedCallback::new()); + client.get_lock_status(callback.clone()); + let response = callback + .wait_for_response(Duration::from_millis(3000), None) + .unwrap() + .unwrap(); + + assert!(response.is_unlocked); + } +} diff --git a/apps/desktop/desktop_native/autofill_provider/src/lock_status.rs b/apps/desktop/desktop_native/autofill_provider/src/lock_status.rs new file mode 100644 index 00000000000..134070bc54a --- /dev/null +++ b/apps/desktop/desktop_native/autofill_provider/src/lock_status.rs @@ -0,0 +1,48 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::{BitwardenError, Callback, TimedCallback}; + +/// Request to retrieve the lock status of the desktop client. +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct LockStatusRequest {} + +/// Response for the lock status of the desktop client. +#[derive(Debug, Deserialize)] +pub struct LockStatusResponse { + /// Whether the desktop client is unlocked. + #[serde(rename = "isUnlocked")] + pub is_unlocked: bool, +} + +impl Callback for Arc { + fn complete(&self, response: serde_json::Value) -> Result<(), serde_json::Error> { + let response = serde_json::from_value(response)?; + self.as_ref().on_complete(response); + Ok(()) + } + + fn error(&self, error: BitwardenError) { + self.as_ref().on_error(error); + } +} + +/// Callback to process a response to a lock status request. +pub trait GetLockStatusCallback: Send + Sync { + /// Function to call if a successful response is returned. + fn on_complete(&self, response: LockStatusResponse); + + /// Function to call if an error response is returned. + fn on_error(&self, error: BitwardenError); +} + +impl GetLockStatusCallback for TimedCallback { + fn on_complete(&self, response: LockStatusResponse) { + self.send(Ok(response)); + } + + fn on_error(&self, error: BitwardenError) { + self.send(Err(error)); + } +} diff --git a/apps/desktop/desktop_native/autofill_provider/src/registration.rs b/apps/desktop/desktop_native/autofill_provider/src/registration.rs new file mode 100644 index 00000000000..3f361588241 --- /dev/null +++ b/apps/desktop/desktop_native/autofill_provider/src/registration.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::{BitwardenError, Callback, Position, UserVerification}; + +/// Request to create a credential. +#[cfg_attr(target_os = "macos", derive(uniffi::Record))] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyRegistrationRequest { + /// Relying Party ID for the request. + pub rp_id: String, + + /// The user name for the credential that was previously given to the OS. + pub user_name: String, + + /// The user ID for the credential that was previously given to the OS. + pub user_handle: Vec, + + /// SHA-256 hash of the `clientDataJSON` for the registration request. + pub client_data_hash: Vec, + + /// User verification preference. + pub user_verification: UserVerification, + + /// Supported key algorithms in COSE format. + pub supported_algorithms: Vec, + + /// Coordinates of the center of the WebAuthn client's window, relative to + /// the top-left point on the screen. + /// # Operating System Differences + /// + /// ## macOS + /// Note that macOS APIs gives points relative to the bottom-left point on the + /// screen by default, so the y-coordinate will be flipped. + /// + /// ## Windows + /// On Windows, this must be logical pixels, not physical pixels. + pub window_xy: Position, + + /// List of excluded credential IDs. + pub excluded_credentials: Vec>, + + /// Byte string representing the native OS window handle for the WebAuthn client. + /// # Operating System Differences + /// + /// ## macOS + /// Unused. + /// + /// ## Windows + /// On Windows, this is a HWND. + #[cfg(not(target_os = "macos"))] + pub client_window_handle: Vec, + + /// Native context required for callbacks to the OS. Format differs by OS. + /// # Operating System Differences + /// + /// ## macOS + /// Unused. + /// + /// ## Windows + /// On Windows, this is a base64-string representing the following data: + /// `request transaction id (GUID, 16 bytes) || SHA-256(pluginOperationRequest)` + #[cfg(not(target_os = "macos"))] + pub context: String, +} + +/// Response for a passkey registration request. +#[cfg_attr(target_os = "macos", derive(uniffi::Record))] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyRegistrationResponse { + /// Relying Party ID. + pub rp_id: String, + + /// SHA-256 hash of the `clientDataJSON` used in the registration. + pub client_data_hash: Vec, + + /// The ID for the created credential. + pub credential_id: Vec, + + /// WebAuthn attestation object. + pub attestation_object: Vec, +} + +/// Callback to process a response to passkey registration request. +#[cfg_attr(target_os = "macos", uniffi::export(with_foreign))] +pub trait PreparePasskeyRegistrationCallback: Send + Sync { + /// Function to call if a successful response is returned. + fn on_complete(&self, credential: PasskeyRegistrationResponse); + + /// Function to call if an error response is returned. + fn on_error(&self, error: BitwardenError); +} + +impl Callback for Arc { + fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> { + let credential = serde_json::from_value(credential)?; + PreparePasskeyRegistrationCallback::on_complete(self.as_ref(), credential); + Ok(()) + } + + fn error(&self, error: BitwardenError) { + PreparePasskeyRegistrationCallback::on_error(self.as_ref(), error); + } +} diff --git a/apps/desktop/desktop_native/autofill_provider/src/window_handle_query.rs b/apps/desktop/desktop_native/autofill_provider/src/window_handle_query.rs new file mode 100644 index 00000000000..b4d388ae6c3 --- /dev/null +++ b/apps/desktop/desktop_native/autofill_provider/src/window_handle_query.rs @@ -0,0 +1,75 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_with::{ + base64::{Base64, Standard}, + formats::Padded, + serde_as, +}; + +use crate::{BitwardenError, Callback, TimedCallback}; + +/// Request to get the window handle of the desktop client. +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct WindowHandleQueryRequest { + /// Marker field for parsing; data is never read. + /// + /// TODO: this is used to disambiguate parsing the type in desktop_napi. + /// This will be cleaned up in PM-23485. + window_handle: String, +} + +/// Response to window handle request. +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowHandleQueryResponse { + /// Whether the desktop client is currently visible. + pub is_visible: bool, + + /// Whether the desktop client is currently focused. + pub is_focused: bool, + + /// Byte string representing the native OS window handle for the desktop client. + /// # Operating System Differences + /// + /// ## macOS + /// Unused. + /// + /// ## Windows + /// On Windows, this is a HWND. + #[serde_as(as = "Base64")] + pub handle: Vec, +} + +impl Callback for Arc { + fn complete(&self, response: serde_json::Value) -> Result<(), serde_json::Error> { + let response = serde_json::from_value(response)?; + self.as_ref().on_complete(response); + Ok(()) + } + + fn error(&self, error: BitwardenError) { + self.as_ref().on_error(error); + } +} + +/// Callback to process a response to a window handle query request. +pub trait GetWindowHandleQueryCallback: Send + Sync { + /// Function to call if a successful response is returned. + fn on_complete(&self, response: WindowHandleQueryResponse); + + /// Function to call if an error response is returned. + fn on_error(&self, error: BitwardenError); +} + +impl GetWindowHandleQueryCallback for TimedCallback { + fn on_complete(&self, response: WindowHandleQueryResponse) { + self.send(Ok(response)); + } + + fn on_error(&self, error: BitwardenError) { + self.send(Err(error)); + } +} diff --git a/apps/desktop/desktop_native/autofill_provider/uniffi-bindgen.rs b/apps/desktop/desktop_native/autofill_provider/uniffi-bindgen.rs new file mode 100644 index 00000000000..433c6c65b37 --- /dev/null +++ b/apps/desktop/desktop_native/autofill_provider/uniffi-bindgen.rs @@ -0,0 +1,9 @@ +#[cfg(target_os = "macos")] +fn main() { + uniffi::uniffi_bindgen_main() +} + +#[cfg(not(target_os = "macos"))] +fn main() { + unimplemented!("uniffi-bindgen is not enabled on this target."); +} diff --git a/apps/desktop/desktop_native/macos_provider/uniffi.toml b/apps/desktop/desktop_native/autofill_provider/uniffi.toml similarity index 100% rename from apps/desktop/desktop_native/macos_provider/uniffi.toml rename to apps/desktop/desktop_native/autofill_provider/uniffi.toml diff --git a/apps/desktop/desktop_native/autotype/Cargo.toml b/apps/desktop/desktop_native/autotype/Cargo.toml index 6bf3218d98a..59dd36c6c91 100644 --- a/apps/desktop/desktop_native/autotype/Cargo.toml +++ b/apps/desktop/desktop_native/autotype/Cargo.toml @@ -5,13 +5,10 @@ license.workspace = true edition.workspace = true publish.workspace = true -[dependencies] -anyhow = { workspace = true } - [target.'cfg(windows)'.dependencies] itertools.workspace = true mockall = "=0.14.0" -serial_test = "=3.2.0" +serial_test = "=3.3.1" tracing.workspace = true windows = { workspace = true, features = [ "Win32_UI_Input_KeyboardAndMouse", @@ -19,5 +16,17 @@ windows = { workspace = true, features = [ ] } windows-core = { workspace = true } +[dependencies] +anyhow = { workspace = true } + +[target.'cfg(windows)'.dev-dependencies] +windows = { workspace = true, features = [ + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_WindowsAndMessaging", + "Win32_Foundation", + "Win32_System_LibraryLoader", + "Win32_Graphics_Gdi", +] } + [lints] workspace = true diff --git a/apps/desktop/desktop_native/autotype/tests/integration_tests.rs b/apps/desktop/desktop_native/autotype/tests/integration_tests.rs new file mode 100644 index 00000000000..b87219f77fe --- /dev/null +++ b/apps/desktop/desktop_native/autotype/tests/integration_tests.rs @@ -0,0 +1,324 @@ +#![cfg(target_os = "windows")] + +use std::{ + sync::{Arc, Mutex}, + thread, + time::Duration, +}; + +use autotype::{get_foreground_window_title, type_input}; +use serial_test::serial; +use tracing::debug; +use windows::Win32::{ + Foundation::{COLORREF, HINSTANCE, HMODULE, HWND, LPARAM, LRESULT, WPARAM}, + Graphics::Gdi::{CreateSolidBrush, UpdateWindow, ValidateRect, COLOR_WINDOW}, + System::LibraryLoader::{GetModuleHandleA, GetModuleHandleW}, + UI::WindowsAndMessaging::*, +}; +use windows_core::{s, w, Result, PCSTR, PCWSTR}; + +struct TestWindow { + handle: HWND, + capture: Option, +} + +impl Drop for TestWindow { + fn drop(&mut self) { + // Clean up the InputCapture pointer + unsafe { + let capture_ptr = GetWindowLongPtrW(self.handle, GWLP_USERDATA) as *mut InputCapture; + if !capture_ptr.is_null() { + let _ = Box::from_raw(capture_ptr); + } + CloseWindow(self.handle).expect("window handle should be closeable"); + DestroyWindow(self.handle).expect("window handle should be destroyable"); + } + } +} + +// state to capture keyboard input +#[derive(Clone)] +struct InputCapture { + chars: Arc>>, +} + +impl InputCapture { + fn new() -> Self { + Self { + chars: Arc::new(Mutex::new(Vec::new())), + } + } + + fn get_chars(&self) -> Vec { + self.chars + .lock() + .expect("mutex should not be poisoned") + .clone() + } +} + +// Custom window procedure that captures input +unsafe extern "system" fn capture_input_proc( + handle: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_CREATE => { + // Store the InputCapture pointer in window data + let create_struct = lparam.0 as *const CREATESTRUCTW; + let capture_ptr = (*create_struct).lpCreateParams as *mut InputCapture; + SetWindowLongPtrW(handle, GWLP_USERDATA, capture_ptr as isize); + LRESULT(0) + } + WM_CHAR => { + // Get the InputCapture from window data + let capture_ptr = GetWindowLongPtrW(handle, GWLP_USERDATA) as *mut InputCapture; + if !capture_ptr.is_null() { + let capture = &*capture_ptr; + if let Some(ch) = char::from_u32(wparam.0 as u32) { + capture + .chars + .lock() + .expect("mutex should not be poisoned") + .push(ch); + } + } + LRESULT(0) + } + WM_DESTROY => { + PostQuitMessage(0); + LRESULT(0) + } + _ => DefWindowProcW(handle, msg, wparam, lparam), + } +} + +// A pointer to the window procedure +type ProcType = unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM) -> LRESULT; + +// +extern "system" fn show_window_proc( + handle: HWND, // the window handle + message: u32, // the system message + wparam: WPARAM, /* additional message information. The contents of the wParam parameter + * depend on the value of the message parameter. */ + lparam: LPARAM, /* additional message information. The contents of the lParam parameter + * depend on the value of the message parameter. */ +) -> LRESULT { + unsafe { + match message { + WM_PAINT => { + debug!("WM_PAINT"); + let res = ValidateRect(Some(handle), None); + debug_assert!(res.ok().is_ok()); + LRESULT(0) + } + WM_DESTROY => { + debug!("WM_DESTROY"); + PostQuitMessage(0); + LRESULT(0) + } + _ => DefWindowProcA(handle, message, wparam, lparam), + } + } +} + +impl TestWindow { + fn set_foreground(&self) -> Result<()> { + unsafe { + let _ = ShowWindow(self.handle, SW_SHOW); + let _ = SetForegroundWindow(self.handle); + let _ = UpdateWindow(self.handle); + let _ = SetForegroundWindow(self.handle); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + Ok(()) + } + + fn wait_for_input(&self, timeout_ms: u64) { + let start = std::time::Instant::now(); + while start.elapsed().as_millis() < timeout_ms as u128 { + process_messages(); + thread::sleep(Duration::from_millis(10)); + } + } +} + +fn process_messages() { + unsafe { + let mut msg = MSG::default(); + while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } +} + +fn create_input_window(title: PCWSTR, proc_type: ProcType) -> Result { + unsafe { + let instance = GetModuleHandleW(None).unwrap_or(HMODULE(std::ptr::null_mut())); + let instance: HINSTANCE = instance.into(); + debug_assert!(!instance.is_invalid()); + + let window_class = w!("show_window"); + + // Register window class with our custom proc + let wc = WNDCLASSW { + lpfnWndProc: Some(proc_type), + hInstance: instance, + lpszClassName: window_class, + hbrBackground: CreateSolidBrush(COLORREF( + (COLOR_WINDOW.0 + 1).try_into().expect("i32 to fit in u32"), + )), + ..Default::default() + }; + + let _atom = RegisterClassW(&wc); + + let capture = InputCapture::new(); + + // Pass InputCapture as lpParam + let capture_ptr = Box::into_raw(Box::new(capture.clone())); + + // Create window + // + let handle = CreateWindowExW( + WINDOW_EX_STYLE(0), + window_class, + title, + WS_OVERLAPPEDWINDOW | WS_VISIBLE, + CW_USEDEFAULT, + CW_USEDEFAULT, + 400, + 300, + None, + None, + Some(instance), + Some(capture_ptr as *const _), + ) + .expect("window should be created"); + + // Process pending messages + process_messages(); + thread::sleep(Duration::from_millis(100)); + + Ok(TestWindow { + handle, + capture: Some(capture), + }) + } +} + +fn create_title_window(title: PCSTR, proc_type: ProcType) -> Result { + unsafe { + let instance = GetModuleHandleA(None)?; + let instance: HINSTANCE = instance.into(); + debug_assert!(!instance.is_invalid()); + + let window_class = s!("input_window"); + + // Register window class with our custom proc + // + let wc = WNDCLASSA { + hCursor: LoadCursorW(None, IDC_ARROW)?, + hInstance: instance, + lpszClassName: window_class, + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(proc_type), + ..Default::default() + }; + + let _atom = RegisterClassA(&wc); + + // Create window + // + let handle = CreateWindowExA( + WINDOW_EX_STYLE::default(), + window_class, + title, + WS_OVERLAPPEDWINDOW | WS_VISIBLE, + CW_USEDEFAULT, + CW_USEDEFAULT, + 800, + 600, + None, + None, + Some(instance), + None, + ) + .expect("window should be created"); + + Ok(TestWindow { + handle, + capture: None, + }) + } +} + +#[serial] +#[test] +fn test_get_active_window_title_success() { + let title; + { + let window = create_title_window(s!("TITLE_FOOBAR"), show_window_proc).unwrap(); + window.set_foreground().unwrap(); + title = get_foreground_window_title().unwrap(); + } + + assert_eq!(title, "TITLE_FOOBAR\0".to_owned()); + + thread::sleep(Duration::from_millis(100)); +} + +#[serial] +#[test] +fn test_get_active_window_title_doesnt_fail_if_empty_title() { + let title; + { + let window = create_title_window(s!(""), show_window_proc).unwrap(); + window.set_foreground().unwrap(); + title = get_foreground_window_title(); + } + + assert_eq!(title.unwrap(), "".to_owned()); + + thread::sleep(Duration::from_millis(100)); +} + +#[serial] +#[test] +fn test_type_input_success() { + const TAB: u16 = 0x09; + let chars; + { + let window = create_input_window(w!("foo"), capture_input_proc).unwrap(); + window.set_foreground().unwrap(); + + type_input( + &[ + 0x66, 0x6F, 0x6C, 0x6C, 0x6F, 0x77, 0x5F, 0x74, 0x68, 0x65, TAB, 0x77, 0x68, 0x69, + 0x74, 0x65, 0x5F, 0x72, 0x61, 0x62, 0x62, 0x69, 0x74, + ], + &["Control".to_owned(), "Alt".to_owned(), "B".to_owned()], + ) + .unwrap(); + + // Wait for and process input messages + window.wait_for_input(250); + + // Verify captured input + let capture = window.capture.as_ref().unwrap(); + chars = capture.get_chars(); + } + + assert!(!chars.is_empty(), "No input captured"); + + let input_str = String::from_iter(chars.iter()); + let input_str = input_str.replace("\t", "_"); + + assert_eq!(input_str, "follow_the_white_rabbit"); + + thread::sleep(Duration::from_millis(100)); +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml index ff641731661..5cc457809f2 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml @@ -9,19 +9,19 @@ publish.workspace = true [target.'cfg(target_os = "windows")'.dependencies] aes-gcm = { workspace = true } +anyhow = { workspace = true } +base64 = { workspace = true } chacha20poly1305 = { workspace = true } chromium_importer = { path = "../chromium_importer" } clap = { version = "=4.5.53", features = ["derive"] } scopeguard = { workspace = true } sysinfo = { workspace = true } -windows = { workspace = true, features = [ - "Win32_System_Pipes", -] } -anyhow = { workspace = true } -base64 = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } +windows = { workspace = true, features = [ + "Win32_System_Pipes", +] } [build-dependencies] embed-resource = "=3.0.6" diff --git a/apps/desktop/desktop_native/build.js b/apps/desktop/desktop_native/build.js index 54a6dba8326..b20aa7e5af8 100644 --- a/apps/desktop/desktop_native/build.js +++ b/apps/desktop/desktop_native/build.js @@ -20,47 +20,79 @@ fs.mkdirSync(path.join(__dirname, "dist"), { recursive: true }); const args = process.argv.slice(2); // Get arguments passed to the script const mode = args.includes("--release") ? "release" : "debug"; +const isRelease = mode === "release"; const targetArg = args.find(arg => arg.startsWith("--target=")); const target = targetArg ? targetArg.split("=")[1] : null; let crossPlatform = process.argv.length > 2 && process.argv[2] === "cross-platform"; +/** + * Execute a command. + * @param {string} bin Executable to run. + * @param {string[]} args Arguments for executable. + * @param {string} [workingDirectory] Path to working directory, relative to the script directory. Defaults to the script directory. + * @param {string} [useShell] Whether to use a shell to execute the command. Defaults to false. + */ +function runCommand(bin, args, workingDirectory = "", useShell = false) { + const options = { stdio: 'inherit', cwd: path.resolve(__dirname, workingDirectory), shell: useShell } + console.debug("Running command:", bin, args, options) + child_process.execFileSync(bin, args, options) +} + function buildNapiModule(target, release = true) { - const targetArg = target ? `--target ${target}` : ""; + const targetArg = target ? `--target=${target}` : ""; const releaseArg = release ? "--release" : ""; - child_process.execSync(`npm run build -- ${releaseArg} ${targetArg}`, { stdio: 'inherit', cwd: path.join(__dirname, "napi") }); + const crossCompileArg = effectivePlatform(target) !== process.platform ? "--cross-compile" : ""; + runCommand("npm", ["run", "build", "--", crossCompileArg, releaseArg, targetArg].filter(s => s != ''), "./napi", true); +} + +/** + * Build a Rust binary with Cargo. + * + * If {@link target} is specified, cross-compilation helpers are used to build if necessary, and the resulting + * binary is copied to the `dist` folder. + * @param {string} bin Name of cargo binary package in `desktop_native` workspace. + * @param {string?} target Rust compiler target, e.g. `aarch64-pc-windows-msvc`. + * @param {boolean} release Whether to build in release mode. + */ +function cargoBuild(bin, target, release) { + const targetArg = target ? `--target=${target}` : ""; + const releaseArg = release ? "--release" : ""; + const args = ["build", "--bin", bin, releaseArg, targetArg] + // Use cross-compilation helper if necessary + if (effectivePlatform(target) === "win32" && process.platform !== "win32") { + args.unshift("xwin") + } + runCommand("cargo", args.filter(s => s != '')) + + // Infer the architecture and platform if not passed explicitly + let nodeArch, platform; + if (target) { + nodeArch = rustTargetsMap[target].nodeArch; + platform = rustTargetsMap[target].platform; + } + else { + nodeArch = process.arch; + platform = process.platform; + } + + // Copy the resulting binary to the dist folder + const profileFolder = isRelease ? "release" : "debug"; + const ext = platform === "win32" ? ".exe" : ""; + const src = path.join(__dirname, "target", target ? target : "", profileFolder, `${bin}${ext}`) + const dst = path.join(__dirname, "dist", `${bin}.${platform}-${nodeArch}${ext}`) + console.log(`Copying ${src} to ${dst}`); + fs.copyFileSync(src, dst); } function buildProxyBin(target, release = true) { - const targetArg = target ? `--target ${target}` : ""; - const releaseArg = release ? "--release" : ""; - child_process.execSync(`cargo build --bin desktop_proxy ${releaseArg} ${targetArg}`, {stdio: 'inherit', cwd: path.join(__dirname, "proxy")}); - - if (target) { - // Copy the resulting binary to the dist folder - const targetFolder = release ? "release" : "debug"; - const ext = process.platform === "win32" ? ".exe" : ""; - const nodeArch = rustTargetsMap[target].nodeArch; - fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `desktop_proxy${ext}`), path.join(__dirname, "dist", `desktop_proxy.${process.platform}-${nodeArch}${ext}`)); - } + cargoBuild("desktop_proxy", target, release) } function buildImporterBinaries(target, release = true) { // These binaries are only built for Windows, so we can skip them on other platforms - if (process.platform !== "win32") { - return; - } - - const bin = "bitwarden_chromium_import_helper"; - const targetArg = target ? `--target ${target}` : ""; - const releaseArg = release ? "--release" : ""; - child_process.execSync(`cargo build --bin ${bin} ${releaseArg} ${targetArg}`); - - if (target) { - // Copy the resulting binary to the dist folder - const targetFolder = release ? "release" : "debug"; - const nodeArch = rustTargetsMap[target].nodeArch; - fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `${bin}.exe`), path.join(__dirname, "dist", `${bin}.${process.platform}-${nodeArch}.exe`)); + if (effectivePlatform(target) == "win32") { + cargoBuild("bitwarden_chromium_import_helper", target, release) } } @@ -69,17 +101,29 @@ function buildProcessIsolation() { return; } - child_process.execSync(`cargo build --release`, { - stdio: 'inherit', - cwd: path.join(__dirname, "process_isolation") - }); + runCommand("cargo", ["build", "--package", "process_isolation", "--release"]); console.log("Copying process isolation library to dist folder"); fs.copyFileSync(path.join(__dirname, "target", "release", "libprocess_isolation.so"), path.join(__dirname, "dist", `libprocess_isolation.so`)); } function installTarget(target) { - child_process.execSync(`rustup target add ${target}`, { stdio: 'inherit', cwd: __dirname }); + runCommand("rustup", ["target", "add", target]); + // Install cargo-xwin for cross-platform builds targeting Windows + if (target.includes('windows') && process.platform !== 'win32') { + runCommand("cargo", ["install", "--version", "0.20.2", "--locked", "cargo-xwin"]); + // install tools needed for packaging Appx, only supported on macOS for now. + if (process.platform === "darwin") { + runCommand("brew", ["install", "iinuwa/msix-packaging-tap/msix-packaging", "osslsigncode"]); + } + } +} + +function effectivePlatform(target) { + if (target) { + return rustTargetsMap[target].platform + } + return process.platform } if (!crossPlatform && !target) { @@ -94,9 +138,9 @@ if (!crossPlatform && !target) { if (target) { console.log(`Building for target: ${target} in ${mode} mode`); installTarget(target); - buildNapiModule(target, mode === "release"); - buildProxyBin(target, mode === "release"); - buildImporterBinaries(false, mode === "release"); + buildNapiModule(target, isRelease); + buildProxyBin(target, isRelease); + buildImporterBinaries(target, isRelease); buildProcessIsolation(); return; } @@ -113,8 +157,8 @@ if (process.platform === "linux") { platformTargets.forEach(([target, _]) => { installTarget(target); - buildNapiModule(target, mode === "release"); - buildProxyBin(target, mode === "release"); - buildImporterBinaries(target, mode === "release"); + buildNapiModule(target, isRelease); + buildProxyBin(target, isRelease); + buildImporterBinaries(target, isRelease); buildProcessIsolation(); }); diff --git a/apps/desktop/desktop_native/chromium_importer/Cargo.toml b/apps/desktop/desktop_native/chromium_importer/Cargo.toml index 9e9a9e0fee8..9bb1c0b87f2 100644 --- a/apps/desktop/desktop_native/chromium_importer/Cargo.toml +++ b/apps/desktop/desktop_native/chromium_importer/Cargo.toml @@ -16,6 +16,12 @@ rusqlite = { version = "=0.37.0", features = ["bundled"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +cbc = { workspace = true, features = ["alloc"] } +oo7 = { workspace = true } +pbkdf2 = "=0.12.2" +sha1 = "=0.10.6" + [target.'cfg(target_os = "macos")'.dependencies] cbc = { workspace = true, features = ["alloc"] } pbkdf2 = "=0.12.2" @@ -25,20 +31,14 @@ sha1 = "=0.10.6" [target.'cfg(target_os = "windows")'.dependencies] aes-gcm = { workspace = true } base64 = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } +verifysign = "=0.2.4" windows = { workspace = true, features = [ "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } -verifysign = "=0.2.4" -tokio = { workspace = true, features = ["full"] } -tracing = { workspace = true } - -[target.'cfg(target_os = "linux")'.dependencies] -cbc = { workspace = true, features = ["alloc"] } -oo7 = { workspace = true } -pbkdf2 = "=0.12.2" -sha1 = "=0.10.6" [lints] workspace = true diff --git a/apps/desktop/desktop_native/chromium_importer/src/metadata.rs b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs index 9aa2cea6e5e..ea723291fe3 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/metadata.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs @@ -24,12 +24,13 @@ pub fn get_supported_importers( let installed_browsers = T::get_installed_browsers().unwrap_or_default(); const IMPORTERS: &[(&str, &str)] = &[ + ("arccsv", "Arc"), + ("bravecsv", "Brave"), ("chromecsv", "Chrome"), ("chromiumcsv", "Chromium"), - ("bravecsv", "Brave"), + ("edgecsv", "Microsoft Edge"), ("operacsv", "Opera"), ("vivaldicsv", "Vivaldi"), - ("edgecsv", "Microsoft Edge"), ]; let supported: HashSet<&'static str> = @@ -91,6 +92,7 @@ mod tests { let map = get_supported_importers::(); let expected: HashSet = HashSet::from([ + "arccsv".to_string(), "chromecsv".to_string(), "chromiumcsv".to_string(), "bravecsv".to_string(), @@ -113,6 +115,7 @@ mod tests { fn macos_specific_loaders_match_const_array() { let map = get_supported_importers::(); let ids = [ + "arccsv", "chromecsv", "chromiumcsv", "bravecsv", diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index dc9246f55c6..6dfe0487ed0 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -13,7 +13,7 @@ default = [ "dep:security-framework", "dep:security-framework-sys", "dep:zbus", - "dep:zbus_polkit" + "dep:zbus_polkit", ] manual_test = [] @@ -31,6 +31,7 @@ futures = { workspace = true } interprocess = { workspace = true, features = ["tokio"] } memsec = { workspace = true, features = ["alloc_ext"] } rand = { workspace = true } +rsa = "=0.9.6" sha2 = { workspace = true } ssh-key = { workspace = true, features = [ "encryption", @@ -46,6 +47,23 @@ tracing = { workspace = true } typenum = { workspace = true } zeroizing-alloc = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +ashpd = { workspace = true } +homedir = { workspace = true } +libc = { workspace = true } +linux-keyutils = { workspace = true } +oo7 = { workspace = true } +zbus = { workspace = true, optional = true } +zbus_polkit = { workspace = true, optional = true } + +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = { workspace = true, optional = true } +desktop_objc = { path = "../objc" } +homedir = { workspace = true } +secmem-proc = { workspace = true } +security-framework = { workspace = true, optional = true } +security-framework-sys = { workspace = true, optional = true } + [target.'cfg(windows)'.dependencies] pin-project = { workspace = true } scopeguard = { workspace = true } @@ -68,22 +86,8 @@ windows = { workspace = true, features = [ ], optional = true } windows-future = { workspace = true } -[target.'cfg(target_os = "macos")'.dependencies] -core-foundation = { workspace = true, optional = true } -homedir = { workspace = true } -secmem-proc = { workspace = true } -security-framework = { workspace = true, optional = true } -security-framework-sys = { workspace = true, optional = true } -desktop_objc = { path = "../objc" } - -[target.'cfg(target_os = "linux")'.dependencies] -ashpd = { workspace = true } -homedir = { workspace = true } -libc = { workspace = true } -linux-keyutils = { workspace = true } -oo7 = { workspace = true } -zbus = { workspace = true, optional = true } -zbus_polkit = { workspace = true, optional = true } +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } [lints] workspace = true diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs b/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs index ff2abc0686b..ef6527e7b26 100644 --- a/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs +++ b/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs @@ -19,20 +19,18 @@ use tracing::{debug, warn}; use zbus::Connection; use zbus_polkit::policykit1::{AuthorityProxy, CheckAuthorizationFlags, Subject}; -use crate::secure_memory::*; +use crate::secure_memory::{encrypted_memory_store::EncryptedMemoryStore, SecureMemoryStore as _}; pub struct BiometricLockSystem { // The userkeys that are held in memory MUST be protected from memory dumping attacks, to // ensure locked vaults cannot be unlocked - secure_memory: Arc>, + secure_memory: Arc>>, } impl BiometricLockSystem { pub fn new() -> Self { Self { - secure_memory: Arc::new(Mutex::new( - crate::secure_memory::encrypted_memory_store::EncryptedMemoryStore::new(), - )), + secure_memory: Arc::new(Mutex::new(EncryptedMemoryStore::default())), } } } @@ -64,7 +62,7 @@ impl super::BiometricTrait for BiometricLockSystem { .put(user_id.to_string(), key); } - async fn unlock(&self, user_id: &str, _hwnd: Vec) -> Result> { + async fn unlock(&self, user_id: &String, _hwnd: Vec) -> Result> { if !polkit_authenticate_bitwarden_policy().await? { return Err(anyhow!("Authentication failed")); } @@ -72,11 +70,11 @@ impl super::BiometricTrait for BiometricLockSystem { self.secure_memory .lock() .await - .get(user_id) + .get(user_id)? .ok_or(anyhow!("No key found")) } - async fn unlock_available(&self, user_id: &str) -> Result { + async fn unlock_available(&self, user_id: &String) -> Result { Ok(self.secure_memory.lock().await.has(user_id)) } @@ -84,7 +82,7 @@ impl super::BiometricTrait for BiometricLockSystem { Ok(false) } - async fn unenroll(&self, user_id: &str) -> Result<(), anyhow::Error> { + async fn unenroll(&self, user_id: &String) -> Result<(), anyhow::Error> { self.secure_memory.lock().await.remove(user_id); Ok(()) } diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs b/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs index 55aee27dd33..d577a2a0c0b 100644 --- a/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs +++ b/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs @@ -21,14 +21,17 @@ pub trait BiometricTrait: Send + Sync { /// enrollment, this function should do nothing. async fn enroll_persistent(&self, user_id: &str, key: &[u8]) -> Result<()>; /// Clear the persistent and ephemeral keys - async fn unenroll(&self, user_id: &str) -> Result<()>; + #[allow(clippy::ptr_arg)] // to allow using user_id as map key type + async fn unenroll(&self, user_id: &String) -> Result<()>; /// Check if a persistent (survives app restarts and reboots) key is set for a user async fn has_persistent(&self, user_id: &str) -> Result; /// Provide a key to be ephemerally held. This should be called on every unlock. async fn provide_key(&self, user_id: &str, key: &[u8]); /// Perform biometric unlock and return the key - async fn unlock(&self, user_id: &str, hwnd: Vec) -> Result>; + #[allow(clippy::ptr_arg)] // to allow using user_id as map key type + async fn unlock(&self, user_id: &String, hwnd: Vec) -> Result>; /// Check if biometric unlock is available based on whether a key is present and whether /// authentication is possible - async fn unlock_available(&self, user_id: &str) -> Result; + #[allow(clippy::ptr_arg)] // to allow using user_id as map key type + async fn unlock_available(&self, user_id: &String) -> Result; } diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/unimplemented.rs b/apps/desktop/desktop_native/core/src/biometric_v2/unimplemented.rs index 1503cfea89c..02ac435da3c 100644 --- a/apps/desktop/desktop_native/core/src/biometric_v2/unimplemented.rs +++ b/apps/desktop/desktop_native/core/src/biometric_v2/unimplemented.rs @@ -29,11 +29,11 @@ impl super::BiometricTrait for BiometricLockSystem { unimplemented!() } - async fn unlock(&self, _user_id: &str, _hwnd: Vec) -> Result, anyhow::Error> { + async fn unlock(&self, _user_id: &String, _hwnd: Vec) -> Result, anyhow::Error> { unimplemented!() } - async fn unlock_available(&self, _user_id: &str) -> Result { + async fn unlock_available(&self, _user_id: &String) -> Result { unimplemented!() } @@ -41,7 +41,7 @@ impl super::BiometricTrait for BiometricLockSystem { unimplemented!() } - async fn unenroll(&self, _user_id: &str) -> Result<(), anyhow::Error> { + async fn unenroll(&self, _user_id: &String) -> Result<(), anyhow::Error> { unimplemented!() } } diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs b/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs index 669dd757c40..914bf50c20e 100644 --- a/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs +++ b/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs @@ -110,7 +110,7 @@ impl super::BiometricTrait for BiometricLockSystem { } } - async fn unenroll(&self, user_id: &str) -> Result<()> { + async fn unenroll(&self, user_id: &String) -> Result<()> { self.secure_memory.lock().await.remove(user_id); delete_keychain_entry(user_id).await } @@ -148,7 +148,7 @@ impl super::BiometricTrait for BiometricLockSystem { .put(user_id.to_string(), key); } - async fn unlock(&self, user_id: &str, _hwnd: Vec) -> Result> { + async fn unlock(&self, user_id: &String, _hwnd: Vec) -> Result> { // Allow restoring focus to the previous window (browser) let previous_active_window = super::windows_focus::get_active_window(); let _focus_scopeguard = scopeguard::guard((), |_| { @@ -164,8 +164,7 @@ impl super::BiometricTrait for BiometricLockSystem { if secure_memory.has(user_id) { if windows_hello_authenticate("Unlock your vault".to_string()).await? { secure_memory - .get(user_id) - .clone() + .get(user_id)? .ok_or_else(|| anyhow!("No key found for user")) } else { Err(anyhow!("Authentication failed")) @@ -186,7 +185,7 @@ impl super::BiometricTrait for BiometricLockSystem { } } - async fn unlock_available(&self, user_id: &str) -> Result { + async fn unlock_available(&self, user_id: &String) -> Result { let secure_memory = self.secure_memory.lock().await; let has_key = secure_memory.has(user_id) || has_keychain_entry(user_id).await.unwrap_or(false); @@ -435,7 +434,7 @@ mod tests { #[tokio::test] #[ignore] async fn test_double_unenroll() { - let user_id = "test_user"; + let user_id = String::from("test_user"); let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH]; rand::fill(&mut key); @@ -443,34 +442,34 @@ mod tests { println!("Enrolling user"); windows_hello_lock_system - .enroll_persistent(user_id, &key) + .enroll_persistent(&user_id, &key) .await .unwrap(); assert!(windows_hello_lock_system - .has_persistent(user_id) + .has_persistent(&user_id) .await .unwrap()); println!("Unlocking user"); let key_after_unlock = windows_hello_lock_system - .unlock(user_id, Vec::new()) + .unlock(&user_id, Vec::new()) .await .unwrap(); assert_eq!(key_after_unlock, key); println!("Unenrolling user"); - windows_hello_lock_system.unenroll(user_id).await.unwrap(); + windows_hello_lock_system.unenroll(&user_id).await.unwrap(); assert!(!windows_hello_lock_system - .has_persistent(user_id) + .has_persistent(&user_id) .await .unwrap()); println!("Unenrolling user again"); // This throws PASSWORD_NOT_FOUND but our code should handle that and not throw. - windows_hello_lock_system.unenroll(user_id).await.unwrap(); + windows_hello_lock_system.unenroll(&user_id).await.unwrap(); assert!(!windows_hello_lock_system - .has_persistent(user_id) + .has_persistent(&user_id) .await .unwrap()); } @@ -478,7 +477,7 @@ mod tests { #[tokio::test] #[ignore] async fn test_enroll_unlock_unenroll() { - let user_id = "test_user"; + let user_id = String::from("test_user"); let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH]; rand::fill(&mut key); @@ -486,25 +485,25 @@ mod tests { println!("Enrolling user"); windows_hello_lock_system - .enroll_persistent(user_id, &key) + .enroll_persistent(&user_id, &key) .await .unwrap(); assert!(windows_hello_lock_system - .has_persistent(user_id) + .has_persistent(&user_id) .await .unwrap()); println!("Unlocking user"); let key_after_unlock = windows_hello_lock_system - .unlock(user_id, Vec::new()) + .unlock(&user_id, Vec::new()) .await .unwrap(); assert_eq!(key_after_unlock, key); println!("Unenrolling user"); - windows_hello_lock_system.unenroll(user_id).await.unwrap(); + windows_hello_lock_system.unenroll(&user_id).await.unwrap(); assert!(!windows_hello_lock_system - .has_persistent(user_id) + .has_persistent(&user_id) .await .unwrap()); } diff --git a/apps/desktop/desktop_native/core/src/lib.rs b/apps/desktop/desktop_native/core/src/lib.rs index 668badb95ed..b6a558b7152 100644 --- a/apps/desktop/desktop_native/core/src/lib.rs +++ b/apps/desktop/desktop_native/core/src/lib.rs @@ -9,7 +9,7 @@ pub mod ipc; pub mod password; pub mod powermonitor; pub mod process_isolation; -pub(crate) mod secure_memory; +pub mod secure_memory; pub mod ssh_agent; use zeroizing_alloc::ZeroAlloc; diff --git a/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs b/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs index 8d8e10d92c4..45b8936a17f 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs @@ -5,7 +5,7 @@ use windows::Win32::Security::Cryptography::{ CRYPTPROTECTMEMORY_SAME_PROCESS, }; -use crate::secure_memory::SecureMemoryStore; +use crate::secure_memory::{DecryptionError, SecureMemoryStore}; /// https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata /// The DPAPI store encrypts data using the Windows Data Protection API (DPAPI). The key is bound @@ -26,7 +26,9 @@ impl DpapiSecretKVStore { } impl SecureMemoryStore for DpapiSecretKVStore { - fn put(&mut self, key: String, value: &[u8]) { + type KeyType = String; + + fn put(&mut self, key: Self::KeyType, value: &[u8]) { let length_header_len = std::mem::size_of::(); // The allocated data has to be a multiple of CRYPTPROTECTMEMORY_BLOCK_SIZE, so we pad it @@ -55,8 +57,8 @@ impl SecureMemoryStore for DpapiSecretKVStore { self.map.insert(key, padded_data); } - fn get(&mut self, key: &str) -> Option> { - self.map.get(key).map(|data| { + fn get(&mut self, key: &Self::KeyType) -> Result>, DecryptionError> { + if let Some(data) = self.map.get(key) { // A copy is created, that is then mutated by the DPAPI unprotect function. let mut data = data.clone(); unsafe { @@ -77,15 +79,19 @@ impl SecureMemoryStore for DpapiSecretKVStore { .expect("length header should be usize"), ); - data[length_header_size..length_header_size + data_length].to_vec() - }) + Ok(Some( + data[length_header_size..length_header_size + data_length].to_vec(), + )) + } else { + Ok(None) + } } - fn has(&self, key: &str) -> bool { + fn has(&self, key: &Self::KeyType) -> bool { self.map.contains_key(key) } - fn remove(&mut self, key: &str) { + fn remove(&mut self, key: &Self::KeyType) { self.map.remove(key); } @@ -113,7 +119,7 @@ mod tests { store.put(key.clone(), &value); assert!(store.has(&key), "Store should have key for size {}", size); assert_eq!( - store.get(&key), + store.get(&key).expect("entry in map for key"), Some(value), "Value mismatch for size {}", size @@ -128,7 +134,7 @@ mod tests { let value = vec![1, 2, 3, 4, 5]; store.put(key.clone(), &value); assert!(store.has(&key)); - assert_eq!(store.get(&key), Some(value)); + assert_eq!(store.get(&key).expect("entry in map for key"), Some(value)); store.remove(&key); assert!(!store.has(&key)); } diff --git a/apps/desktop/desktop_native/core/src/secure_memory/encrypted_memory_store.rs b/apps/desktop/desktop_native/core/src/secure_memory/encrypted_memory_store.rs index d116e564bc8..8961b63ccee 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/encrypted_memory_store.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/encrypted_memory_store.rs @@ -1,7 +1,9 @@ +use std::collections::BTreeMap; + use tracing::error; use crate::secure_memory::{ - secure_key::{EncryptedMemory, SecureMemoryEncryptionKey}, + secure_key::{DecryptionError, EncryptedMemory, SecureMemoryEncryptionKey}, SecureMemoryStore, }; @@ -12,50 +14,87 @@ use crate::secure_memory::{ /// /// The key is briefly in process memory during encryption and decryption, in memory that is /// protected from swapping to disk via mlock, and then zeroed out immediately after use. -#[allow(unused)] -pub(crate) struct EncryptedMemoryStore { - map: std::collections::HashMap, +/// # Type Parameters +/// +/// * `K` - The type of the key. +pub struct EncryptedMemoryStore +where + K: std::cmp::Ord + std::fmt::Display + std::clone::Clone, +{ + map: BTreeMap, memory_encryption_key: SecureMemoryEncryptionKey, } -impl EncryptedMemoryStore { - #[allow(unused)] - pub(crate) fn new() -> Self { +impl EncryptedMemoryStore +where + K: std::cmp::Ord + std::fmt::Display + std::clone::Clone, +{ + #[must_use] + pub fn new() -> Self { EncryptedMemoryStore { - map: std::collections::HashMap::new(), + map: BTreeMap::new(), memory_encryption_key: SecureMemoryEncryptionKey::new(), } } + + /// # Returns + /// + /// An array of all decrypted values. + /// Due to the usage of `BtreeMap`, the order is deterministic. + /// + /// # Errors + /// + /// `DecryptionError` if an error occured during decryption + pub fn to_vec(&mut self) -> Result>, DecryptionError> { + let mut result = vec![]; + let keys: Vec<_> = self.map.keys().cloned().collect(); + + for key in &keys { + let bytes = self.get(key)?.expect("All keys to still be in map."); + result.push(bytes); + } + Ok(result) + } } -impl SecureMemoryStore for EncryptedMemoryStore { - fn put(&mut self, key: String, value: &[u8]) { +impl Default for EncryptedMemoryStore +where + K: std::cmp::Ord + std::fmt::Display + std::clone::Clone, +{ + fn default() -> Self { + Self::new() + } +} + +impl SecureMemoryStore for EncryptedMemoryStore +where + K: std::cmp::Ord + std::fmt::Display + std::clone::Clone, +{ + type KeyType = K; + + fn put(&mut self, key: Self::KeyType, value: &[u8]) { let encrypted_value = self.memory_encryption_key.encrypt(value); self.map.insert(key, encrypted_value); } - fn get(&mut self, key: &str) -> Option> { - let encrypted_memory = self.map.get(key); - if let Some(encrypted_memory) = encrypted_memory { - match self.memory_encryption_key.decrypt(encrypted_memory) { - Ok(plaintext) => Some(plaintext), - Err(_) => { - error!("In memory store, decryption failed for key {}. The memory may have been tampered with. re-keying.", key); - self.memory_encryption_key = SecureMemoryEncryptionKey::new(); - self.clear(); - None - } - } + fn get(&mut self, key: &Self::KeyType) -> Result>, DecryptionError> { + if let Some(encrypted) = self.map.get(key) { + self.memory_encryption_key.decrypt(encrypted).map_err(|error| { + error!(?error, %key, "In memory store, decryption failed. The memory may have been tampered with. Re-keying."); + self.memory_encryption_key = SecureMemoryEncryptionKey::new(); + self.clear(); + error + }).map(Some) } else { - None + Ok(None) } } - fn has(&self, key: &str) -> bool { + fn has(&self, key: &Self::KeyType) -> bool { self.map.contains_key(key) } - fn remove(&mut self, key: &str) { + fn remove(&mut self, key: &Self::KeyType) { self.map.remove(key); } @@ -64,7 +103,10 @@ impl SecureMemoryStore for EncryptedMemoryStore { } } -impl Drop for EncryptedMemoryStore { +impl Drop for EncryptedMemoryStore +where + K: std::cmp::Ord + std::fmt::Display + std::clone::Clone, +{ fn drop(&mut self) { self.clear(); } @@ -76,30 +118,98 @@ mod tests { #[test] fn test_secret_kv_store_various_sizes() { - let mut store = EncryptedMemoryStore::new(); + let mut store = EncryptedMemoryStore::default(); for size in 0..=2048 { - let key = format!("test_key_{}", size); + let key = format!("test_key_{size}"); let value: Vec = (0..size).map(|i| (i % 256) as u8).collect(); store.put(key.clone(), &value); - assert!(store.has(&key), "Store should have key for size {}", size); + assert!(store.has(&key), "Store should have key for size {size}"); assert_eq!( - store.get(&key), + store.get(&key).expect("entry in map for key"), Some(value), - "Value mismatch for size {}", - size + "Value mismatch for size {size}", ); } } #[test] fn test_crud() { - let mut store = EncryptedMemoryStore::new(); + let mut store = EncryptedMemoryStore::default(); let key = "test_key".to_string(); let value = vec![1, 2, 3, 4, 5]; store.put(key.clone(), &value); assert!(store.has(&key)); - assert_eq!(store.get(&key), Some(value)); + assert_eq!(store.get(&key).expect("entry in map for key"), Some(value)); store.remove(&key); assert!(!store.has(&key)); } + + #[test] + fn test_to_vec_contains_all() { + let mut store = EncryptedMemoryStore::default(); + + for size in 0..=2048 { + let key = format!("test_key_{size}"); + let value: Vec = (0..size).map(|i| (i % 256) as u8).collect(); + store.put(key.clone(), &value); + } + let vec_values = store.to_vec().expect("decryption to not fail"); + + // to_vec() should contain same number of values as inserted + assert_eq!(vec_values.len(), 2049); + + // the value from the store should match the value in the vec + let keys: Vec<_> = store.map.keys().cloned().collect(); + for (store_key, vec_value) in keys.iter().zip(vec_values.iter()) { + let store_value = store.get(store_key).expect("entry in map for key").unwrap(); + assert_eq!(&store_value, vec_value); + store.remove(store_key); + } + + // all values were present + assert!(store.map.is_empty()); + } + + #[test] + fn test_to_vec_preserves_sorted_key_order() { + let mut store = EncryptedMemoryStore::new(); + + // insert in non-sorted order + store.put("morpheus", &[4, 5, 6]); + store.put("trinity", &[1, 2, 3]); + store.put("dozer", &[7, 8, 9]); + store.put("neo", &[10, 11, 12]); + + let vec = store.to_vec().expect("decryption to not fail"); + + assert_eq!( + vec, + vec![ + vec![7, 8, 9], // dozer + vec![4, 5, 6], // morpheus + vec![10, 11, 12], // neo + vec![1, 2, 3], // trinity + ] + ); + } + + #[test] + fn test_to_vec_order_after_remove() { + let mut store = EncryptedMemoryStore::new(); + + // insert in non-sorted order + store.put("trinity", &[3]); + store.put("morpheus", &[1]); + store.put("neo", &[2]); + + let vec = store.to_vec().expect("decryption to not fail"); + + assert_eq!(vec, vec![vec![1], vec![2], vec![3]]); + + store.remove(&"neo"); + + let vec = store.to_vec().expect("decryption to not fail"); + + assert_eq!(vec, vec![vec![1], vec![3]]); + } } diff --git a/apps/desktop/desktop_native/core/src/secure_memory/mod.rs b/apps/desktop/desktop_native/core/src/secure_memory/mod.rs index d4323ce40dd..b5c3bcdccd9 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/mod.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/mod.rs @@ -4,24 +4,35 @@ pub(crate) mod dpapi; pub(crate) mod encrypted_memory_store; mod secure_key; +pub use encrypted_memory_store::EncryptedMemoryStore; + +use crate::secure_memory::secure_key::DecryptionError; + /// The secure memory store provides an ephemeral key-value store for sensitive data. /// Data stored in this store is prevented from being swapped to disk and zeroed out. Additionally, /// platform-specific protections are applied to prevent memory dumps or debugger access from /// reading the stored values. -#[allow(unused)] -pub(crate) trait SecureMemoryStore { +pub trait SecureMemoryStore { + type KeyType; + /// Stores a copy of the provided value in secure memory. - fn put(&mut self, key: String, value: &[u8]); + fn put(&mut self, key: Self::KeyType, value: &[u8]); + /// Retrieves a copy of the value associated with the given key from secure memory. /// This copy does not have additional memory protections applied, and should be zeroed when no /// longer needed. /// - /// Note: If memory was tampered with, this will re-key the store and return None. - fn get(&mut self, key: &str) -> Option>; + /// # Errors + /// + /// `DecryptionError` if memory is tampered with. This also re-keys the memory store. + fn get(&mut self, key: &Self::KeyType) -> Result>, DecryptionError>; + /// Checks if a value is stored under the given key. - fn has(&self, key: &str) -> bool; + fn has(&self, key: &Self::KeyType) -> bool; + /// Removes the value associated with the given key from secure memory. - fn remove(&mut self, key: &str); + fn remove(&mut self, key: &Self::KeyType); + /// Clears all values stored in secure memory. fn clear(&mut self); } diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/crypto.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/crypto.rs index 7e2917ade6d..7fb3bcc299a 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/crypto.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/crypto.rs @@ -77,10 +77,20 @@ impl AsRef<[u8]> for MemoryEncryptionKey { } #[derive(Debug)] -pub(crate) enum DecryptionError { +pub enum DecryptionError { CouldNotDecrypt, } +impl std::error::Error for DecryptionError {} + +impl std::fmt::Display for DecryptionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DecryptionError::CouldNotDecrypt => write!(f, "Could not decrypt"), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mod.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mod.rs index 26e72f7d581..8050157a5f3 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mod.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mod.rs @@ -19,9 +19,7 @@ mod keyctl; mod memfd_secret; mod mlock; -pub use crypto::EncryptedMemory; - -use crate::secure_memory::secure_key::crypto::DecryptionError; +pub use crypto::{DecryptionError, EncryptedMemory}; /// An ephemeral key that is protected using a platform mechanism. It is generated on construction /// freshly, and can be used to encrypt and decrypt segments of memory. Since the key is ephemeral, diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs index 8ba64618ffa..a8938acb992 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs @@ -307,3 +307,128 @@ fn parse_key_safe(pem: &str) -> Result Err(anyhow::Error::msg(format!("Failed to parse key: {e}"))), } } + +#[cfg(test)] +mod tests { + use std::sync::atomic::Ordering; + + use ssh_key::Signature; + + use super::*; + + // Test Ed25519 key (unencrypted OpenSSH format) + const TEST_ED25519_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAOYor3+kyAsXYs2sGikmUuhpxmVf2hAGd2TK7KwN4N9gAAAJj79ujB+/bo +wQAAAAtzc2gtZWQyNTUxOQAAACAOYor3+kyAsXYs2sGikmUuhpxmVf2hAGd2TK7KwN4N9g +AAAEAgAQkLDKjON00XO+Y09BoIBuQsAXAx6HUhQoTEodVzig5iivf6TICxdizawaKSZS6G +nGZV/aEAZ3ZMrsrA3g32AAAAEHRlc3RAZXhhbXBsZS5jb20BAgMEBQ== +-----END OPENSSH PRIVATE KEY-----"; + + // Test RSA 2048-bit key (unencrypted OpenSSH format) + const TEST_RSA_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn +NhAAAAAwEAAQAAAQEAy0YUFvgBLMZXIKjsBfcdO6N2Kk2VmjSpxa2aFD1TrAcVyyIZ9v8o +slQITyFL4GCK5VCJX9bqXBwc9ml8G/zt21ue6nadeZLhp2iXeQ+VUxmola9HhaFvxSNqi0 +MOJaWIfmisH4jt7Msdv4jwlDE5AkHAFig8wiwDgvSV3kmfhyPs38aq8Pa+wT3zBneGXT17 +34OhH4nicuq+L0GcR9BJQ5+jXNQIgGdqd7sKa8JchPXLXAbTug2SfwRmKgiCM0L6JQ5NSQ +FdRHW/iz4ARacSkHP3w0pH6ZtAd8+glzvZn1KcXwrN/CYl3fqFwiwcQXIF0KDoOI/UyiKZ +uDE+DW5M1wAAA8g2Sf0XNkn9FwAAAAdzc2gtcnNhAAABAQDLRhQW+AEsxlcgqOwF9x07o3 +YqTZWaNKnFrZoUPVOsBxXLIhn2/yiyVAhPIUvgYIrlUIlf1upcHBz2aXwb/O3bW57qdp15 +kuGnaJd5D5VTGaiVr0eFoW/FI2qLQw4lpYh+aKwfiO3syx2/iPCUMTkCQcAWKDzCLAOC9J +XeSZ+HI+zfxqrw9r7BPfMGd4ZdPXvfg6EfieJy6r4vQZxH0ElDn6Nc1AiAZ2p3uwprwlyE +9ctcBtO6DZJ/BGYqCIIzQvolDk1JAV1Edb+LPgBFpxKQc/fDSkfpm0B3z6CXO9mfUpxfCs +38JiXd+oXCLBxBcgXQoOg4j9TKIpm4MT4NbkzXAAAAAwEAAQAAAQB9HWssIAYJGyNxlMeB +fHJfzOLkctCME7ITXCEkKAMiNVIyr5CvuKnB6XsbyXC8cG/NaV7EwLGLdDpXaOHdEDcO9z +u/MLcIp2GA+x2QhAjzFy3uw+4P0CfNfVkM0n8YqOR0edTHrC5Vu0daJt19OTbPrsyeVrHf +Cdw3dHfyU/p+4IMP9NRA5ZSmYuOacC7ZoZU7xeVBpeZ4KEzrO98iIWtscncaQv4AcaAehL +VpvZWG1QmRhdbooU2ce5KH3aFKiyszcMGPMzn4aTZS14ycLFzmrMSa+nYf+nHXmyR5KmBd +A5P6ZLtcpT1xw6CC/ItRsdD7E67bugG38lgQpzloHAsRAAAAgBVKGMFi+lP+HKYdSzPAQN +n3HxVuuZ5VIjM6Rq2SxfdyGKj5PH4+ofNGBrF5j1du1oqfPypMM/B75bkBNOlzn6TQcgyX +YlsVOF31aE1hRg8eN1BH2bc1DC43MyTHgunAFzIYfs1hbX8i+cMybzXSTDsIc/xvQHkJ2w +TrPuz7+MATAAAAgQDk6e4ywxrINaOcuDKmRQxTs7rlkJk/tX59OkkqD/gYLMBRMfeKeuFD +Y8M1f5vlDkGFD/Jy0RtTfEJh02VjKTrszaaGCDFHe9tt6DAHY457tzr856zsq5hKDFEU0+ +jd+yE8QaloegGrcpujrxHnrpZx/7mA2qjQxLveHyCGWH3Q2wAAAIEA41N7DKxeb0doXai7 +Sl8+RpZBoyCyNkexWKHAeATKb4abd+k5/EEoLAb6aKaGMzMPm+s82l0lozVreKvHdAdZsY +fq1lhaVvnRWZhN/DXf7Akgicrg/TLqHH9w6db0Vg5A+zHmbkUzZ4A30CYIgn4vzVv5YIq3 +CmfliIQWtUylhrUAAAAQdGVzdEBleGFtcGxlLmNvbQECAw== +-----END OPENSSH PRIVATE KEY-----"; + + fn create_test_agent() -> ( + BitwardenDesktopAgent, + tokio::sync::mpsc::Receiver, + tokio::sync::broadcast::Sender<(u32, bool)>, + ) { + let (request_tx, request_rx) = tokio::sync::mpsc::channel::(16); + let (response_tx, response_rx) = tokio::sync::broadcast::channel::<(u32, bool)>(16); + let response_rx = Arc::new(Mutex::new(response_rx)); + + let agent = BitwardenDesktopAgent::new(request_tx, response_rx); + (agent, request_rx, response_tx) + } + + #[tokio::test] + async fn test_agent_sign_with_ed25519_key() { + let (mut agent, _request_rx, _response_tx) = create_test_agent(); + agent.is_running.store(true, Ordering::Relaxed); + + let keys = vec![( + TEST_ED25519_KEY.to_string(), + "ed25519-key".to_string(), + "ed25519-uuid".to_string(), + )]; + agent.set_keys(keys).expect("set_keys should succeed"); + + let keystore = agent.keystore.0.read().expect("RwLock is not poisoned"); + assert_eq!(keystore.len(), 1); + let (_pub_bytes, ssh_key) = keystore.iter().next().expect("should have one key"); + + // Verify the key metadata + assert_eq!(ssh_key.name, "ed25519-key"); + assert_eq!(ssh_key.cipher_uuid, "ed25519-uuid"); + + // Verify the key can sign data + let signing_key = ssh_key.private_key().expect("should have signing key"); + let message = b"test message for ed25519"; + let signature: Signature = signing_key.try_sign(message).expect("signing should work"); + + // Verify signature is non-empty and has expected algorithm + assert!(!signature.as_bytes().is_empty()); + assert_eq!(signature.algorithm(), ssh_key::Algorithm::Ed25519); + } + + #[tokio::test] + async fn test_agent_sign_with_rsa_key() { + let (mut agent, _request_rx, _response_tx) = create_test_agent(); + agent.is_running.store(true, Ordering::Relaxed); + + let keys = vec![( + TEST_RSA_KEY.to_string(), + "rsa-key".to_string(), + "rsa-uuid".to_string(), + )]; + agent.set_keys(keys).expect("set_keys should succeed"); + + let keystore = agent.keystore.0.read().expect("RwLock is not poisoned"); + assert_eq!(keystore.len(), 1); + let (_pub_bytes, ssh_key) = keystore.iter().next().expect("should have one key"); + + // Verify the key metadata + assert_eq!(ssh_key.name, "rsa-key"); + assert_eq!(ssh_key.cipher_uuid, "rsa-uuid"); + + // Verify the key can sign data + let signing_key = ssh_key.private_key().expect("should have signing key"); + let message = b"test message for rsa"; + let signature: Signature = signing_key.try_sign(message).expect("signing should work"); + + // Verify signature is non-empty and has expected algorithm + assert!(!signature.as_bytes().is_empty()); + assert_eq!( + signature.algorithm(), + ssh_key::Algorithm::Rsa { + hash: Some(ssh_key::HashAlg::Sha512) + } + ); + } +} diff --git a/apps/desktop/desktop_native/macos_provider/src/assertion.rs b/apps/desktop/desktop_native/macos_provider/src/assertion.rs deleted file mode 100644 index c5b43bb87fa..00000000000 --- a/apps/desktop/desktop_native/macos_provider/src/assertion.rs +++ /dev/null @@ -1,58 +0,0 @@ -use std::sync::Arc; - -use serde::{Deserialize, Serialize}; - -use crate::{BitwardenError, Callback, Position, UserVerification}; - -#[derive(uniffi::Record, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PasskeyAssertionRequest { - rp_id: String, - client_data_hash: Vec, - user_verification: UserVerification, - allowed_credentials: Vec>, - window_xy: Position, - //extension_input: Vec, TODO: Implement support for extensions -} - -#[derive(uniffi::Record, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PasskeyAssertionWithoutUserInterfaceRequest { - rp_id: String, - credential_id: Vec, - user_name: String, - user_handle: Vec, - record_identifier: Option, - client_data_hash: Vec, - user_verification: UserVerification, - window_xy: Position, -} - -#[derive(uniffi::Record, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PasskeyAssertionResponse { - rp_id: String, - user_handle: Vec, - signature: Vec, - client_data_hash: Vec, - authenticator_data: Vec, - credential_id: Vec, -} - -#[uniffi::export(with_foreign)] -pub trait PreparePasskeyAssertionCallback: Send + Sync { - fn on_complete(&self, credential: PasskeyAssertionResponse); - fn on_error(&self, error: BitwardenError); -} - -impl Callback for Arc { - fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> { - let credential = serde_json::from_value(credential)?; - PreparePasskeyAssertionCallback::on_complete(self.as_ref(), credential); - Ok(()) - } - - fn error(&self, error: BitwardenError) { - PreparePasskeyAssertionCallback::on_error(self.as_ref(), error); - } -} diff --git a/apps/desktop/desktop_native/macos_provider/src/lib.rs b/apps/desktop/desktop_native/macos_provider/src/lib.rs deleted file mode 100644 index 8619a77a0f2..00000000000 --- a/apps/desktop/desktop_native/macos_provider/src/lib.rs +++ /dev/null @@ -1,296 +0,0 @@ -#![cfg(target_os = "macos")] -#![allow(clippy::disallowed_macros)] // uniffi macros trip up clippy's evaluation - -use std::{ - collections::HashMap, - sync::{atomic::AtomicU32, Arc, Mutex, Once}, - time::Instant, -}; - -use futures::FutureExt; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use tracing::{error, info}; -use tracing_subscriber::{ - filter::{EnvFilter, LevelFilter}, - layer::SubscriberExt, - util::SubscriberInitExt, -}; - -uniffi::setup_scaffolding!(); - -mod assertion; -mod registration; - -use assertion::{ - PasskeyAssertionRequest, PasskeyAssertionWithoutUserInterfaceRequest, - PreparePasskeyAssertionCallback, -}; -use registration::{PasskeyRegistrationRequest, PreparePasskeyRegistrationCallback}; - -static INIT: Once = Once::new(); - -#[derive(uniffi::Enum, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum UserVerification { - Preferred, - Required, - Discouraged, -} - -#[derive(uniffi::Record, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Position { - pub x: i32, - pub y: i32, -} - -#[derive(Debug, uniffi::Error, Serialize, Deserialize)] -pub enum BitwardenError { - Internal(String), -} - -// TODO: These have to be named differently than the actual Uniffi traits otherwise -// the generated code will lead to ambiguous trait implementations -// These are only used internally, so it doesn't matter that much -trait Callback: Send + Sync { - fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error>; - fn error(&self, error: BitwardenError); -} - -#[derive(uniffi::Enum, Debug)] -/// Store the connection status between the macOS credential provider extension -/// and the desktop application's IPC server. -pub enum ConnectionStatus { - Connected, - Disconnected, -} - -#[derive(uniffi::Object)] -pub struct MacOSProviderClient { - to_server_send: tokio::sync::mpsc::Sender, - - // We need to keep track of the callbacks so we can call them when we receive a response - response_callbacks_counter: AtomicU32, - #[allow(clippy::type_complexity)] - response_callbacks_queue: Arc, Instant)>>>, - - // Flag to track connection status - atomic for thread safety without locks - connection_status: Arc, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Store native desktop status information to use for IPC communication -/// between the application and the macOS credential provider. -pub struct NativeStatus { - key: String, - value: String, -} - -// In our callback management, 0 is a reserved sequence number indicating that a message does not -// have a callback. -const NO_CALLBACK_INDICATOR: u32 = 0; - -#[uniffi::export] -impl MacOSProviderClient { - // FIXME: Remove unwraps! They panic and terminate the whole application. - #[allow(clippy::unwrap_used)] - #[uniffi::constructor] - pub fn connect() -> Self { - INIT.call_once(|| { - let filter = EnvFilter::builder() - // Everything logs at `INFO` - .with_default_directive(LevelFilter::INFO.into()) - .from_env_lossy(); - - tracing_subscriber::registry() - .with(filter) - .with(tracing_oslog::OsLogger::new( - "com.bitwarden.desktop.autofill-extension", - "default", - )) - .init(); - }); - - let (from_server_send, mut from_server_recv) = tokio::sync::mpsc::channel(32); - let (to_server_send, to_server_recv) = tokio::sync::mpsc::channel(32); - - let client = MacOSProviderClient { - to_server_send, - response_callbacks_counter: AtomicU32::new(1), /* Start at 1 since 0 is reserved for - * "no callback" scenarios */ - response_callbacks_queue: Arc::new(Mutex::new(HashMap::new())), - connection_status: Arc::new(std::sync::atomic::AtomicBool::new(false)), - }; - - let path = desktop_core::ipc::path("af"); - - let queue = client.response_callbacks_queue.clone(); - let connection_status = client.connection_status.clone(); - - std::thread::spawn(move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("Can't create runtime"); - - rt.spawn( - desktop_core::ipc::client::connect(path, from_server_send, to_server_recv) - .map(|r| r.map_err(|e| e.to_string())), - ); - - rt.block_on(async move { - while let Some(message) = from_server_recv.recv().await { - match serde_json::from_str::(&message) { - Ok(SerializedMessage::Command(CommandMessage::Connected)) => { - info!("Connected to server"); - connection_status.store(true, std::sync::atomic::Ordering::Relaxed); - } - Ok(SerializedMessage::Command(CommandMessage::Disconnected)) => { - info!("Disconnected from server"); - connection_status.store(false, std::sync::atomic::Ordering::Relaxed); - } - Ok(SerializedMessage::Message { - sequence_number, - value, - }) => match queue.lock().unwrap().remove(&sequence_number) { - Some((cb, request_start_time)) => { - info!( - "Time to process request: {:?}", - request_start_time.elapsed() - ); - match value { - Ok(value) => { - if let Err(e) = cb.complete(value) { - error!(error = %e, "Error deserializing message"); - } - } - Err(e) => { - error!(error = ?e, "Error processing message"); - cb.error(e) - } - } - } - None => { - error!(sequence_number, "No callback found for sequence number") - } - }, - Err(e) => { - error!(error = %e, "Error deserializing message"); - } - }; - } - }); - }); - - client - } - - pub fn send_native_status(&self, key: String, value: String) { - let status = NativeStatus { key, value }; - self.send_message(status, None); - } - - pub fn prepare_passkey_registration( - &self, - request: PasskeyRegistrationRequest, - callback: Arc, - ) { - self.send_message(request, Some(Box::new(callback))); - } - - pub fn prepare_passkey_assertion( - &self, - request: PasskeyAssertionRequest, - callback: Arc, - ) { - self.send_message(request, Some(Box::new(callback))); - } - - pub fn prepare_passkey_assertion_without_user_interface( - &self, - request: PasskeyAssertionWithoutUserInterfaceRequest, - callback: Arc, - ) { - self.send_message(request, Some(Box::new(callback))); - } - - pub fn get_connection_status(&self) -> ConnectionStatus { - let is_connected = self - .connection_status - .load(std::sync::atomic::Ordering::Relaxed); - if is_connected { - ConnectionStatus::Connected - } else { - ConnectionStatus::Disconnected - } - } -} - -#[derive(Serialize, Deserialize)] -#[serde(tag = "command", rename_all = "camelCase")] -enum CommandMessage { - Connected, - Disconnected, -} - -#[derive(Serialize, Deserialize)] -#[serde(untagged, rename_all = "camelCase")] -enum SerializedMessage { - Command(CommandMessage), - Message { - sequence_number: u32, - value: Result, - }, -} - -impl MacOSProviderClient { - #[allow(clippy::unwrap_used)] - fn add_callback(&self, callback: Box) -> u32 { - let sequence_number = self - .response_callbacks_counter - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - - self.response_callbacks_queue - .lock() - .expect("response callbacks queue mutex should not be poisoned") - .insert(sequence_number, (callback, Instant::now())); - - sequence_number - } - - #[allow(clippy::unwrap_used)] - fn send_message( - &self, - message: impl Serialize + DeserializeOwned, - callback: Option>, - ) { - let sequence_number = if let Some(callback) = callback { - self.add_callback(callback) - } else { - NO_CALLBACK_INDICATOR - }; - - let message = serde_json::to_string(&SerializedMessage::Message { - sequence_number, - value: Ok(serde_json::to_value(message).unwrap()), - }) - .expect("Can't serialize message"); - - if let Err(e) = self.to_server_send.blocking_send(message) { - // Make sure we remove the callback from the queue if we can't send the message - if sequence_number != NO_CALLBACK_INDICATOR { - if let Some((callback, _)) = self - .response_callbacks_queue - .lock() - .expect("response callbacks queue mutex should not be poisoned") - .remove(&sequence_number) - { - callback.error(BitwardenError::Internal(format!( - "Error sending message: {e}" - ))); - } - } - } - } -} diff --git a/apps/desktop/desktop_native/macos_provider/src/registration.rs b/apps/desktop/desktop_native/macos_provider/src/registration.rs deleted file mode 100644 index c961566a86c..00000000000 --- a/apps/desktop/desktop_native/macos_provider/src/registration.rs +++ /dev/null @@ -1,45 +0,0 @@ -use std::sync::Arc; - -use serde::{Deserialize, Serialize}; - -use crate::{BitwardenError, Callback, Position, UserVerification}; - -#[derive(uniffi::Record, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PasskeyRegistrationRequest { - rp_id: String, - user_name: String, - user_handle: Vec, - client_data_hash: Vec, - user_verification: UserVerification, - supported_algorithms: Vec, - window_xy: Position, - excluded_credentials: Vec>, -} - -#[derive(uniffi::Record, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PasskeyRegistrationResponse { - rp_id: String, - client_data_hash: Vec, - credential_id: Vec, - attestation_object: Vec, -} - -#[uniffi::export(with_foreign)] -pub trait PreparePasskeyRegistrationCallback: Send + Sync { - fn on_complete(&self, credential: PasskeyRegistrationResponse); - fn on_error(&self, error: BitwardenError); -} - -impl Callback for Arc { - fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> { - let credential = serde_json::from_value(credential)?; - PreparePasskeyRegistrationCallback::on_complete(self.as_ref(), credential); - Ok(()) - } - - fn error(&self, error: BitwardenError) { - PreparePasskeyRegistrationCallback::on_error(self.as_ref(), error); - } -} diff --git a/apps/desktop/desktop_native/macos_provider/uniffi-bindgen.rs b/apps/desktop/desktop_native/macos_provider/uniffi-bindgen.rs deleted file mode 100644 index f6cff6cf1d9..00000000000 --- a/apps/desktop/desktop_native/macos_provider/uniffi-bindgen.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - uniffi::uniffi_bindgen_main() -} diff --git a/apps/desktop/desktop_native/napi/src/autofill.rs b/apps/desktop/desktop_native/napi/src/autofill.rs new file mode 100644 index 00000000000..7717b22ccef --- /dev/null +++ b/apps/desktop/desktop_native/napi/src/autofill.rs @@ -0,0 +1,332 @@ +#[napi] +pub mod autofill { + use desktop_core::ipc::server::{Message, MessageType}; + use napi::{ + bindgen_prelude::FnArgs, + threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, + }; + use serde::{de::DeserializeOwned, Deserialize, Serialize}; + use tracing::error; + + #[napi] + pub async fn run_command(value: String) -> napi::Result { + desktop_core::autofill::run_command(value) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[derive(Debug, serde::Serialize, serde:: Deserialize)] + pub enum BitwardenError { + Internal(String), + } + + #[napi(string_enum)] + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub enum UserVerification { + #[napi(value = "preferred")] + Preferred, + #[napi(value = "required")] + Required, + #[napi(value = "discouraged")] + Discouraged, + } + + #[derive(Serialize, Deserialize)] + #[serde(bound = "T: Serialize + DeserializeOwned")] + pub struct PasskeyMessage { + pub sequence_number: u32, + pub value: Result, + } + + #[napi(object)] + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Position { + pub x: i32, + pub y: i32, + } + + #[napi(object)] + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct PasskeyRegistrationRequest { + pub rp_id: String, + pub user_name: String, + pub user_handle: Vec, + pub client_data_hash: Vec, + pub user_verification: UserVerification, + pub supported_algorithms: Vec, + pub window_xy: Position, + pub excluded_credentials: Vec>, + } + + #[napi(object)] + #[derive(Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct PasskeyRegistrationResponse { + pub rp_id: String, + pub client_data_hash: Vec, + pub credential_id: Vec, + pub attestation_object: Vec, + } + + #[napi(object)] + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct PasskeyAssertionRequest { + pub rp_id: String, + pub client_data_hash: Vec, + pub user_verification: UserVerification, + pub allowed_credentials: Vec>, + pub window_xy: Position, + //extension_input: Vec, TODO: Implement support for extensions + } + + #[napi(object)] + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct PasskeyAssertionWithoutUserInterfaceRequest { + pub rp_id: String, + pub credential_id: Vec, + pub user_name: String, + pub user_handle: Vec, + pub record_identifier: Option, + pub client_data_hash: Vec, + pub user_verification: UserVerification, + pub window_xy: Position, + } + + #[napi(object)] + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct NativeStatus { + pub key: String, + pub value: String, + } + + #[napi(object)] + #[derive(Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct PasskeyAssertionResponse { + pub rp_id: String, + pub user_handle: Vec, + pub signature: Vec, + pub client_data_hash: Vec, + pub authenticator_data: Vec, + pub credential_id: Vec, + } + + #[napi] + pub struct AutofillIpcServer { + server: desktop_core::ipc::server::Server, + } + + // FIXME: Remove unwraps! They panic and terminate the whole application. + #[allow(clippy::unwrap_used)] + #[napi] + impl AutofillIpcServer { + /// Create and start the IPC server without blocking. + /// + /// @param name The endpoint name to listen on. This name uniquely identifies the IPC + /// connection and must be the same for both the server and client. @param callback + /// This function will be called whenever a message is received from a client. + #[allow(clippy::unused_async)] // FIXME: Remove unused async! + #[napi(factory)] + pub async fn listen( + name: String, + // Ideally we'd have a single callback that has an enum containing the request values, + // but NAPI doesn't support that just yet + #[napi( + ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void" + )] + registration_callback: ThreadsafeFunction< + FnArgs<(u32, u32, PasskeyRegistrationRequest)>, + >, + #[napi( + ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void" + )] + assertion_callback: ThreadsafeFunction< + FnArgs<(u32, u32, PasskeyAssertionRequest)>, + >, + #[napi( + ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void" + )] + assertion_without_user_interface_callback: ThreadsafeFunction< + FnArgs<(u32, u32, PasskeyAssertionWithoutUserInterfaceRequest)>, + >, + #[napi( + ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void" + )] + native_status_callback: ThreadsafeFunction<(u32, u32, NativeStatus)>, + ) -> napi::Result { + let (send, mut recv) = tokio::sync::mpsc::channel::(32); + tokio::spawn(async move { + while let Some(Message { + client_id, + kind, + message, + }) = recv.recv().await + { + match kind { + // TODO: We're ignoring the connection and disconnection messages for now + MessageType::Connected | MessageType::Disconnected => continue, + MessageType::Message => { + let Some(message) = message else { + error!("Message is empty"); + continue; + }; + + match serde_json::from_str::>( + &message, + ) { + Ok(msg) => { + let value = msg + .value + .map(|value| (client_id, msg.sequence_number, value).into()) + .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); + + assertion_callback + .call(value, ThreadsafeFunctionCallMode::NonBlocking); + continue; + } + Err(e) => { + error!(error = %e, "Error deserializing message1"); + } + } + + match serde_json::from_str::< + PasskeyMessage, + >(&message) + { + Ok(msg) => { + let value = msg + .value + .map(|value| (client_id, msg.sequence_number, value).into()) + .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); + + assertion_without_user_interface_callback + .call(value, ThreadsafeFunctionCallMode::NonBlocking); + continue; + } + Err(e) => { + error!(error = %e, "Error deserializing message1"); + } + } + + match serde_json::from_str::>( + &message, + ) { + Ok(msg) => { + let value = msg + .value + .map(|value| (client_id, msg.sequence_number, value).into()) + .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); + registration_callback + .call(value, ThreadsafeFunctionCallMode::NonBlocking); + continue; + } + Err(e) => { + error!(error = %e, "Error deserializing message2"); + } + } + + match serde_json::from_str::>(&message) { + Ok(msg) => { + let value = msg + .value + .map(|value| (client_id, msg.sequence_number, value)) + .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); + native_status_callback + .call(value, ThreadsafeFunctionCallMode::NonBlocking); + continue; + } + Err(error) => { + error!(%error, "Unable to deserialze native status."); + } + } + + error!(message, "Received an unknown message2"); + } + } + } + }); + + let path = desktop_core::ipc::path(&name); + + let server = desktop_core::ipc::server::Server::start(&path, send).map_err(|e| { + napi::Error::from_reason(format!( + "Error listening to server - Path: {path:?} - Error: {e} - {e:?}" + )) + })?; + + Ok(AutofillIpcServer { server }) + } + + /// Return the path to the IPC server. + #[napi] + pub fn get_path(&self) -> String { + self.server.path.to_string_lossy().to_string() + } + + /// Stop the IPC server. + #[napi] + pub fn stop(&self) -> napi::Result<()> { + self.server.stop(); + Ok(()) + } + + #[napi] + pub fn complete_registration( + &self, + client_id: u32, + sequence_number: u32, + response: PasskeyRegistrationResponse, + ) -> napi::Result { + let message = PasskeyMessage { + sequence_number, + value: Ok(response), + }; + self.send(client_id, serde_json::to_string(&message).unwrap()) + } + + #[napi] + pub fn complete_assertion( + &self, + client_id: u32, + sequence_number: u32, + response: PasskeyAssertionResponse, + ) -> napi::Result { + let message = PasskeyMessage { + sequence_number, + value: Ok(response), + }; + self.send(client_id, serde_json::to_string(&message).unwrap()) + } + + #[napi] + pub fn complete_error( + &self, + client_id: u32, + sequence_number: u32, + error: String, + ) -> napi::Result { + let message: PasskeyMessage<()> = PasskeyMessage { + sequence_number, + value: Err(BitwardenError::Internal(error)), + }; + self.send(client_id, serde_json::to_string(&message).unwrap()) + } + + // TODO: Add a way to send a message to a specific client? + fn send(&self, _client_id: u32, message: String) -> napi::Result { + self.server + .send(message) + .map_err(|e| { + napi::Error::from_reason(format!("Error sending message - Error: {e} - {e:?}")) + }) + // NAPI doesn't support u64 or usize, so we need to convert to u32 + .map(|u| u32::try_from(u).unwrap_or_default()) + } + } +} diff --git a/apps/desktop/desktop_native/napi/src/autostart.rs b/apps/desktop/desktop_native/napi/src/autostart.rs new file mode 100644 index 00000000000..3068226809e --- /dev/null +++ b/apps/desktop/desktop_native/napi/src/autostart.rs @@ -0,0 +1,9 @@ +#[napi] +pub mod autostart { + #[napi] + pub async fn set_autostart(autostart: bool, params: Vec) -> napi::Result<()> { + desktop_core::autostart::set_autostart(autostart, params) + .await + .map_err(|e| napi::Error::from_reason(format!("Error setting autostart - {e} - {e:?}"))) + } +} diff --git a/apps/desktop/desktop_native/napi/src/autotype.rs b/apps/desktop/desktop_native/napi/src/autotype.rs new file mode 100644 index 00000000000..b63c95ceb5c --- /dev/null +++ b/apps/desktop/desktop_native/napi/src/autotype.rs @@ -0,0 +1,20 @@ +#[napi] +pub mod autotype { + #[napi] + pub fn get_foreground_window_title() -> napi::Result { + autotype::get_foreground_window_title().map_err(|_| { + napi::Error::from_reason( + "Autotype Error: failed to get foreground window title".to_string(), + ) + }) + } + + #[napi] + pub fn type_input( + input: Vec, + keyboard_shortcut: Vec, + ) -> napi::Result<(), napi::Status> { + autotype::type_input(&input, &keyboard_shortcut) + .map_err(|e| napi::Error::from_reason(format!("Autotype Error: {e}"))) + } +} diff --git a/apps/desktop/desktop_native/napi/src/biometrics.rs b/apps/desktop/desktop_native/napi/src/biometrics.rs new file mode 100644 index 00000000000..bca802d5884 --- /dev/null +++ b/apps/desktop/desktop_native/napi/src/biometrics.rs @@ -0,0 +1,100 @@ +#[napi] +pub mod biometrics { + use desktop_core::biometric::{Biometric, BiometricTrait}; + + // Prompt for biometric confirmation + #[napi] + pub async fn prompt( + hwnd: napi::bindgen_prelude::Buffer, + message: String, + ) -> napi::Result { + Biometric::prompt(hwnd.into(), message) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn available() -> napi::Result { + Biometric::available() + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn set_biometric_secret( + service: String, + account: String, + secret: String, + key_material: Option, + iv_b64: String, + ) -> napi::Result { + Biometric::set_biometric_secret( + &service, + &account, + &secret, + key_material.map(|m| m.into()), + &iv_b64, + ) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + /// Retrieves the biometric secret for the given service and account. + /// Throws Error with message [`passwords::PASSWORD_NOT_FOUND`] if the secret does not exist. + #[napi] + pub async fn get_biometric_secret( + service: String, + account: String, + key_material: Option, + ) -> napi::Result { + Biometric::get_biometric_secret(&service, &account, key_material.map(|m| m.into())) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + /// Derives key material from biometric data. Returns a string encoded with a + /// base64 encoded key and the base64 encoded challenge used to create it + /// separated by a `|` character. + /// + /// If the iv is provided, it will be used as the challenge. Otherwise a random challenge will + /// be generated. + /// + /// `format!("|")` + #[allow(clippy::unused_async)] // FIXME: Remove unused async! + #[napi] + pub async fn derive_key_material(iv: Option) -> napi::Result { + Biometric::derive_key_material(iv.as_deref()) + .map(|k| k.into()) + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi(object)] + pub struct KeyMaterial { + pub os_key_part_b64: String, + pub client_key_part_b64: Option, + } + + impl From for desktop_core::biometric::KeyMaterial { + fn from(km: KeyMaterial) -> Self { + desktop_core::biometric::KeyMaterial { + os_key_part_b64: km.os_key_part_b64, + client_key_part_b64: km.client_key_part_b64, + } + } + } + + #[napi(object)] + pub struct OsDerivedKey { + pub key_b64: String, + pub iv_b64: String, + } + + impl From for OsDerivedKey { + fn from(km: desktop_core::biometric::OsDerivedKey) -> Self { + OsDerivedKey { + key_b64: km.key_b64, + iv_b64: km.iv_b64, + } + } + } +} diff --git a/apps/desktop/desktop_native/napi/src/biometrics_v2.rs b/apps/desktop/desktop_native/napi/src/biometrics_v2.rs new file mode 100644 index 00000000000..2df3a6a07be --- /dev/null +++ b/apps/desktop/desktop_native/napi/src/biometrics_v2.rs @@ -0,0 +1,116 @@ +#[napi] +pub mod biometrics_v2 { + use desktop_core::biometric_v2::BiometricTrait; + + #[napi] + pub struct BiometricLockSystem { + inner: desktop_core::biometric_v2::BiometricLockSystem, + } + + #[napi] + pub fn init_biometric_system() -> napi::Result { + Ok(BiometricLockSystem { + inner: desktop_core::biometric_v2::BiometricLockSystem::new(), + }) + } + + #[napi] + pub async fn authenticate( + biometric_lock_system: &BiometricLockSystem, + hwnd: napi::bindgen_prelude::Buffer, + message: String, + ) -> napi::Result { + biometric_lock_system + .inner + .authenticate(hwnd.into(), message) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn authenticate_available( + biometric_lock_system: &BiometricLockSystem, + ) -> napi::Result { + biometric_lock_system + .inner + .authenticate_available() + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn enroll_persistent( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + key: napi::bindgen_prelude::Buffer, + ) -> napi::Result<()> { + biometric_lock_system + .inner + .enroll_persistent(&user_id, &key) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn provide_key( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + key: napi::bindgen_prelude::Buffer, + ) -> napi::Result<()> { + biometric_lock_system + .inner + .provide_key(&user_id, &key) + .await; + Ok(()) + } + + #[napi] + pub async fn unlock( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + hwnd: napi::bindgen_prelude::Buffer, + ) -> napi::Result { + biometric_lock_system + .inner + .unlock(&user_id, hwnd.into()) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + .map(|v| v.into()) + } + + #[napi] + pub async fn unlock_available( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + ) -> napi::Result { + biometric_lock_system + .inner + .unlock_available(&user_id) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn has_persistent( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + ) -> napi::Result { + biometric_lock_system + .inner + .has_persistent(&user_id) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn unenroll( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + ) -> napi::Result<()> { + biometric_lock_system + .inner + .unenroll(&user_id) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } +} diff --git a/apps/desktop/desktop_native/napi/src/chromium_importer.rs b/apps/desktop/desktop_native/napi/src/chromium_importer.rs new file mode 100644 index 00000000000..da295984a47 --- /dev/null +++ b/apps/desktop/desktop_native/napi/src/chromium_importer.rs @@ -0,0 +1,116 @@ +#[napi] +pub mod chromium_importer { + use std::collections::HashMap; + + use chromium_importer::{ + chromium::{ + DefaultInstalledBrowserRetriever, LoginImportResult as _LoginImportResult, + ProfileInfo as _ProfileInfo, + }, + metadata::NativeImporterMetadata as _NativeImporterMetadata, + }; + + #[napi(object)] + pub struct ProfileInfo { + pub id: String, + pub name: String, + } + + #[napi(object)] + pub struct Login { + pub url: String, + pub username: String, + pub password: String, + pub note: String, + } + + #[napi(object)] + pub struct LoginImportFailure { + pub url: String, + pub username: String, + pub error: String, + } + + #[napi(object)] + pub struct LoginImportResult { + pub login: Option, + pub failure: Option, + } + + #[napi(object)] + pub struct NativeImporterMetadata { + pub id: String, + pub loaders: Vec, + pub instructions: String, + } + + impl From<_LoginImportResult> for LoginImportResult { + fn from(l: _LoginImportResult) -> Self { + match l { + _LoginImportResult::Success(l) => LoginImportResult { + login: Some(Login { + url: l.url, + username: l.username, + password: l.password, + note: l.note, + }), + failure: None, + }, + _LoginImportResult::Failure(l) => LoginImportResult { + login: None, + failure: Some(LoginImportFailure { + url: l.url, + username: l.username, + error: l.error, + }), + }, + } + } + } + + impl From<_ProfileInfo> for ProfileInfo { + fn from(p: _ProfileInfo) -> Self { + ProfileInfo { + id: p.folder, + name: p.name, + } + } + } + + impl From<_NativeImporterMetadata> for NativeImporterMetadata { + fn from(m: _NativeImporterMetadata) -> Self { + NativeImporterMetadata { + id: m.id, + loaders: m.loaders, + instructions: m.instructions, + } + } + } + + #[napi] + /// Returns OS aware metadata describing supported Chromium based importers as a JSON string. + pub fn get_metadata() -> HashMap { + chromium_importer::metadata::get_supported_importers::() + .into_iter() + .map(|(browser, metadata)| (browser, NativeImporterMetadata::from(metadata))) + .collect() + } + + #[napi] + pub fn get_available_profiles(browser: String) -> napi::Result> { + chromium_importer::chromium::get_available_profiles(&browser) + .map(|profiles| profiles.into_iter().map(ProfileInfo::from).collect()) + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn import_logins( + browser: String, + profile_id: String, + ) -> napi::Result> { + chromium_importer::chromium::import_logins(&browser, &profile_id) + .await + .map(|logins| logins.into_iter().map(LoginImportResult::from).collect()) + .map_err(|e| napi::Error::from_reason(e.to_string())) + } +} diff --git a/apps/desktop/desktop_native/napi/src/clipboards.rs b/apps/desktop/desktop_native/napi/src/clipboards.rs new file mode 100644 index 00000000000..810e457dd60 --- /dev/null +++ b/apps/desktop/desktop_native/napi/src/clipboards.rs @@ -0,0 +1,15 @@ +#[napi] +pub mod clipboards { + #[allow(clippy::unused_async)] // FIXME: Remove unused async! + #[napi] + pub async fn read() -> napi::Result { + desktop_core::clipboard::read().map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[allow(clippy::unused_async)] // FIXME: Remove unused async! + #[napi] + pub async fn write(text: String, password: bool) -> napi::Result<()> { + desktop_core::clipboard::write(&text, password) + .map_err(|e| napi::Error::from_reason(e.to_string())) + } +} diff --git a/apps/desktop/desktop_native/napi/src/ipc.rs b/apps/desktop/desktop_native/napi/src/ipc.rs new file mode 100644 index 00000000000..ba72b1dce2b --- /dev/null +++ b/apps/desktop/desktop_native/napi/src/ipc.rs @@ -0,0 +1,106 @@ +#[napi] +pub mod ipc { + use desktop_core::ipc::server::{Message, MessageType}; + use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; + + #[napi(object)] + pub struct IpcMessage { + pub client_id: u32, + pub kind: IpcMessageType, + pub message: Option, + } + + impl From for IpcMessage { + fn from(message: Message) -> Self { + IpcMessage { + client_id: message.client_id, + kind: message.kind.into(), + message: message.message, + } + } + } + + #[napi] + pub enum IpcMessageType { + Connected, + Disconnected, + Message, + } + + impl From for IpcMessageType { + fn from(message_type: MessageType) -> Self { + match message_type { + MessageType::Connected => IpcMessageType::Connected, + MessageType::Disconnected => IpcMessageType::Disconnected, + MessageType::Message => IpcMessageType::Message, + } + } + } + + #[napi] + pub struct NativeIpcServer { + server: desktop_core::ipc::server::Server, + } + + #[napi] + impl NativeIpcServer { + /// Create and start the IPC server without blocking. + /// + /// @param name The endpoint name to listen on. This name uniquely identifies the IPC + /// connection and must be the same for both the server and client. @param callback + /// This function will be called whenever a message is received from a client. + #[allow(clippy::unused_async)] // FIXME: Remove unused async! + #[napi(factory)] + pub async fn listen( + name: String, + #[napi(ts_arg_type = "(error: null | Error, message: IpcMessage) => void")] + callback: ThreadsafeFunction, + ) -> napi::Result { + let (send, mut recv) = tokio::sync::mpsc::channel::(32); + tokio::spawn(async move { + while let Some(message) = recv.recv().await { + callback.call(Ok(message.into()), ThreadsafeFunctionCallMode::NonBlocking); + } + }); + + let path = desktop_core::ipc::path(&name); + + let server = desktop_core::ipc::server::Server::start(&path, send).map_err(|e| { + napi::Error::from_reason(format!( + "Error listening to server - Path: {path:?} - Error: {e} - {e:?}" + )) + })?; + + Ok(NativeIpcServer { server }) + } + + /// Return the path to the IPC server. + #[napi] + pub fn get_path(&self) -> String { + self.server.path.to_string_lossy().to_string() + } + + /// Stop the IPC server. + #[napi] + pub fn stop(&self) -> napi::Result<()> { + self.server.stop(); + Ok(()) + } + + /// Send a message over the IPC server to all the connected clients + /// + /// @return The number of clients that the message was sent to. Note that the number of + /// messages actually received may be less, as some clients could disconnect before + /// receiving the message. + #[napi] + pub fn send(&self, message: String) -> napi::Result { + self.server + .send(message) + .map_err(|e| { + napi::Error::from_reason(format!("Error sending message - Error: {e} - {e:?}")) + }) + // NAPI doesn't support u64 or usize, so we need to convert to u32 + .map(|u| u32::try_from(u).unwrap_or_default()) + } + } +} diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 588f757631c..e3abfd50e7a 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -4,1244 +4,22 @@ extern crate napi_derive; mod passkey_authenticator_internal; mod registry; -#[napi] -pub mod passwords { - /// The error message returned when a password is not found during retrieval or deletion. - #[napi] - pub const PASSWORD_NOT_FOUND: &str = desktop_core::password::PASSWORD_NOT_FOUND; - - /// Fetch the stored password from the keychain. - /// Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist. - #[napi] - pub async fn get_password(service: String, account: String) -> napi::Result { - desktop_core::password::get_password(&service, &account) - .await - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - /// Save the password to the keychain. Adds an entry if none exists otherwise updates the - /// existing entry. - #[napi] - pub async fn set_password( - service: String, - account: String, - password: String, - ) -> napi::Result<()> { - desktop_core::password::set_password(&service, &account, &password) - .await - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - /// Delete the stored password from the keychain. - /// Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist. - #[napi] - pub async fn delete_password(service: String, account: String) -> napi::Result<()> { - desktop_core::password::delete_password(&service, &account) - .await - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - /// Checks if the os secure storage is available - #[napi] - pub async fn is_available() -> napi::Result { - desktop_core::password::is_available() - .await - .map_err(|e| napi::Error::from_reason(e.to_string())) - } -} - -#[napi] -pub mod biometrics { - use desktop_core::biometric::{Biometric, BiometricTrait}; - - // Prompt for biometric confirmation - #[napi] - pub async fn prompt( - hwnd: napi::bindgen_prelude::Buffer, - message: String, - ) -> napi::Result { - Biometric::prompt(hwnd.into(), message) - .await - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn available() -> napi::Result { - Biometric::available() - .await - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn set_biometric_secret( - service: String, - account: String, - secret: String, - key_material: Option, - iv_b64: String, - ) -> napi::Result { - Biometric::set_biometric_secret( - &service, - &account, - &secret, - key_material.map(|m| m.into()), - &iv_b64, - ) - .await - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - /// Retrieves the biometric secret for the given service and account. - /// Throws Error with message [`passwords::PASSWORD_NOT_FOUND`] if the secret does not exist. - #[napi] - pub async fn get_biometric_secret( - service: String, - account: String, - key_material: Option, - ) -> napi::Result { - Biometric::get_biometric_secret(&service, &account, key_material.map(|m| m.into())) - .await - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - /// Derives key material from biometric data. Returns a string encoded with a - /// base64 encoded key and the base64 encoded challenge used to create it - /// separated by a `|` character. - /// - /// If the iv is provided, it will be used as the challenge. Otherwise a random challenge will - /// be generated. - /// - /// `format!("|")` - #[allow(clippy::unused_async)] // FIXME: Remove unused async! - #[napi] - pub async fn derive_key_material(iv: Option) -> napi::Result { - Biometric::derive_key_material(iv.as_deref()) - .map(|k| k.into()) - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - #[napi(object)] - pub struct KeyMaterial { - pub os_key_part_b64: String, - pub client_key_part_b64: Option, - } - - impl From for desktop_core::biometric::KeyMaterial { - fn from(km: KeyMaterial) -> Self { - desktop_core::biometric::KeyMaterial { - os_key_part_b64: km.os_key_part_b64, - client_key_part_b64: km.client_key_part_b64, - } - } - } - - #[napi(object)] - pub struct OsDerivedKey { - pub key_b64: String, - pub iv_b64: String, - } - - impl From for OsDerivedKey { - fn from(km: desktop_core::biometric::OsDerivedKey) -> Self { - OsDerivedKey { - key_b64: km.key_b64, - iv_b64: km.iv_b64, - } - } - } -} - -#[napi] -pub mod biometrics_v2 { - use desktop_core::biometric_v2::BiometricTrait; - - #[napi] - pub struct BiometricLockSystem { - inner: desktop_core::biometric_v2::BiometricLockSystem, - } - - #[napi] - pub fn init_biometric_system() -> napi::Result { - Ok(BiometricLockSystem { - inner: desktop_core::biometric_v2::BiometricLockSystem::new(), - }) - } - - #[napi] - pub async fn authenticate( - biometric_lock_system: &BiometricLockSystem, - hwnd: napi::bindgen_prelude::Buffer, - message: String, - ) -> napi::Result { - biometric_lock_system - .inner - .authenticate(hwnd.into(), message) - .await - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn authenticate_available( - biometric_lock_system: &BiometricLockSystem, - ) -> napi::Result { - biometric_lock_system - .inner - .authenticate_available() - .await - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn enroll_persistent( - biometric_lock_system: &BiometricLockSystem, - user_id: String, - key: napi::bindgen_prelude::Buffer, - ) -> napi::Result<()> { - biometric_lock_system - .inner - .enroll_persistent(&user_id, &key) - .await - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn provide_key( - biometric_lock_system: &BiometricLockSystem, - user_id: String, - key: napi::bindgen_prelude::Buffer, - ) -> napi::Result<()> { - biometric_lock_system - .inner - .provide_key(&user_id, &key) - .await; - Ok(()) - } - - #[napi] - pub async fn unlock( - biometric_lock_system: &BiometricLockSystem, - user_id: String, - hwnd: napi::bindgen_prelude::Buffer, - ) -> napi::Result { - biometric_lock_system - .inner - .unlock(&user_id, hwnd.into()) - .await - .map_err(|e| napi::Error::from_reason(e.to_string())) - .map(|v| v.into()) - } - - #[napi] - pub async fn unlock_available( - biometric_lock_system: &BiometricLockSystem, - user_id: String, - ) -> napi::Result { - biometric_lock_system - .inner - .unlock_available(&user_id) - .await - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn has_persistent( - biometric_lock_system: &BiometricLockSystem, - user_id: String, - ) -> napi::Result { - biometric_lock_system - .inner - .has_persistent(&user_id) - .await - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn unenroll( - biometric_lock_system: &BiometricLockSystem, - user_id: String, - ) -> napi::Result<()> { - biometric_lock_system - .inner - .unenroll(&user_id) - .await - .map_err(|e| napi::Error::from_reason(e.to_string())) - } -} - -#[napi] -pub mod clipboards { - #[allow(clippy::unused_async)] // FIXME: Remove unused async! - #[napi] - pub async fn read() -> napi::Result { - desktop_core::clipboard::read().map_err(|e| napi::Error::from_reason(e.to_string())) - } - - #[allow(clippy::unused_async)] // FIXME: Remove unused async! - #[napi] - pub async fn write(text: String, password: bool) -> napi::Result<()> { - desktop_core::clipboard::write(&text, password) - .map_err(|e| napi::Error::from_reason(e.to_string())) - } -} - -#[napi] -pub mod sshagent { - use std::sync::Arc; - - use napi::{ - bindgen_prelude::Promise, - threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, - }; - use tokio::{self, sync::Mutex}; - use tracing::error; - - #[napi] - pub struct SshAgentState { - state: desktop_core::ssh_agent::BitwardenDesktopAgent, - } - - #[napi(object)] - pub struct PrivateKey { - pub private_key: String, - pub name: String, - pub cipher_id: String, - } - - #[napi(object)] - pub struct SshKey { - pub private_key: String, - pub public_key: String, - pub key_fingerprint: String, - } - - #[napi(object)] - pub struct SshUIRequest { - pub cipher_id: Option, - pub is_list: bool, - pub process_name: String, - pub is_forwarding: bool, - pub namespace: Option, - } - - #[allow(clippy::unused_async)] // FIXME: Remove unused async! - #[napi] - pub async fn serve( - callback: ThreadsafeFunction>, - ) -> napi::Result { - let (auth_request_tx, mut auth_request_rx) = - tokio::sync::mpsc::channel::(32); - let (auth_response_tx, auth_response_rx) = - tokio::sync::broadcast::channel::<(u32, bool)>(32); - let auth_response_tx_arc = Arc::new(Mutex::new(auth_response_tx)); - // Wrap callback in Arc so it can be shared across spawned tasks - let callback = Arc::new(callback); - tokio::spawn(async move { - let _ = auth_response_rx; - - while let Some(request) = auth_request_rx.recv().await { - let cloned_response_tx_arc = auth_response_tx_arc.clone(); - let cloned_callback = callback.clone(); - tokio::spawn(async move { - let auth_response_tx_arc = cloned_response_tx_arc; - let callback = cloned_callback; - // In NAPI v3, obtain the JS callback return as a Promise and await it - // in Rust - let (tx, rx) = std::sync::mpsc::channel::>(); - let status = callback.call_with_return_value( - Ok(SshUIRequest { - cipher_id: request.cipher_id, - is_list: request.is_list, - process_name: request.process_name, - is_forwarding: request.is_forwarding, - namespace: request.namespace, - }), - ThreadsafeFunctionCallMode::Blocking, - move |ret: Result, napi::Error>, _env| { - if let Ok(p) = ret { - let _ = tx.send(p); - } - Ok(()) - }, - ); - - let result = if status == napi::Status::Ok { - match rx.recv() { - Ok(promise) => match promise.await { - Ok(v) => v, - Err(e) => { - error!(error = %e, "UI callback promise rejected"); - false - } - }, - Err(e) => { - error!(error = %e, "Failed to receive UI callback promise"); - false - } - } - } else { - error!(error = ?status, "Calling UI callback failed"); - false - }; - - let _ = auth_response_tx_arc - .lock() - .await - .send((request.request_id, result)) - .expect("should be able to send auth response to agent"); - }); - } - }); - - match desktop_core::ssh_agent::BitwardenDesktopAgent::start_server( - auth_request_tx, - Arc::new(Mutex::new(auth_response_rx)), - ) { - Ok(state) => Ok(SshAgentState { state }), - Err(e) => Err(napi::Error::from_reason(e.to_string())), - } - } - - #[napi] - pub fn stop(agent_state: &mut SshAgentState) -> napi::Result<()> { - let bitwarden_agent_state = &mut agent_state.state; - bitwarden_agent_state.stop(); - Ok(()) - } - - #[napi] - pub fn is_running(agent_state: &mut SshAgentState) -> bool { - let bitwarden_agent_state = agent_state.state.clone(); - bitwarden_agent_state.is_running() - } - - #[napi] - pub fn set_keys( - agent_state: &mut SshAgentState, - new_keys: Vec, - ) -> napi::Result<()> { - let bitwarden_agent_state = &mut agent_state.state; - bitwarden_agent_state - .set_keys( - new_keys - .iter() - .map(|k| (k.private_key.clone(), k.name.clone(), k.cipher_id.clone())) - .collect(), - ) - .map_err(|e| napi::Error::from_reason(e.to_string()))?; - Ok(()) - } - - #[napi] - pub fn lock(agent_state: &mut SshAgentState) -> napi::Result<()> { - let bitwarden_agent_state = &mut agent_state.state; - bitwarden_agent_state - .lock() - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - #[napi] - pub fn clear_keys(agent_state: &mut SshAgentState) -> napi::Result<()> { - let bitwarden_agent_state = &mut agent_state.state; - bitwarden_agent_state - .clear_keys() - .map_err(|e| napi::Error::from_reason(e.to_string())) - } -} - -#[napi] -pub mod processisolations { - #[allow(clippy::unused_async)] // FIXME: Remove unused async! - #[napi] - pub async fn disable_coredumps() -> napi::Result<()> { - desktop_core::process_isolation::disable_coredumps() - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - #[allow(clippy::unused_async)] // FIXME: Remove unused async! - #[napi] - pub async fn is_core_dumping_disabled() -> napi::Result { - desktop_core::process_isolation::is_core_dumping_disabled() - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - #[allow(clippy::unused_async)] // FIXME: Remove unused async! - #[napi] - pub async fn isolate_process() -> napi::Result<()> { - desktop_core::process_isolation::isolate_process() - .map_err(|e| napi::Error::from_reason(e.to_string())) - } -} - -#[napi] -pub mod powermonitors { - use napi::{ - threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, - tokio, - }; - - #[napi] - pub async fn on_lock(callback: ThreadsafeFunction<()>) -> napi::Result<()> { - let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(32); - desktop_core::powermonitor::on_lock(tx) - .await - .map_err(|e| napi::Error::from_reason(e.to_string()))?; - tokio::spawn(async move { - while let Some(()) = rx.recv().await { - callback.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking); - } - }); - Ok(()) - } - - #[napi] - pub async fn is_lock_monitor_available() -> napi::Result { - Ok(desktop_core::powermonitor::is_lock_monitor_available().await) - } -} - -#[napi] -pub mod windows_registry { - #[allow(clippy::unused_async)] // FIXME: Remove unused async! - #[napi] - pub async fn create_key(key: String, subkey: String, value: String) -> napi::Result<()> { - crate::registry::create_key(&key, &subkey, &value) - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - #[allow(clippy::unused_async)] // FIXME: Remove unused async! - #[napi] - pub async fn delete_key(key: String, subkey: String) -> napi::Result<()> { - crate::registry::delete_key(&key, &subkey) - .map_err(|e| napi::Error::from_reason(e.to_string())) - } -} - -#[napi] -pub mod ipc { - use desktop_core::ipc::server::{Message, MessageType}; - use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; - - #[napi(object)] - pub struct IpcMessage { - pub client_id: u32, - pub kind: IpcMessageType, - pub message: Option, - } - - impl From for IpcMessage { - fn from(message: Message) -> Self { - IpcMessage { - client_id: message.client_id, - kind: message.kind.into(), - message: message.message, - } - } - } - - #[napi] - pub enum IpcMessageType { - Connected, - Disconnected, - Message, - } - - impl From for IpcMessageType { - fn from(message_type: MessageType) -> Self { - match message_type { - MessageType::Connected => IpcMessageType::Connected, - MessageType::Disconnected => IpcMessageType::Disconnected, - MessageType::Message => IpcMessageType::Message, - } - } - } - - #[napi] - pub struct NativeIpcServer { - server: desktop_core::ipc::server::Server, - } - - #[napi] - impl NativeIpcServer { - /// Create and start the IPC server without blocking. - /// - /// @param name The endpoint name to listen on. This name uniquely identifies the IPC - /// connection and must be the same for both the server and client. @param callback - /// This function will be called whenever a message is received from a client. - #[allow(clippy::unused_async)] // FIXME: Remove unused async! - #[napi(factory)] - pub async fn listen( - name: String, - #[napi(ts_arg_type = "(error: null | Error, message: IpcMessage) => void")] - callback: ThreadsafeFunction, - ) -> napi::Result { - let (send, mut recv) = tokio::sync::mpsc::channel::(32); - tokio::spawn(async move { - while let Some(message) = recv.recv().await { - callback.call(Ok(message.into()), ThreadsafeFunctionCallMode::NonBlocking); - } - }); - - let path = desktop_core::ipc::path(&name); - - let server = desktop_core::ipc::server::Server::start(&path, send).map_err(|e| { - napi::Error::from_reason(format!( - "Error listening to server - Path: {path:?} - Error: {e} - {e:?}" - )) - })?; - - Ok(NativeIpcServer { server }) - } - - /// Return the path to the IPC server. - #[napi] - pub fn get_path(&self) -> String { - self.server.path.to_string_lossy().to_string() - } - - /// Stop the IPC server. - #[napi] - pub fn stop(&self) -> napi::Result<()> { - self.server.stop(); - Ok(()) - } - - /// Send a message over the IPC server to all the connected clients - /// - /// @return The number of clients that the message was sent to. Note that the number of - /// messages actually received may be less, as some clients could disconnect before - /// receiving the message. - #[napi] - pub fn send(&self, message: String) -> napi::Result { - self.server - .send(message) - .map_err(|e| { - napi::Error::from_reason(format!("Error sending message - Error: {e} - {e:?}")) - }) - // NAPI doesn't support u64 or usize, so we need to convert to u32 - .map(|u| u32::try_from(u).unwrap_or_default()) - } - } -} - -#[napi] -pub mod autostart { - #[napi] - pub async fn set_autostart(autostart: bool, params: Vec) -> napi::Result<()> { - desktop_core::autostart::set_autostart(autostart, params) - .await - .map_err(|e| napi::Error::from_reason(format!("Error setting autostart - {e} - {e:?}"))) - } -} - -#[napi] -pub mod autofill { - use desktop_core::ipc::server::{Message, MessageType}; - use napi::{ - bindgen_prelude::FnArgs, - threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, - }; - use serde::{de::DeserializeOwned, Deserialize, Serialize}; - use tracing::error; - - #[napi] - pub async fn run_command(value: String) -> napi::Result { - desktop_core::autofill::run_command(value) - .await - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - #[derive(Debug, serde::Serialize, serde:: Deserialize)] - pub enum BitwardenError { - Internal(String), - } - - #[napi(string_enum)] - #[derive(Debug, Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - pub enum UserVerification { - #[napi(value = "preferred")] - Preferred, - #[napi(value = "required")] - Required, - #[napi(value = "discouraged")] - Discouraged, - } - - #[derive(Serialize, Deserialize)] - #[serde(bound = "T: Serialize + DeserializeOwned")] - pub struct PasskeyMessage { - pub sequence_number: u32, - pub value: Result, - } - - #[napi(object)] - #[derive(Debug, Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - pub struct Position { - pub x: i32, - pub y: i32, - } - - #[napi(object)] - #[derive(Debug, Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - pub struct PasskeyRegistrationRequest { - pub rp_id: String, - pub user_name: String, - pub user_handle: Vec, - pub client_data_hash: Vec, - pub user_verification: UserVerification, - pub supported_algorithms: Vec, - pub window_xy: Position, - pub excluded_credentials: Vec>, - } - - #[napi(object)] - #[derive(Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - pub struct PasskeyRegistrationResponse { - pub rp_id: String, - pub client_data_hash: Vec, - pub credential_id: Vec, - pub attestation_object: Vec, - } - - #[napi(object)] - #[derive(Debug, Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - pub struct PasskeyAssertionRequest { - pub rp_id: String, - pub client_data_hash: Vec, - pub user_verification: UserVerification, - pub allowed_credentials: Vec>, - pub window_xy: Position, - //extension_input: Vec, TODO: Implement support for extensions - } - - #[napi(object)] - #[derive(Debug, Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - pub struct PasskeyAssertionWithoutUserInterfaceRequest { - pub rp_id: String, - pub credential_id: Vec, - pub user_name: String, - pub user_handle: Vec, - pub record_identifier: Option, - pub client_data_hash: Vec, - pub user_verification: UserVerification, - pub window_xy: Position, - } - - #[napi(object)] - #[derive(Debug, Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - pub struct NativeStatus { - pub key: String, - pub value: String, - } - - #[napi(object)] - #[derive(Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - pub struct PasskeyAssertionResponse { - pub rp_id: String, - pub user_handle: Vec, - pub signature: Vec, - pub client_data_hash: Vec, - pub authenticator_data: Vec, - pub credential_id: Vec, - } - - #[napi] - pub struct AutofillIpcServer { - server: desktop_core::ipc::server::Server, - } - - // FIXME: Remove unwraps! They panic and terminate the whole application. - #[allow(clippy::unwrap_used)] - #[napi] - impl AutofillIpcServer { - /// Create and start the IPC server without blocking. - /// - /// @param name The endpoint name to listen on. This name uniquely identifies the IPC - /// connection and must be the same for both the server and client. @param callback - /// This function will be called whenever a message is received from a client. - #[allow(clippy::unused_async)] // FIXME: Remove unused async! - #[napi(factory)] - pub async fn listen( - name: String, - // Ideally we'd have a single callback that has an enum containing the request values, - // but NAPI doesn't support that just yet - #[napi( - ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void" - )] - registration_callback: ThreadsafeFunction< - FnArgs<(u32, u32, PasskeyRegistrationRequest)>, - >, - #[napi( - ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void" - )] - assertion_callback: ThreadsafeFunction< - FnArgs<(u32, u32, PasskeyAssertionRequest)>, - >, - #[napi( - ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void" - )] - assertion_without_user_interface_callback: ThreadsafeFunction< - FnArgs<(u32, u32, PasskeyAssertionWithoutUserInterfaceRequest)>, - >, - #[napi( - ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void" - )] - native_status_callback: ThreadsafeFunction<(u32, u32, NativeStatus)>, - ) -> napi::Result { - let (send, mut recv) = tokio::sync::mpsc::channel::(32); - tokio::spawn(async move { - while let Some(Message { - client_id, - kind, - message, - }) = recv.recv().await - { - match kind { - // TODO: We're ignoring the connection and disconnection messages for now - MessageType::Connected | MessageType::Disconnected => continue, - MessageType::Message => { - let Some(message) = message else { - error!("Message is empty"); - continue; - }; - - match serde_json::from_str::>( - &message, - ) { - Ok(msg) => { - let value = msg - .value - .map(|value| (client_id, msg.sequence_number, value).into()) - .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); - - assertion_callback - .call(value, ThreadsafeFunctionCallMode::NonBlocking); - continue; - } - Err(e) => { - error!(error = %e, "Error deserializing message1"); - } - } - - match serde_json::from_str::< - PasskeyMessage, - >(&message) - { - Ok(msg) => { - let value = msg - .value - .map(|value| (client_id, msg.sequence_number, value).into()) - .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); - - assertion_without_user_interface_callback - .call(value, ThreadsafeFunctionCallMode::NonBlocking); - continue; - } - Err(e) => { - error!(error = %e, "Error deserializing message1"); - } - } - - match serde_json::from_str::>( - &message, - ) { - Ok(msg) => { - let value = msg - .value - .map(|value| (client_id, msg.sequence_number, value).into()) - .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); - registration_callback - .call(value, ThreadsafeFunctionCallMode::NonBlocking); - continue; - } - Err(e) => { - error!(error = %e, "Error deserializing message2"); - } - } - - match serde_json::from_str::>(&message) { - Ok(msg) => { - let value = msg - .value - .map(|value| (client_id, msg.sequence_number, value)) - .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); - native_status_callback - .call(value, ThreadsafeFunctionCallMode::NonBlocking); - continue; - } - Err(error) => { - error!(%error, "Unable to deserialze native status."); - } - } - - error!(message, "Received an unknown message2"); - } - } - } - }); - - let path = desktop_core::ipc::path(&name); - - let server = desktop_core::ipc::server::Server::start(&path, send).map_err(|e| { - napi::Error::from_reason(format!( - "Error listening to server - Path: {path:?} - Error: {e} - {e:?}" - )) - })?; - - Ok(AutofillIpcServer { server }) - } - - /// Return the path to the IPC server. - #[napi] - pub fn get_path(&self) -> String { - self.server.path.to_string_lossy().to_string() - } - - /// Stop the IPC server. - #[napi] - pub fn stop(&self) -> napi::Result<()> { - self.server.stop(); - Ok(()) - } - - #[napi] - pub fn complete_registration( - &self, - client_id: u32, - sequence_number: u32, - response: PasskeyRegistrationResponse, - ) -> napi::Result { - let message = PasskeyMessage { - sequence_number, - value: Ok(response), - }; - self.send(client_id, serde_json::to_string(&message).unwrap()) - } - - #[napi] - pub fn complete_assertion( - &self, - client_id: u32, - sequence_number: u32, - response: PasskeyAssertionResponse, - ) -> napi::Result { - let message = PasskeyMessage { - sequence_number, - value: Ok(response), - }; - self.send(client_id, serde_json::to_string(&message).unwrap()) - } - - #[napi] - pub fn complete_error( - &self, - client_id: u32, - sequence_number: u32, - error: String, - ) -> napi::Result { - let message: PasskeyMessage<()> = PasskeyMessage { - sequence_number, - value: Err(BitwardenError::Internal(error)), - }; - self.send(client_id, serde_json::to_string(&message).unwrap()) - } - - // TODO: Add a way to send a message to a specific client? - fn send(&self, _client_id: u32, message: String) -> napi::Result { - self.server - .send(message) - .map_err(|e| { - napi::Error::from_reason(format!("Error sending message - Error: {e} - {e:?}")) - }) - // NAPI doesn't support u64 or usize, so we need to convert to u32 - .map(|u| u32::try_from(u).unwrap_or_default()) - } - } -} - -#[napi] -pub mod passkey_authenticator { - #[napi] - pub fn register() -> napi::Result<()> { - crate::passkey_authenticator_internal::register().map_err(|e| { - napi::Error::from_reason(format!("Passkey registration failed - Error: {e} - {e:?}")) - }) - } -} - -#[napi] -pub mod logging { - //! `logging` is the interface between the native desktop's usage of the `tracing` crate - //! for logging, to intercept events and write to the JS space. - //! - //! # Example - //! - //! [Elec] 14:34:03.517 › [NAPI] [INFO] desktop_core::ssh_agent::platform_ssh_agent: Starting - //! SSH Agent server {socket=/Users/foo/.bitwarden-ssh-agent.sock} - - use std::{fmt::Write, sync::OnceLock}; - - use napi::{ - bindgen_prelude::FnArgs, - threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, - }; - use tracing::Level; - use tracing_subscriber::{ - filter::EnvFilter, - fmt::format::{DefaultVisitor, Writer}, - layer::SubscriberExt, - util::SubscriberInitExt, - Layer, - }; - - struct JsLogger(OnceLock>>); - static JS_LOGGER: JsLogger = JsLogger(OnceLock::new()); - - #[napi] - pub enum LogLevel { - Trace, - Debug, - Info, - Warn, - Error, - } - - impl From<&Level> for LogLevel { - fn from(level: &Level) -> Self { - match *level { - Level::TRACE => LogLevel::Trace, - Level::DEBUG => LogLevel::Debug, - Level::INFO => LogLevel::Info, - Level::WARN => LogLevel::Warn, - Level::ERROR => LogLevel::Error, - } - } - } - - // JsLayer lets us intercept events and write them to the JS Logger. - struct JsLayer; - - impl Layer for JsLayer - where - S: tracing::Subscriber, - { - // This function builds a log message buffer from the event data and - // calls the JS logger with it. - // - // For example, this log call: - // - // ``` - // mod supreme { - // mod module { - // let foo = "bar"; - // info!(best_variable_name = %foo, "Foo done it again."); - // } - // } - // ``` - // - // , results in the following string: - // - // [INFO] supreme::module: Foo done it again. {best_variable_name=bar} - fn on_event( - &self, - event: &tracing::Event<'_>, - _ctx: tracing_subscriber::layer::Context<'_, S>, - ) { - let mut buffer = String::new(); - - // create the preamble text that precedes the message and vars. e.g.: - // [INFO] desktop_core::ssh_agent::platform_ssh_agent: - let level = event.metadata().level().as_str(); - let module_path = event.metadata().module_path().unwrap_or_default(); - - write!(&mut buffer, "[{level}] {module_path}:") - .expect("Failed to write tracing event to buffer"); - - let writer = Writer::new(&mut buffer); - - // DefaultVisitor adds the message and variables to the buffer - let mut visitor = DefaultVisitor::new(writer, false); - event.record(&mut visitor); - - let msg = (event.metadata().level().into(), buffer); - - if let Some(logger) = JS_LOGGER.0.get() { - let _ = logger.call(Ok(msg.into()), ThreadsafeFunctionCallMode::NonBlocking); - }; - } - } - - #[napi] - pub fn init_napi_log(js_log_fn: ThreadsafeFunction>) { - let _ = JS_LOGGER.0.set(js_log_fn); - - // the log level hierarchy is determined by: - // - if RUST_LOG is detected at runtime - // - if RUST_LOG is provided at compile time - // - default to INFO - let filter = EnvFilter::builder() - .with_default_directive( - option_env!("RUST_LOG") - .unwrap_or("info") - .parse() - .expect("should provide valid log level at compile time."), - ) - // parse directives from the RUST_LOG environment variable, - // overriding the default directive for matching targets. - .from_env_lossy(); - - // With the `tracing-log` feature enabled for the `tracing_subscriber`, - // the registry below will initialize a log compatibility layer, which allows - // the subscriber to consume log::Records as though they were tracing Events. - // https://docs.rs/tracing-subscriber/latest/tracing_subscriber/util/trait.SubscriberInitExt.html#method.init - tracing_subscriber::registry() - .with(filter) - .with(JsLayer) - .init(); - } -} - -#[napi] -pub mod chromium_importer { - use std::collections::HashMap; - - use chromium_importer::{ - chromium::{ - DefaultInstalledBrowserRetriever, LoginImportResult as _LoginImportResult, - ProfileInfo as _ProfileInfo, - }, - metadata::NativeImporterMetadata as _NativeImporterMetadata, - }; - - #[napi(object)] - pub struct ProfileInfo { - pub id: String, - pub name: String, - } - - #[napi(object)] - pub struct Login { - pub url: String, - pub username: String, - pub password: String, - pub note: String, - } - - #[napi(object)] - pub struct LoginImportFailure { - pub url: String, - pub username: String, - pub error: String, - } - - #[napi(object)] - pub struct LoginImportResult { - pub login: Option, - pub failure: Option, - } - - #[napi(object)] - pub struct NativeImporterMetadata { - pub id: String, - pub loaders: Vec, - pub instructions: String, - } - - impl From<_LoginImportResult> for LoginImportResult { - fn from(l: _LoginImportResult) -> Self { - match l { - _LoginImportResult::Success(l) => LoginImportResult { - login: Some(Login { - url: l.url, - username: l.username, - password: l.password, - note: l.note, - }), - failure: None, - }, - _LoginImportResult::Failure(l) => LoginImportResult { - login: None, - failure: Some(LoginImportFailure { - url: l.url, - username: l.username, - error: l.error, - }), - }, - } - } - } - - impl From<_ProfileInfo> for ProfileInfo { - fn from(p: _ProfileInfo) -> Self { - ProfileInfo { - id: p.folder, - name: p.name, - } - } - } - - impl From<_NativeImporterMetadata> for NativeImporterMetadata { - fn from(m: _NativeImporterMetadata) -> Self { - NativeImporterMetadata { - id: m.id, - loaders: m.loaders, - instructions: m.instructions, - } - } - } - - #[napi] - /// Returns OS aware metadata describing supported Chromium based importers as a JSON string. - pub fn get_metadata() -> HashMap { - chromium_importer::metadata::get_supported_importers::() - .into_iter() - .map(|(browser, metadata)| (browser, NativeImporterMetadata::from(metadata))) - .collect() - } - - #[napi] - pub fn get_available_profiles(browser: String) -> napi::Result> { - chromium_importer::chromium::get_available_profiles(&browser) - .map(|profiles| profiles.into_iter().map(ProfileInfo::from).collect()) - .map_err(|e| napi::Error::from_reason(e.to_string())) - } - - #[napi] - pub async fn import_logins( - browser: String, - profile_id: String, - ) -> napi::Result> { - chromium_importer::chromium::import_logins(&browser, &profile_id) - .await - .map(|logins| logins.into_iter().map(LoginImportResult::from).collect()) - .map_err(|e| napi::Error::from_reason(e.to_string())) - } -} - -#[napi] -pub mod autotype { - #[napi] - pub fn get_foreground_window_title() -> napi::Result { - autotype::get_foreground_window_title().map_err(|_| { - napi::Error::from_reason( - "Autotype Error: failed to get foreground window title".to_string(), - ) - }) - } - - #[napi] - pub fn type_input( - input: Vec, - keyboard_shortcut: Vec, - ) -> napi::Result<(), napi::Status> { - autotype::type_input(&input, &keyboard_shortcut) - .map_err(|e| napi::Error::from_reason(format!("Autotype Error: {e}"))) - } -} +// NAPI namespaces +// In each of these modules, the types are defined within a nested namespace of +// the same name so that NAPI can export the TypeScript types within a +// namespace. +pub mod autofill; +pub mod autostart; +pub mod autotype; +pub mod biometrics; +pub mod biometrics_v2; +pub mod chromium_importer; +pub mod clipboards; +pub mod ipc; +pub mod logging; +pub mod passkey_authenticator; +pub mod passwords; +pub mod powermonitors; +pub mod processisolations; +pub mod sshagent; +pub mod windows_registry; diff --git a/apps/desktop/desktop_native/napi/src/logging.rs b/apps/desktop/desktop_native/napi/src/logging.rs new file mode 100644 index 00000000000..e5791065e4e --- /dev/null +++ b/apps/desktop/desktop_native/napi/src/logging.rs @@ -0,0 +1,131 @@ +#[napi] +pub mod logging { + //! `logging` is the interface between the native desktop's usage of the `tracing` crate + //! for logging, to intercept events and write to the JS space. + //! + //! # Example + //! + //! [Elec] 14:34:03.517 › [NAPI] [INFO] desktop_core::ssh_agent::platform_ssh_agent: Starting + //! SSH Agent server {socket=/Users/foo/.bitwarden-ssh-agent.sock} + + use std::{fmt::Write, sync::OnceLock}; + + use napi::{ + bindgen_prelude::FnArgs, + threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, + }; + use tracing::Level; + use tracing_subscriber::{ + filter::EnvFilter, + fmt::format::{DefaultVisitor, Writer}, + layer::SubscriberExt, + util::SubscriberInitExt, + Layer, + }; + + struct JsLogger(OnceLock>>); + static JS_LOGGER: JsLogger = JsLogger(OnceLock::new()); + + #[napi] + pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, + } + + impl From<&Level> for LogLevel { + fn from(level: &Level) -> Self { + match *level { + Level::TRACE => LogLevel::Trace, + Level::DEBUG => LogLevel::Debug, + Level::INFO => LogLevel::Info, + Level::WARN => LogLevel::Warn, + Level::ERROR => LogLevel::Error, + } + } + } + + // JsLayer lets us intercept events and write them to the JS Logger. + struct JsLayer; + + impl Layer for JsLayer + where + S: tracing::Subscriber, + { + // This function builds a log message buffer from the event data and + // calls the JS logger with it. + // + // For example, this log call: + // + // ``` + // mod supreme { + // mod module { + // let foo = "bar"; + // info!(best_variable_name = %foo, "Foo done it again."); + // } + // } + // ``` + // + // , results in the following string: + // + // [INFO] supreme::module: Foo done it again. {best_variable_name=bar} + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + let mut buffer = String::new(); + + // create the preamble text that precedes the message and vars. e.g.: + // [INFO] desktop_core::ssh_agent::platform_ssh_agent: + let level = event.metadata().level().as_str(); + let module_path = event.metadata().module_path().unwrap_or_default(); + + write!(&mut buffer, "[{level}] {module_path}:") + .expect("Failed to write tracing event to buffer"); + + let writer = Writer::new(&mut buffer); + + // DefaultVisitor adds the message and variables to the buffer + let mut visitor = DefaultVisitor::new(writer, false); + event.record(&mut visitor); + + let msg = (event.metadata().level().into(), buffer); + + if let Some(logger) = JS_LOGGER.0.get() { + let _ = logger.call(Ok(msg.into()), ThreadsafeFunctionCallMode::NonBlocking); + }; + } + } + + #[napi] + pub fn init_napi_log(js_log_fn: ThreadsafeFunction>) { + let _ = JS_LOGGER.0.set(js_log_fn); + + // the log level hierarchy is determined by: + // - if RUST_LOG is detected at runtime + // - if RUST_LOG is provided at compile time + // - default to INFO + let filter = EnvFilter::builder() + .with_default_directive( + option_env!("RUST_LOG") + .unwrap_or("info") + .parse() + .expect("should provide valid log level at compile time."), + ) + // parse directives from the RUST_LOG environment variable, + // overriding the default directive for matching targets. + .from_env_lossy(); + + // With the `tracing-log` feature enabled for the `tracing_subscriber`, + // the registry below will initialize a log compatibility layer, which allows + // the subscriber to consume log::Records as though they were tracing Events. + // https://docs.rs/tracing-subscriber/latest/tracing_subscriber/util/trait.SubscriberInitExt.html#method.init + tracing_subscriber::registry() + .with(filter) + .with(JsLayer) + .init(); + } +} diff --git a/apps/desktop/desktop_native/napi/src/passkey_authenticator.rs b/apps/desktop/desktop_native/napi/src/passkey_authenticator.rs new file mode 100644 index 00000000000..37796353b80 --- /dev/null +++ b/apps/desktop/desktop_native/napi/src/passkey_authenticator.rs @@ -0,0 +1,9 @@ +#[napi] +pub mod passkey_authenticator { + #[napi] + pub fn register() -> napi::Result<()> { + crate::passkey_authenticator_internal::register().map_err(|e| { + napi::Error::from_reason(format!("Passkey registration failed - Error: {e} - {e:?}")) + }) + } +} diff --git a/apps/desktop/desktop_native/napi/src/passwords.rs b/apps/desktop/desktop_native/napi/src/passwords.rs new file mode 100644 index 00000000000..763f338b0cb --- /dev/null +++ b/apps/desktop/desktop_native/napi/src/passwords.rs @@ -0,0 +1,46 @@ +#[napi] +pub mod passwords { + + /// The error message returned when a password is not found during retrieval or deletion. + #[napi] + pub const PASSWORD_NOT_FOUND: &str = desktop_core::password::PASSWORD_NOT_FOUND; + + /// Fetch the stored password from the keychain. + /// Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist. + #[napi] + pub async fn get_password(service: String, account: String) -> napi::Result { + desktop_core::password::get_password(&service, &account) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + /// Save the password to the keychain. Adds an entry if none exists otherwise updates the + /// existing entry. + #[napi] + pub async fn set_password( + service: String, + account: String, + password: String, + ) -> napi::Result<()> { + desktop_core::password::set_password(&service, &account, &password) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + /// Delete the stored password from the keychain. + /// Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist. + #[napi] + pub async fn delete_password(service: String, account: String) -> napi::Result<()> { + desktop_core::password::delete_password(&service, &account) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + /// Checks if the os secure storage is available + #[napi] + pub async fn is_available() -> napi::Result { + desktop_core::password::is_available() + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } +} diff --git a/apps/desktop/desktop_native/napi/src/powermonitors.rs b/apps/desktop/desktop_native/napi/src/powermonitors.rs new file mode 100644 index 00000000000..eb673bdbe68 --- /dev/null +++ b/apps/desktop/desktop_native/napi/src/powermonitors.rs @@ -0,0 +1,26 @@ +#[napi] +pub mod powermonitors { + use napi::{ + threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, + tokio, + }; + + #[napi] + pub async fn on_lock(callback: ThreadsafeFunction<()>) -> napi::Result<()> { + let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(32); + desktop_core::powermonitor::on_lock(tx) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + tokio::spawn(async move { + while let Some(()) = rx.recv().await { + callback.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking); + } + }); + Ok(()) + } + + #[napi] + pub async fn is_lock_monitor_available() -> napi::Result { + Ok(desktop_core::powermonitor::is_lock_monitor_available().await) + } +} diff --git a/apps/desktop/desktop_native/napi/src/processisolations.rs b/apps/desktop/desktop_native/napi/src/processisolations.rs new file mode 100644 index 00000000000..6ab4a2a645d --- /dev/null +++ b/apps/desktop/desktop_native/napi/src/processisolations.rs @@ -0,0 +1,23 @@ +#[napi] +pub mod processisolations { + #[allow(clippy::unused_async)] // FIXME: Remove unused async! + #[napi] + pub async fn disable_coredumps() -> napi::Result<()> { + desktop_core::process_isolation::disable_coredumps() + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[allow(clippy::unused_async)] // FIXME: Remove unused async! + #[napi] + pub async fn is_core_dumping_disabled() -> napi::Result { + desktop_core::process_isolation::is_core_dumping_disabled() + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[allow(clippy::unused_async)] // FIXME: Remove unused async! + #[napi] + pub async fn isolate_process() -> napi::Result<()> { + desktop_core::process_isolation::isolate_process() + .map_err(|e| napi::Error::from_reason(e.to_string())) + } +} diff --git a/apps/desktop/desktop_native/napi/src/sshagent.rs b/apps/desktop/desktop_native/napi/src/sshagent.rs new file mode 100644 index 00000000000..83eec090302 --- /dev/null +++ b/apps/desktop/desktop_native/napi/src/sshagent.rs @@ -0,0 +1,163 @@ +#[napi] +pub mod sshagent { + use std::sync::Arc; + + use napi::{ + bindgen_prelude::Promise, + threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, + }; + use tokio::{self, sync::Mutex}; + use tracing::error; + + #[napi] + pub struct SshAgentState { + state: desktop_core::ssh_agent::BitwardenDesktopAgent, + } + + #[napi(object)] + pub struct PrivateKey { + pub private_key: String, + pub name: String, + pub cipher_id: String, + } + + #[napi(object)] + pub struct SshKey { + pub private_key: String, + pub public_key: String, + pub key_fingerprint: String, + } + + #[napi(object)] + pub struct SshUIRequest { + pub cipher_id: Option, + pub is_list: bool, + pub process_name: String, + pub is_forwarding: bool, + pub namespace: Option, + } + + #[allow(clippy::unused_async)] // FIXME: Remove unused async! + #[napi] + pub async fn serve( + callback: ThreadsafeFunction>, + ) -> napi::Result { + let (auth_request_tx, mut auth_request_rx) = + tokio::sync::mpsc::channel::(32); + let (auth_response_tx, auth_response_rx) = + tokio::sync::broadcast::channel::<(u32, bool)>(32); + let auth_response_tx_arc = Arc::new(Mutex::new(auth_response_tx)); + // Wrap callback in Arc so it can be shared across spawned tasks + let callback = Arc::new(callback); + tokio::spawn(async move { + let _ = auth_response_rx; + + while let Some(request) = auth_request_rx.recv().await { + let cloned_response_tx_arc = auth_response_tx_arc.clone(); + let cloned_callback = callback.clone(); + tokio::spawn(async move { + let auth_response_tx_arc = cloned_response_tx_arc; + let callback = cloned_callback; + // In NAPI v3, obtain the JS callback return as a Promise and await it + // in Rust + let (tx, rx) = std::sync::mpsc::channel::>(); + let status = callback.call_with_return_value( + Ok(SshUIRequest { + cipher_id: request.cipher_id, + is_list: request.is_list, + process_name: request.process_name, + is_forwarding: request.is_forwarding, + namespace: request.namespace, + }), + ThreadsafeFunctionCallMode::Blocking, + move |ret: Result, napi::Error>, _env| { + if let Ok(p) = ret { + let _ = tx.send(p); + } + Ok(()) + }, + ); + + let result = if status == napi::Status::Ok { + match rx.recv() { + Ok(promise) => match promise.await { + Ok(v) => v, + Err(e) => { + error!(error = %e, "UI callback promise rejected"); + false + } + }, + Err(e) => { + error!(error = %e, "Failed to receive UI callback promise"); + false + } + } + } else { + error!(error = ?status, "Calling UI callback failed"); + false + }; + + let _ = auth_response_tx_arc + .lock() + .await + .send((request.request_id, result)) + .expect("should be able to send auth response to agent"); + }); + } + }); + + match desktop_core::ssh_agent::BitwardenDesktopAgent::start_server( + auth_request_tx, + Arc::new(Mutex::new(auth_response_rx)), + ) { + Ok(state) => Ok(SshAgentState { state }), + Err(e) => Err(napi::Error::from_reason(e.to_string())), + } + } + + #[napi] + pub fn stop(agent_state: &mut SshAgentState) -> napi::Result<()> { + let bitwarden_agent_state = &mut agent_state.state; + bitwarden_agent_state.stop(); + Ok(()) + } + + #[napi] + pub fn is_running(agent_state: &mut SshAgentState) -> bool { + let bitwarden_agent_state = agent_state.state.clone(); + bitwarden_agent_state.is_running() + } + + #[napi] + pub fn set_keys( + agent_state: &mut SshAgentState, + new_keys: Vec, + ) -> napi::Result<()> { + let bitwarden_agent_state = &mut agent_state.state; + bitwarden_agent_state + .set_keys( + new_keys + .iter() + .map(|k| (k.private_key.clone(), k.name.clone(), k.cipher_id.clone())) + .collect(), + ) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(()) + } + + #[napi] + pub fn lock(agent_state: &mut SshAgentState) -> napi::Result<()> { + let bitwarden_agent_state = &mut agent_state.state; + bitwarden_agent_state + .lock() + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub fn clear_keys(agent_state: &mut SshAgentState) -> napi::Result<()> { + let bitwarden_agent_state = &mut agent_state.state; + bitwarden_agent_state + .clear_keys() + .map_err(|e| napi::Error::from_reason(e.to_string())) + } +} diff --git a/apps/desktop/desktop_native/napi/src/windows_registry.rs b/apps/desktop/desktop_native/napi/src/windows_registry.rs new file mode 100644 index 00000000000..e22e2ce46f5 --- /dev/null +++ b/apps/desktop/desktop_native/napi/src/windows_registry.rs @@ -0,0 +1,16 @@ +#[napi] +pub mod windows_registry { + #[allow(clippy::unused_async)] // FIXME: Remove unused async! + #[napi] + pub async fn create_key(key: String, subkey: String, value: String) -> napi::Result<()> { + crate::registry::create_key(&key, &subkey, &value) + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[allow(clippy::unused_async)] // FIXME: Remove unused async! + #[napi] + pub async fn delete_key(key: String, subkey: String) -> napi::Result<()> { + crate::registry::delete_key(&key, &subkey) + .map_err(|e| napi::Error::from_reason(e.to_string())) + } +} diff --git a/apps/desktop/desktop_native/ssh_agent/Cargo.toml b/apps/desktop/desktop_native/ssh_agent/Cargo.toml new file mode 100644 index 00000000000..becab28c356 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "ssh_agent" +edition = { workspace = true } +license = { workspace = true } +version = { workspace = true } +publish = { workspace = true } + +[dependencies] +anyhow = { workspace = true } +base64 = { workspace = true } +ssh-key = { version = "=0.6.7", features = [ + "encryption", + "ed25519", + "rsa", + "rand_core", +] } + +[lints] +workspace = true diff --git a/apps/desktop/desktop_native/ssh_agent/src/crypto/mod.rs b/apps/desktop/desktop_native/ssh_agent/src/crypto/mod.rs new file mode 100644 index 00000000000..655e440dc78 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/crypto/mod.rs @@ -0,0 +1,184 @@ +//! Cryptographic key management for the SSH agent. +//! +//! This module provides the core primitive types and functionality for managing +//! SSH keys in the Bitwarden SSH agent. +//! +//! # Supported signing algorithms +//! +//! - Ed25519 +//! - RSA +//! +//! ECDSA keys are not currently supported (PM-29894) + +use std::fmt; + +use anyhow::anyhow; +use ssh_key::private::{Ed25519Keypair, RsaKeypair}; + +/// Represents an SSH key and its associated metadata. +#[derive(Clone)] +pub(crate) struct SSHKeyData { + /// Private key of the key pair + private_key: PrivateKey, + /// Public key of the key pair + public_key: PublicKey, + /// Human-readable name + name: String, + /// Vault cipher ID associated with the key pair + cipher_id: String, +} + +impl SSHKeyData { + /// Creates a new `SSHKeyData` instance. + /// + /// # Arguments + /// + /// * `private_key` - The private key component + /// * `public_key` - The public key component + /// * `name` - A human-readable name for the key + /// * `cipher_id` - The vault cipher identifier associated with this key + pub(crate) fn new( + private_key: PrivateKey, + public_key: PublicKey, + name: String, + cipher_id: String, + ) -> Self { + Self { + private_key, + public_key, + name, + cipher_id, + } + } + + /// # Returns + /// + /// A reference to the [`PublicKey`]. + pub(crate) fn public_key(&self) -> &PublicKey { + &self.public_key + } + + /// # Returns + /// + /// A reference to the [`PrivateKey`]. + pub(crate) fn private_key(&self) -> &PrivateKey { + &self.private_key + } + + /// # Returns + /// + /// A reference to the human-readable name for this key. + pub(crate) fn name(&self) -> &String { + &self.name + } + + /// # Returns + /// + /// A reference to the cipher ID that links this key to a vault entry. + pub(crate) fn cipher_id(&self) -> &String { + &self.cipher_id + } +} + +/// Represents an SSH private key. +#[derive(Clone, PartialEq, Debug)] +pub(crate) enum PrivateKey { + Ed25519(Ed25519Keypair), + Rsa(RsaKeypair), +} + +impl TryFrom for PrivateKey { + type Error = anyhow::Error; + + fn try_from(key: ssh_key::private::PrivateKey) -> Result { + match key.algorithm() { + ssh_key::Algorithm::Ed25519 => Ok(Self::Ed25519( + key.key_data() + .ed25519() + .ok_or(anyhow!("Failed to parse ed25519 key"))? + .to_owned(), + )), + ssh_key::Algorithm::Rsa { hash: _ } => Ok(Self::Rsa( + key.key_data() + .rsa() + .ok_or(anyhow!("Failed to parse RSA key"))? + .to_owned(), + )), + _ => Err(anyhow!("Unsupported key type")), + } + } +} + +/// Represents an SSH public key. +/// +/// Contains the algorithm identifier (e.g., "ssh-ed25519", "ssh-rsa") +/// and the binary blob of the public key data. +#[derive(Clone, Ord, Eq, PartialOrd, PartialEq)] +pub(crate) struct PublicKey { + pub alg: String, + pub blob: Vec, +} + +impl PublicKey { + pub(crate) fn alg(&self) -> &str { + &self.alg + } + + pub(crate) fn blob(&self) -> &[u8] { + &self.blob + } +} + +impl fmt::Debug for PublicKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "PublicKey(\"{self}\")") + } +} + +impl fmt::Display for PublicKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use base64::{prelude::BASE64_STANDARD, Engine as _}; + + write!(f, "{} {}", self.alg(), BASE64_STANDARD.encode(self.blob())) + } +} + +#[cfg(test)] +mod tests { + use ssh_key::{ + private::{Ed25519Keypair, RsaKeypair}, + rand_core::OsRng, + LineEnding, + }; + + use super::*; + + const MIN_KEY_BIT_SIZE: usize = 2048; + + fn create_valid_ed25519_key_string() -> String { + let ed25519_keypair = Ed25519Keypair::random(&mut OsRng); + let ssh_key = + ssh_key::PrivateKey::new(ssh_key::private::KeypairData::Ed25519(ed25519_keypair), "") + .unwrap(); + ssh_key.to_openssh(LineEnding::LF).unwrap().to_string() + } + + #[test] + fn test_privatekey_from_ed25519() { + let key_string = create_valid_ed25519_key_string(); + let ssh_key = ssh_key::PrivateKey::from_openssh(&key_string).unwrap(); + + let private_key = PrivateKey::try_from(ssh_key).unwrap(); + assert!(matches!(private_key, PrivateKey::Ed25519(_))); + } + + #[test] + fn test_privatekey_from_rsa() { + let rsa_keypair = RsaKeypair::random(&mut OsRng, MIN_KEY_BIT_SIZE).unwrap(); + let ssh_key = + ssh_key::PrivateKey::new(ssh_key::private::KeypairData::Rsa(rsa_keypair), "").unwrap(); + + let private_key = PrivateKey::try_from(ssh_key).unwrap(); + assert!(matches!(private_key, PrivateKey::Rsa(_))); + } +} diff --git a/apps/desktop/desktop_native/ssh_agent/src/lib.rs b/apps/desktop/desktop_native/ssh_agent/src/lib.rs new file mode 100644 index 00000000000..aaaf635e6c6 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/lib.rs @@ -0,0 +1,7 @@ +//! Bitwarden SSH Agent implementation +//! +//! + +#![allow(dead_code)] // TODO remove when all code is used in follow-up PR + +mod crypto; diff --git a/apps/desktop/desktop_native/windows_plugin_authenticator/Cargo.toml b/apps/desktop/desktop_native/windows_plugin_authenticator/Cargo.toml index 17c834325a4..9fd873d868e 100644 --- a/apps/desktop/desktop_native/windows_plugin_authenticator/Cargo.toml +++ b/apps/desktop/desktop_native/windows_plugin_authenticator/Cargo.toml @@ -6,6 +6,7 @@ license = { workspace = true } publish = { workspace = true } [target.'cfg(windows)'.dependencies] +hex = { workspace = true } windows = { workspace = true, features = [ "Win32_Foundation", "Win32_Security", @@ -13,7 +14,6 @@ windows = { workspace = true, features = [ "Win32_System_LibraryLoader", ] } windows-core = { workspace = true } -hex = { workspace = true } [lints] workspace = true diff --git a/apps/desktop/electron-builder.beta.json b/apps/desktop/electron-builder.beta.json index 0c95c7f01a6..9c66b17aa1f 100644 --- a/apps/desktop/electron-builder.beta.json +++ b/apps/desktop/electron-builder.beta.json @@ -1,4 +1,6 @@ { + "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", + "extraMetadata": { "name": "bitwarden-beta" }, @@ -13,14 +15,15 @@ }, "afterSign": "scripts/after-sign.js", "afterPack": "scripts/after-pack.js", - "asarUnpack": ["**/*.node"], + "beforePack": "scripts/before-pack.js", "files": [ - "**/*", - "!**/node_modules/@bitwarden/desktop-napi/**/*", - "**/node_modules/@bitwarden/desktop-napi/index.js", - "**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node" + "!node_modules/@bitwarden/desktop-napi/scripts", + "!node_modules/@bitwarden/desktop-napi/src", + "!node_modules/@bitwarden/desktop-napi/Cargo.toml", + "!node_modules/@bitwarden/desktop-napi/build.rs", + "!node_modules/@bitwarden/desktop-napi/package.json" ], - "electronVersion": "36.8.1", + "electronVersion": "37.7.0", "generateUpdatesFilesForAllChannels": true, "publish": { "provider": "generic", @@ -34,11 +37,11 @@ }, "extraFiles": [ { - "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe", + "from": "desktop_native/dist/desktop_proxy.win32-${arch}.exe", "to": "desktop_proxy.exe" }, { - "from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe", + "from": "desktop_native/dist/bitwarden_chromium_import_helper.win32-${arch}.exe", "to": "bitwarden_chromium_import_helper.exe" } ] @@ -58,6 +61,7 @@ "appx": { "artifactName": "Bitwarden-Beta-${version}-${arch}.${ext}", "backgroundColor": "#175DDC", + "customManifestPath": "./custom-appx-manifest.xml", "applicationId": "BitwardenBeta", "identityName": "8bitSolutionsLLC.BitwardenBeta", "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index a4e1c44dc5b..151ce72182d 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -1,4 +1,6 @@ { + "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", + "extraMetadata": { "name": "bitwarden" }, @@ -13,12 +15,13 @@ }, "afterSign": "scripts/after-sign.js", "afterPack": "scripts/after-pack.js", - "asarUnpack": ["**/*.node"], + "beforePack": "scripts/before-pack.js", "files": [ - "**/*", - "!**/node_modules/@bitwarden/desktop-napi/**/*", - "**/node_modules/@bitwarden/desktop-napi/index.js", - "**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node" + "!node_modules/@bitwarden/desktop-napi/scripts", + "!node_modules/@bitwarden/desktop-napi/src", + "!node_modules/@bitwarden/desktop-napi/Cargo.toml", + "!node_modules/@bitwarden/desktop-napi/build.rs", + "!node_modules/@bitwarden/desktop-napi/package.json" ], "electronVersion": "39.2.6", "generateUpdatesFilesForAllChannels": true, @@ -82,7 +85,8 @@ "signIgnore": [ "MacOS/desktop_proxy", "MacOS/desktop_proxy.inherit", - "Contents/Plugins/autofill-extension.appex" + "Contents/Plugins/autofill-extension.appex", + "Frameworks/Electron Framework.framework/(Electron Framework|Libraries|Resources|Versions/Current)/.*" ], "target": ["dmg", "zip"] }, @@ -94,11 +98,11 @@ }, "extraFiles": [ { - "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe", + "from": "desktop_native/dist/desktop_proxy.win32-${arch}.exe", "to": "desktop_proxy.exe" }, { - "from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe", + "from": "desktop_native/dist/bitwarden_chromium_import_helper.win32-${arch}.exe", "to": "bitwarden_chromium_import_helper.exe" } ] @@ -172,6 +176,7 @@ "appx": { "artifactName": "${productName}-${version}-${arch}.${ext}", "backgroundColor": "#175DDC", + "customManifestPath": "./custom-appx-manifest.xml", "applicationId": "bitwardendesktop", "identityName": "8bitSolutionsLLC.bitwardendesktop", "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", diff --git a/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift b/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift index 3de9468c8ab..35c5b8e720e 100644 --- a/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift +++ b/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift @@ -15,7 +15,7 @@ class CredentialProviderViewController: ASCredentialProviderViewController { @IBOutlet weak var logoImageView: NSImageView! // The IPC client to communicate with the Bitwarden desktop app - private var client: MacOsProviderClient? + private var client: AutofillProviderClient? // Timer for checking connection status private var connectionMonitorTimer: Timer? @@ -25,11 +25,12 @@ class CredentialProviderViewController: ASCredentialProviderViewController { // This is so that we can check if the app is running, and launch it, without blocking the main thread // Blocking the main thread caused MacOS layouting to 'fail' or at least be very delayed, which caused our getWindowPositioning code to sent 0,0. // We also properly retry the IPC connection which sometimes would take some time to be up and running, depending on CPU load, phase of jupiters moon, etc. - private func getClient() async -> MacOsProviderClient { + private func getClient() async -> AutofillProviderClient { if let client = self.client { return client } + initializeLogging() let logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider") // Check if the Electron app is running @@ -61,13 +62,13 @@ class CredentialProviderViewController: ASCredentialProviderViewController { // Retry connecting to the Bitwarden IPC with an increasing delay let maxRetries = 20 let delayMs = 500 - var newClient: MacOsProviderClient? + var newClient: AutofillProviderClient? for attempt in 1...maxRetries { logger.log("[autofill-extension] Connection attempt \(attempt)") // Create a new client instance for each retry - newClient = MacOsProviderClient.connect() + newClient = AutofillProviderClient.connect() try? await Task.sleep(nanoseconds: UInt64(100 * attempt + (delayMs * 1_000_000))) // Convert ms to nanoseconds let connectionStatus = newClient!.getConnectionStatus() @@ -129,7 +130,7 @@ class CredentialProviderViewController: ASCredentialProviderViewController { // If we just disconnected, try to cancel the request if currentStatus == .disconnected { - self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Bitwarden desktop app disconnected")) + self.extensionContext.cancelRequest(withError: BitwardenError.Disconnected) } } } diff --git a/apps/desktop/macos/autofill-extension/autofill_extension_enabled.entitlements b/apps/desktop/macos/autofill-extension/autofill_extension_enabled.entitlements new file mode 100644 index 00000000000..49fda8f8af8 --- /dev/null +++ b/apps/desktop/macos/autofill-extension/autofill_extension_enabled.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + LTZ2PFU5D6.com.bitwarden.desktop + + com.apple.developer.authentication-services.autofill-credential-provider + + + diff --git a/apps/desktop/macos/desktop.xcodeproj/project.pbxproj b/apps/desktop/macos/desktop.xcodeproj/project.pbxproj index ed19fc9ef5d..ddca8550a0d 100644 --- a/apps/desktop/macos/desktop.xcodeproj/project.pbxproj +++ b/apps/desktop/macos/desktop.xcodeproj/project.pbxproj @@ -17,7 +17,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = BitwardenMacosProviderFFI.xcframework; path = ../desktop_native/macos_provider/BitwardenMacosProviderFFI.xcframework; sourceTree = ""; }; + 3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = BitwardenMacosProviderFFI.xcframework; path = ../desktop_native/autofill_provider/BitwardenMacosProviderFFI.xcframework; sourceTree = ""; }; 3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BitwardenMacosProvider.swift; sourceTree = ""; }; 968ED08A2C52A47200FFFEE6 /* ReleaseAppStore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ReleaseAppStore.xcconfig; sourceTree = ""; }; 9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "bitwarden-icon.png"; sourceTree = ""; }; @@ -256,7 +256,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = D83832AD2D67B9D0003FB9F8 /* ReleaseDeveloper.xcconfig */; buildSettings = { - CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension.entitlements"; + CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension_enabled.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; CODE_SIGN_STYLE = Manual; @@ -409,7 +409,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = D83832AB2D67B9AE003FB9F8 /* Debug.xcconfig */; buildSettings = { - CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension.entitlements"; + CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension_enabled.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; CODE_SIGN_STYLE = Manual; diff --git a/apps/desktop/macos/desktop.xcodeproj/xcshareddata/xcschemes/autofill-extension.xcscheme b/apps/desktop/macos/desktop.xcodeproj/xcshareddata/xcschemes/autofill-extension.xcscheme new file mode 100644 index 00000000000..18357be4570 --- /dev/null +++ b/apps/desktop/macos/desktop.xcodeproj/xcshareddata/xcschemes/autofill-extension.xcscheme @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json index 9e3b6ba23e0..42257186f90 100644 --- a/apps/desktop/native-messaging-test-runner/package-lock.json +++ b/apps/desktop/native-messaging-test-runner/package-lock.json @@ -15,11 +15,11 @@ "@bitwarden/storage-core": "file:../../../libs/storage-core", "module-alias": "2.2.3", "ts-node": "10.9.2", - "uuid": "13.0.0", - "yargs": "18.0.0" + "uuid": "9.0.1", + "yargs": "17.7.2" }, "devDependencies": { - "@types/node": "22.19.3", + "@types/node": "22.19.7", "typescript": "5.4.2" } }, @@ -117,9 +117,9 @@ "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", "peer": true, "dependencies": { @@ -150,30 +150,6 @@ "node": ">=0.4.0" } }, - "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -181,19 +157,83 @@ "license": "MIT" }, "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "license": "ISC", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, "engines": { - "node": ">=20" + "node": ">=12" } }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -209,12 +249,6 @@ "node": ">=0.3.1" } }, - "node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "license": "MIT" - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -233,16 +267,12 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-east-asian-width": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", - "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", - "license": "MIT", + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/make-error": { @@ -257,36 +287,49 @@ "integrity": "sha512-23g5BFj4zdQL/b6tor7Ji+QY4pEfNH784BMslY9Qb0UnJWRAt+lQGLYmRaM0KDBwIG23ffEBELhZDP2rhi9f/Q==", "license": "MIT" }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "license": "MIT", + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dependencies": { - "ansi-regex": "^6.0.1" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=12" + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "engines": { + "node": ">=8" } }, "node_modules/ts-node": { @@ -353,16 +396,15 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], - "license": "MIT", "bin": { - "uuid": "dist-node/bin/uuid" + "uuid": "dist/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { @@ -371,23 +413,6 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "license": "MIT" }, - "node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -398,29 +423,28 @@ } }, "node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "license": "MIT", + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dependencies": { - "cliui": "^9.0.1", + "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" + "yargs-parser": "^21.1.1" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": ">=12" } }, "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "license": "ISC", + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": ">=12" } }, "node_modules/yn": { diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json index 050bb653445..93f7949bb93 100644 --- a/apps/desktop/native-messaging-test-runner/package.json +++ b/apps/desktop/native-messaging-test-runner/package.json @@ -20,17 +20,23 @@ "@bitwarden/logging": "dist/libs/logging/src", "module-alias": "2.2.3", "ts-node": "10.9.2", - "uuid": "13.0.0", - "yargs": "18.0.0" + "uuid": "9.0.1", + "yargs": "17.7.2" }, "devDependencies": { - "@types/node": "22.19.3", + "@types/node": "22.19.7", "typescript": "5.4.2" }, "_moduleAliases": { "@bitwarden/common": "dist/libs/common/src", "@bitwarden/node/services/node-crypto-function.service": "dist/libs/node/src/services/node-crypto-function.service", "@bitwarden/storage-core": "dist/libs/storage-core/src", - "@bitwarden/logging": "dist/libs/logging/src" + "@bitwarden/logging": "dist/libs/logging/src", + "@bitwarden/client-type": "dist/libs/client-type/src", + "@bitwarden/state": "dist/libs/state/src", + "@bitwarden/state-internal": "dist/libs/state-internal/src", + "@bitwarden/messaging": "dist/libs/messaging/src", + "@bitwarden/guid": "dist/libs/guid/src", + "@bitwarden/serialization": "dist/libs/serialization/src" } } diff --git a/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-create.ts b/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-create.ts index 8d2d734677a..46021eb72ca 100644 --- a/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-create.ts +++ b/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-create.ts @@ -11,6 +11,7 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums"; import { CredentialCreatePayload } from "../../../src/models/native-messaging/encrypted-message-payloads/credential-create-payload"; import { LogUtils } from "../log-utils"; import NativeMessageService from "../native-message.service"; +import { TestRunnerSdkLoadService } from "../sdk-load.service"; import * as config from "../variables"; const argv: any = yargs(hideBin(process.argv)).option("name", { @@ -25,6 +26,10 @@ const { name } = argv; // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { + // Initialize SDK before using crypto functions + const sdkLoadService = new TestRunnerSdkLoadService(); + await sdkLoadService.loadAndInit(); + const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One); // Handshake LogUtils.logInfo("Sending Handshake"); @@ -42,7 +47,10 @@ const { name } = argv; // Get active account userId const status = await nativeMessageService.checkStatus(handshakeResponse.sharedKey); - const activeUser = status.payload.filter((a) => a.active === true && a.status === "unlocked")[0]; + const activeUser = status.payload.filter( + (a: { active: boolean; status: string; id: string }) => + a.active === true && a.status === "unlocked", + )[0]; if (activeUser === undefined) { LogUtils.logError("No active or unlocked user"); } diff --git a/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-retrieval.ts b/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-retrieval.ts index 2e55afbb36f..70b0bad9d66 100644 --- a/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-retrieval.ts +++ b/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-retrieval.ts @@ -7,6 +7,7 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums"; import { LogUtils } from "../log-utils"; import NativeMessageService from "../native-message.service"; +import { TestRunnerSdkLoadService } from "../sdk-load.service"; import * as config from "../variables"; const argv: any = yargs(hideBin(process.argv)).option("uri", { @@ -21,6 +22,10 @@ const { uri } = argv; // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { + // Initialize SDK before using crypto functions + const sdkLoadService = new TestRunnerSdkLoadService(); + await sdkLoadService.loadAndInit(); + const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One); // Handshake LogUtils.logInfo("Sending Handshake"); diff --git a/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-update.ts b/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-update.ts index 93598bf9eef..7ba5eef143a 100644 --- a/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-update.ts +++ b/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-update.ts @@ -11,6 +11,7 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums"; import { CredentialUpdatePayload } from "../../../src/models/native-messaging/encrypted-message-payloads/credential-update-payload"; import { LogUtils } from "../log-utils"; import NativeMessageService from "../native-message.service"; +import { TestRunnerSdkLoadService } from "../sdk-load.service"; import * as config from "../variables"; // Command line arguments @@ -49,6 +50,10 @@ const { name, username, password, uri, credentialId } = argv; // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { + // Initialize SDK before using crypto functions + const sdkLoadService = new TestRunnerSdkLoadService(); + await sdkLoadService.loadAndInit(); + const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One); // Handshake LogUtils.logInfo("Sending Handshake"); @@ -67,7 +72,10 @@ const { name, username, password, uri, credentialId } = argv; // Get active account userId const status = await nativeMessageService.checkStatus(handshakeResponse.sharedKey); - const activeUser = status.payload.filter((a) => a.active === true && a.status === "unlocked")[0]; + const activeUser = status.payload.filter( + (a: { active: boolean; status: string; id: string }) => + a.active === true && a.status === "unlocked", + )[0]; if (activeUser === undefined) { LogUtils.logError("No active or unlocked user"); } diff --git a/apps/desktop/native-messaging-test-runner/src/commands/bw-generate-password.ts b/apps/desktop/native-messaging-test-runner/src/commands/bw-generate-password.ts index da914c67b4a..a0b449b02c7 100644 --- a/apps/desktop/native-messaging-test-runner/src/commands/bw-generate-password.ts +++ b/apps/desktop/native-messaging-test-runner/src/commands/bw-generate-password.ts @@ -7,6 +7,7 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums"; import { LogUtils } from "../log-utils"; import NativeMessageService from "../native-message.service"; +import { TestRunnerSdkLoadService } from "../sdk-load.service"; import * as config from "../variables"; const argv: any = yargs(hideBin(process.argv)).option("userId", { @@ -21,6 +22,10 @@ const { userId } = argv; // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { + // Initialize SDK before using crypto functions + const sdkLoadService = new TestRunnerSdkLoadService(); + await sdkLoadService.loadAndInit(); + const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One); // Handshake LogUtils.logInfo("Sending Handshake"); diff --git a/apps/desktop/native-messaging-test-runner/src/commands/bw-handshake.ts b/apps/desktop/native-messaging-test-runner/src/commands/bw-handshake.ts index 2ba5d469aaa..77a6ac652ad 100644 --- a/apps/desktop/native-messaging-test-runner/src/commands/bw-handshake.ts +++ b/apps/desktop/native-messaging-test-runner/src/commands/bw-handshake.ts @@ -4,11 +4,16 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums"; import { LogUtils } from "../log-utils"; import NativeMessageService from "../native-message.service"; +import { TestRunnerSdkLoadService } from "../sdk-load.service"; import * as config from "../variables"; // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { + // Initialize SDK before using crypto functions + const sdkLoadService = new TestRunnerSdkLoadService(); + await sdkLoadService.loadAndInit(); + const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One); const response = await nativeMessageService.sendHandshake( diff --git a/apps/desktop/native-messaging-test-runner/src/commands/bw-status.ts b/apps/desktop/native-messaging-test-runner/src/commands/bw-status.ts index 466e3fca52b..7014c4713c2 100644 --- a/apps/desktop/native-messaging-test-runner/src/commands/bw-status.ts +++ b/apps/desktop/native-messaging-test-runner/src/commands/bw-status.ts @@ -4,11 +4,16 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums"; import { LogUtils } from "../log-utils"; import NativeMessageService from "../native-message.service"; +import { TestRunnerSdkLoadService } from "../sdk-load.service"; import * as config from "../variables"; // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { + // Initialize SDK before using crypto functions + const sdkLoadService = new TestRunnerSdkLoadService(); + await sdkLoadService.loadAndInit(); + const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One); LogUtils.logInfo("Sending Handshake"); diff --git a/apps/desktop/native-messaging-test-runner/src/deferred.ts b/apps/desktop/native-messaging-test-runner/src/deferred.ts index da34d80ebb2..d3350ade5a4 100644 --- a/apps/desktop/native-messaging-test-runner/src/deferred.ts +++ b/apps/desktop/native-messaging-test-runner/src/deferred.ts @@ -4,8 +4,8 @@ // while allowing an unrelated event to fulfill it elsewhere. export default class Deferred { private promise: Promise; - private resolver: (T?) => void; - private rejecter: (Error?) => void; + private resolver!: (value?: T) => void; + private rejecter!: (reason?: Error) => void; constructor() { this.promise = new Promise((resolve, reject) => { diff --git a/apps/desktop/native-messaging-test-runner/src/ipc.service.ts b/apps/desktop/native-messaging-test-runner/src/ipc.service.ts index d8616e9757a..adb1e693d24 100644 --- a/apps/desktop/native-messaging-test-runner/src/ipc.service.ts +++ b/apps/desktop/native-messaging-test-runner/src/ipc.service.ts @@ -13,7 +13,7 @@ import { race } from "./race"; const DEFAULT_MESSAGE_TIMEOUT = 10 * 1000; // 10 seconds -export type MessageHandler = (MessageCommon) => void; +export type MessageHandler = (message: MessageCommon) => void; // FIXME: update to use a const object instead of a typescript enum // eslint-disable-next-line @bitwarden/platform/no-enums diff --git a/apps/desktop/native-messaging-test-runner/src/race.ts b/apps/desktop/native-messaging-test-runner/src/race.ts index 5ed778aa35b..a1c6cb04c5f 100644 --- a/apps/desktop/native-messaging-test-runner/src/race.ts +++ b/apps/desktop/native-messaging-test-runner/src/race.ts @@ -8,8 +8,8 @@ export const race = ({ promise: Promise; timeout: number; error?: Error; -}) => { - let timer = null; +}): Promise => { + let timer: NodeJS.Timeout | null = null; // Similar to Promise.all, but instead of waiting for all, it resolves once one promise finishes. // Using this so we can reject if the timeout threshold is hit @@ -20,7 +20,9 @@ export const race = ({ }), promise.then((value) => { - clearTimeout(timer); + if (timer != null) { + clearTimeout(timer); + } return value; }), ]); diff --git a/apps/desktop/native-messaging-test-runner/src/sdk-load.service.ts b/apps/desktop/native-messaging-test-runner/src/sdk-load.service.ts new file mode 100644 index 00000000000..d3f8289dffb --- /dev/null +++ b/apps/desktop/native-messaging-test-runner/src/sdk-load.service.ts @@ -0,0 +1,22 @@ +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; + +import { LogUtils } from "./log-utils"; + +/** + * SDK Load Service for the native messaging test runner. + * For Node.js environments, the SDK's Node.js build automatically loads WASM from the filesystem. + * No additional initialization is needed. + */ +export class TestRunnerSdkLoadService extends SdkLoadService { + async load(): Promise { + // In Node.js, @bitwarden/sdk-internal automatically loads the WASM file + // from node/bitwarden_wasm_internal_bg.wasm using fs.readFileSync. + // No explicit loading is required. + } + + override async loadAndInit(): Promise { + LogUtils.logInfo("Initializing SDK"); + await super.loadAndInit(); + LogUtils.logSuccess("SDK initialized"); + } +} diff --git a/apps/desktop/native-messaging-test-runner/tsconfig.json b/apps/desktop/native-messaging-test-runner/tsconfig.json index dcdf992f986..708559efc07 100644 --- a/apps/desktop/native-messaging-test-runner/tsconfig.json +++ b/apps/desktop/native-messaging-test-runner/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "../../../tsconfig.base.json", "compilerOptions": { "baseUrl": "./", "outDir": "dist", @@ -18,7 +19,13 @@ "@bitwarden/auth/*": ["../../../libs/auth/src/*"], "@bitwarden/common/*": ["../../../libs/common/src/*"], "@bitwarden/key-management": ["../../../libs/key-management/src/"], - "@bitwarden/node/*": ["../../../libs/node/src/*"] + "@bitwarden/node/*": ["../../../libs/node/src/*"], + "@bitwarden/state": ["../../../libs/state/src/index.ts"], + "@bitwarden/state-internal": ["../../../libs/state-internal/src/index.ts"], + "@bitwarden/client-type": ["../../../libs/client-type/src/index.ts"], + "@bitwarden/messaging": ["../../../libs/messaging/src/index.ts"], + "@bitwarden/guid": ["../../../libs/guid/src/index.ts"], + "@bitwarden/serialization": ["../../../libs/serialization/src/index.ts"] }, "plugins": [ { @@ -26,5 +33,6 @@ } ] }, - "exclude": ["node_modules"] + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 17322c42a84..cd2147d21e4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2025.12.1", + "version": "2026.2.0", "keywords": [ "bitwarden", "password", @@ -18,18 +18,18 @@ "scripts": { "postinstall": "electron-rebuild", "start": "cross-env ELECTRON_IS_DEV=0 ELECTRON_NO_UPDATER=1 electron ./build", - "build-native-macos": "cd desktop_native && ./macos_provider/build.sh && node build.js cross-platform", + "build-native-macos": "cd desktop_native && ./autofill_provider/build.sh && node build.js cross-platform", "build-native": "cd desktop_native && node build.js", "build": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main\" \"npm run build:renderer\" \"npm run build:preload\"", "build:dev": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main:dev\" \"npm run build:renderer:dev\" \"npm run build:preload:dev\"", "build:preload": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name preload", "build:preload:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name preload", "build:preload:watch": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name preload --watch", - "build:macos-extension:mac": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mac", - "build:macos-extension:mas": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas", - "build:macos-extension:masdev": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas-dev", + "build:macos-extension:mac": "./desktop_native/autofill_provider/build.sh && node scripts/build-macos-extension.js mac", + "build:macos-extension:mas": "./desktop_native/autofill_provider/build.sh && node scripts/build-macos-extension.js mas", + "build:macos-extension:masdev": "./desktop_native/autofill_provider/build.sh && node scripts/build-macos-extension.js mas-dev", "build:main": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main", - "build:main:dev": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main", + "build:main:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main", "build:main:watch": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main --watch", "build:renderer": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name renderer", "build:renderer:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer", @@ -46,10 +46,10 @@ "pack:mac:with-extension": "npm run clean:dist && npm run build:macos-extension:mac && electron-builder --mac --universal -p never", "pack:mac:arm64": "npm run clean:dist && electron-builder --mac --arm64 -p never", "pack:mac:mas": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never", - "pack:mac:masdev": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never", + "pack:mac:masdev": "npm run clean:dist && electron-builder --mac mas-dev --universal -p never -c.mac.identity=null -c.mas.identity=$CSC_NAME -c.mas.provisioningProfile=bitwarden_desktop_developer_id.provisionprofile -c.mas.entitlements=resources/entitlements.mas.autofill-enabled.plist", "pack:local:mac": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never -c.mac.provisioningProfile=\"\" -c.mas.provisioningProfile=\"\"", - "pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", - "pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", + "pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never", + "pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never", "pack:win:ci": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never", "dist:dir": "npm run build && npm run pack:dir", "dist:lin": "npm run build && npm run pack:lin", @@ -62,7 +62,7 @@ "publish:lin": "npm run build && npm run clean:dist && electron-builder --linux --x64 -p always", "publish:mac": "npm run build && npm run clean:dist && electron-builder --mac -p always", "publish:mac:mas": "npm run dist:mac:mas && npm run upload:mas", - "publish:win": "npm run build && npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p always -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", + "publish:win": "npm run build && npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p always", "publish:win:dev": "npm run build:dev && npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p always", "upload:mas": "xcrun altool --upload-app --type osx --file \"$(find ./dist/mas-universal/Bitwarden*.pkg)\" --apiKey $APP_STORE_CONNECT_AUTH_KEY --apiIssuer $APP_STORE_CONNECT_TEAM_ISSUER", "test": "jest", diff --git a/apps/desktop/resources/entitlements.mas.autofill-enabled.plist b/apps/desktop/resources/entitlements.mas.autofill-enabled.plist new file mode 100644 index 00000000000..f25780e5c12 --- /dev/null +++ b/apps/desktop/resources/entitlements.mas.autofill-enabled.plist @@ -0,0 +1,42 @@ + + + + + com.apple.application-identifier + LTZ2PFU5D6.com.bitwarden.desktop + com.apple.developer.team-identifier + LTZ2PFU5D6 + com.apple.security.app-sandbox + + com.apple.security.application-groups + + LTZ2PFU5D6.com.bitwarden.desktop + + com.apple.security.cs.allow-jit + + com.apple.security.device.usb + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.temporary-exception.files.home-relative-path.read-write + + /Library/Application Support/Mozilla/NativeMessagingHosts/ + /Library/Application Support/Google/Chrome/NativeMessagingHosts/ + /Library/Application Support/Google/Chrome Beta/NativeMessagingHosts/ + /Library/Application Support/Google/Chrome Dev/NativeMessagingHosts/ + /Library/Application Support/Google/Chrome Canary/NativeMessagingHosts/ + /Library/Application Support/Chromium/NativeMessagingHosts/ + /Library/Application Support/Microsoft Edge/NativeMessagingHosts/ + /Library/Application Support/Microsoft Edge Beta/NativeMessagingHosts/ + /Library/Application Support/Microsoft Edge Dev/NativeMessagingHosts/ + /Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/ + /Library/Application Support/Vivaldi/NativeMessagingHosts/ + /Library/Application Support/Zen/NativeMessagingHosts/ + /Library/Application Support/net.imput.helium + + com.apple.developer.authentication-services.autofill-credential-provider + + + diff --git a/apps/desktop/resources/entitlements.mas.plist b/apps/desktop/resources/entitlements.mas.plist index 226e9827e37..9760af69e8b 100644 --- a/apps/desktop/resources/entitlements.mas.plist +++ b/apps/desktop/resources/entitlements.mas.plist @@ -34,7 +34,7 @@ /Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/ /Library/Application Support/Vivaldi/NativeMessagingHosts/ /Library/Application Support/Zen/NativeMessagingHosts/ - /Library/Application Support/net.imput.helium + /Library/Application Support/net.imput.helium/NativeMessagingHosts/ diff --git a/apps/desktop/resources/linux-wrapper.sh b/apps/desktop/resources/linux-wrapper.sh index 3c5d16c3a3d..e1cb69274d7 100644 --- a/apps/desktop/resources/linux-wrapper.sh +++ b/apps/desktop/resources/linux-wrapper.sh @@ -12,9 +12,13 @@ if [ -e "/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" ]; then export LD_PRELOAD="/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" fi +# A bug in Electron 39 (which now enables Wayland by default) causes a crash on +# systems using Wayland with hardware acceleration. Platform decided to +# configure Electron to use X11 (with an opt-out) until the upstream bug is +# fixed. The follow-up task is https://bitwarden.atlassian.net/browse/PM-31080. PARAMS="--enable-features=UseOzonePlatform,WaylandWindowDecorations --ozone-platform-hint=auto" -if [ "$USE_X11" = "true" ]; then - PARAMS="" +if [ "$USE_X11" != "false" ]; then + PARAMS="--ozone-platform=x11" fi $APP_PATH/bitwarden-app $PARAMS "$@" diff --git a/apps/desktop/scripts/after-pack.js b/apps/desktop/scripts/after-pack.js index 5fc42f31ac3..091a9ce951e 100644 --- a/apps/desktop/scripts/after-pack.js +++ b/apps/desktop/scripts/after-pack.js @@ -6,9 +6,12 @@ const path = require("path"); const { flipFuses, FuseVersion, FuseV1Options } = require("@electron/fuses"); const builder = require("electron-builder"); const fse = require("fs-extra"); - exports.default = run; +/** + * + * @param {builder.AfterPackContext} context + */ async function run(context) { console.log("## After pack"); // console.log(context); @@ -42,7 +45,7 @@ async function run(context) { if (process.env.GITHUB_ACTIONS === "true") { if (is_mas) { id = is_mas_dev - ? "588E3F1724AE018EBA762E42279DAE85B313E3ED" + ? "A579B6AE496B360642D05B8AB1B650C1B143B770" : "3rd Party Mac Developer Application: Bitwarden Inc"; } else { id = "Developer ID Application: 8bit Solutions LLC"; diff --git a/apps/desktop/scripts/after-sign.js b/apps/desktop/scripts/after-sign.js index 4275ec7d051..0e0e22fc24a 100644 --- a/apps/desktop/scripts/after-sign.js +++ b/apps/desktop/scripts/after-sign.js @@ -16,7 +16,9 @@ async function run(context) { const appPath = `${context.appOutDir}/${appName}.app`; const macBuild = context.electronPlatformName === "darwin"; const copySafariExtension = ["darwin", "mas"].includes(context.electronPlatformName); - const copyAutofillExtension = ["darwin"].includes(context.electronPlatformName); // Disabled for mas builds + const isMasDevBuild = + context.electronPlatformName === "mas" && context.targets.at(0)?.name === "mas-dev"; + const copyAutofillExtension = ["darwin"].includes(context.electronPlatformName) || isMasDevBuild; let shouldResign = false; @@ -31,7 +33,6 @@ async function run(context) { fse.mkdirSync(path.join(appPath, "Contents/PlugIns")); } fse.copySync(extensionPath, path.join(appPath, "Contents/PlugIns/autofill-extension.appex")); - shouldResign = true; } } diff --git a/apps/desktop/scripts/appx-cross-build.ps1 b/apps/desktop/scripts/appx-cross-build.ps1 new file mode 100755 index 00000000000..ef2ab09104c --- /dev/null +++ b/apps/desktop/scripts/appx-cross-build.ps1 @@ -0,0 +1,227 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS +Script to build, package and sign the Bitwarden desktop client as a Windows Appx +package. + +.DESCRIPTION +This script provides cross-platform support for packaging and signing the +Bitwarden desktop client as a Windows Appx package. + +Currently, only macOS -> Windows Appx is supported, but Linux -> Windows Appx +could be added in the future by providing Linux binaries for the msix-packaging +project. + +.NOTES +The reason this script exists is because electron-builder does not currently +support cross-platform Appx packaging without proprietary tools (Parallels +Windows VM). This script uses third-party tools (makemsix from msix-packaging +and osslsigncode) to package and sign the Appx. + +The signing certificate must have the same subject as the publisher name. This +can be generated on the Windows target using PowerShell 5.1 and copied to the +host, or directly on the host with OpenSSL. + +Using Windows PowerShell 5.1: +```powershell +$publisher = "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US" +$certificate = New-SelfSignedCertificate -Type Custom -KeyUsage DigitalSignature -CertStoreLocation "Cert:\CurrentUser\My" -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") -Subject $publisher -FriendlyName "Bitwarden Developer Signing Certificate" +$password = Read-Host -AsSecureString +Export-PfxCertificate -cert "Cert:\CurrentUser\My\${$certificate.Thumbprint}" -FilePath "C:\path/to/pfx" -Password $password +``` + +Using OpenSSL: +```sh +subject="jurisdictionCountryName=US/jurisdictionStateOrProvinceName=Delaware/businessCategory=Private Organization/serialNumber=7654941, C=US, ST=California, L=Santa Barbara, O=Bitwarden Inc., CN=Bitwarden Inc." +keyfile="/tmp/mysigning.rsa.pem" +certfile="/tmp/mysigning.cert.pem" +p12file="/tmp/mysigning.p12" +openssl req -x509 -keyout "$keyfile" -out "$certfile" -subj "$subject" \ + -newkey rsa:2048 -days 3650 -nodes \ + -addext 'keyUsage=critical,digitalSignature' \ + -addext 'extendedKeyUsage=critical,codeSigning' \ + -addext 'basicConstraints=critical,CA:FALSE' +openssl pkcs12 -inkey "$keyfile" -in "$certfile" -export -out "$p12file" +rm $keyfile +``` + +.EXAMPLE +./scripts/cross-build.ps1 -Architecture arm64 -CertificatePath ~/Development/code-signing.pfx -CertificatePassword (Read-Host -AsSecureString) -Release -Beta + +Reads the signing certificate password from user input, then builds, packages +and signs the Appx. + +Alternatively, you can specify the CERTIFICATE_PASSWORD environment variable. +#> +param( + [Parameter(Mandatory=$true)] + [ValidateSet("X64", "ARM64")]$Architecture, + [string] + # Path to PKCS12 certificate file. If not specified, the Appx will not be signed. + $CertificatePath, + [SecureString] + # Password for PKCS12 certificate. Alternatively, may be specified in + # CERTIFICATE_PASSWORD environment variable. If not specified, the Appx will + # not be signed. + $CertificatePassword, + [Switch] + # Whether to build the Beta version of the app. + $Beta=$false, + [Switch] + # Whether to build in release mode. + $Release=$false +) +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true +$startTime = Get-Date +$originalLocation = Get-Location +if (!(Get-Command makemsix -ErrorAction SilentlyContinue)) { + Write-Error "The `makemsix` tool from the msix-packaging project is required to construct Appx package." + Write-Error "On macOS, you can install with Homebrew:" + Write-Error " brew install iinuwa/msix-packaging-tap/msix-packaging" + Exit 1 +} + +if (!(Get-Command osslsigncode -ErrorAction SilentlyContinue)) { + Write-Error "The `osslsigncode` tool is required to sign the Appx package." + Write-Error "On macOS, you can install with Homebrew:" + Write-Error " brew install osslsigncode" + Exit 1 +} + +if (!(Get-Command cargo-xwin -ErrorAction SilentlyContinue)) { + Write-Error "The `cargo-xwin` tool is required to cross-compile Windows native code." + Write-Error "You can install with cargo:" + Write-Error " cargo install --version 0.20.2 --locked cargo-xwin" + Exit 1 +} + +try { + +# Resolve certificate file before we change directories. +$CertificateFile = Get-Item $CertificatePath -ErrorAction SilentlyContinue + +cd $PSScriptRoot/.. + +if ($Beta) { + $electronConfigFile = Get-Item "./electron-builder.beta.json" +} +else { + $electronConfigFile = Get-Item "./electron-builder.json" +} + +$builderConfig = Get-Content $electronConfigFile | ConvertFrom-Json +$packageConfig = Get-Content package.json | ConvertFrom-Json +$manifestTemplate = Get-Content $builderConfig.appx.customManifestPath + +$srcDir = Get-Location +$assetsDir = Get-Item $builderConfig.directories.buildResources +$buildDir = Get-Item $builderConfig.directories.app +$outDir = Join-Path (Get-Location) ($builderConfig.directories.output ?? "dist") + +if ($Release) { + $buildConfiguration = "--release" +} +$arch = "$Architecture".ToLower() +$ext = "appx" +$version = Get-Date -Format "yyyy.M.d.1HHmm" +$productName = $builderConfig.productName +$artifactName = "${productName}-$($packageConfig.version)-${arch}.$ext" + +Write-Host "Building native code" +$rustTarget = switch ($arch) { + x64 { "x86_64-pc-windows-msvc" } + arm64 { "aarch64-pc-windows-msvc" } + default { + Write-Error "Unsupported architecture: $Architecture. Supported architectures are x64 and arm64" + Exit(1) + } +} +npm run build-native -- cross-platform $buildConfiguration "--target=$rustTarget" + +Write-Host "Building Javascript code" +if ($Release) { + npm run build +} +else { + npm run build:dev +} + +Write-Host "Cleaning output folder" +Remove-Item -Recurse -Force $outDir -ErrorAction Ignore + +Write-Host "Packaging Electron executable" +& npx electron-builder --config $electronConfigFile --publish never --dir --win --$arch + +cd $outDir +New-Item -Type Directory (Join-Path $outDir "appx") + +Write-Host "Building Appx directory structure" +$appxDir = (Join-Path $outDir appx/app) +if ($arch -eq "x64") { + Move-Item (Join-Path $outDir "win-unpacked") $appxDir +} +else { + Move-Item (Join-Path $outDir "win-${arch}-unpacked") $appxDir +} + +Write-Host "Copying Assets" +New-Item -Type Directory (Join-Path $outDir appx/assets) +Copy-Item $srcDir/resources/appx/* $outDir/appx/assets/ + +Write-Host "Building Appx manifest" +$translationMap = @{ + 'arch' = $arch + 'applicationId' = $builderConfig.appx.applicationId + 'displayName' = $productName + 'executable' = "app\${productName}.exe" + 'identityName' = $builderConfig.appx.identityName + 'publisher' = $builderConfig.appx.publisher + 'publisherDisplayName' = $builderConfig.appx.publisherDisplayName + 'version' = $version +} + +$manifest = $manifestTemplate +$translationMap.Keys | ForEach-Object { + $manifest = $manifest.Replace("`${$_}", $translationMap[$_]) +} +$manifest | Out-File appx/AppxManifest.xml +$unsignedArtifactpath = [System.IO.Path]::GetFileNameWithoutExtension($artifactName) + "-unsigned.$ext" +Write-Host "Creating unsigned Appx" +makemsix pack -d appx -p $unsignedArtifactpath + +$outfile = Join-Path $outDir $unsignedArtifactPath +if ($null -eq $CertificatePath) { + Write-Warning "No Certificate specified. Not signing Appx." +} +elseif ($null -eq $CertificatePassword -and $null -eq $env:CERTIFICATE_PASSWORD) { + Write-Warning "No certificate password specified in CertificatePassword argument nor CERTIFICATE_PASSWORD environment variable. Not signing Appx." +} +else { + $cert = $CertificateFile + $pw = $null + if ($null -ne $CertificatePassword) { + $pw = ConvertFrom-SecureString -SecureString $CertificatePassword -AsPlainText + } else { + $pw = $env:CERTIFICATE_PASSWORD + } + $unsigned = $outfile + $outfile = (Join-Path $outDir $artifactName) + Write-Host "Signing $artifactName with $cert" + osslsigncode sign ` + -pkcs12 "$cert" ` + -pass "$pw" ` + -in $unsigned ` + -out $outfile + Remove-Item $unsigned +} + +$endTime = Get-Date +$elapsed = $endTime - $startTime +Write-Host "Successfully packaged $(Get-Item $outfile)" +Write-Host ("Finished at $($endTime.ToString('HH:mm:ss')) in $($elapsed.ToString('mm')) minutes and $($elapsed.ToString('ss')).$($elapsed.ToString('fff')) seconds") +} +finally { + Set-Location -Path $originalLocation +} diff --git a/apps/desktop/scripts/before-pack.js b/apps/desktop/scripts/before-pack.js new file mode 100644 index 00000000000..ca9bf924b2d --- /dev/null +++ b/apps/desktop/scripts/before-pack.js @@ -0,0 +1,31 @@ +/* eslint-disable no-console */ +/** @import { BeforePackContext } from 'app-builder-lib' */ +exports.default = run; + +/** + * @param {BeforePackContext} context + */ +async function run(context) { + console.log("## before pack"); + console.log("Stripping .node files that don't belong to this platform..."); + removeExtraNodeFiles(context); +} + +/** + * Removes Node files for platforms besides the current platform being packaged. + * + * @param {BeforePackContext} context + */ +function removeExtraNodeFiles(context) { + // When doing cross-platform builds, due to electron-builder limitiations, + // .node files for other platforms may be generated and unpacked, so we + // remove them manually here before signing and distributing. + const packagerPlatform = context.packager.platform.nodeName; + const platforms = ["darwin", "linux", "win32"]; + const fileFilter = context.packager.info._configuration.files[0].filter; + for (const platform of platforms) { + if (platform != packagerPlatform) { + fileFilter.push(`!node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-*.node`); + } + } +} diff --git a/apps/desktop/sign.js b/apps/desktop/sign.js index 6a42666c46f..a01388c703c 100644 --- a/apps/desktop/sign.js +++ b/apps/desktop/sign.js @@ -1,22 +1,60 @@ /* eslint-disable @typescript-eslint/no-require-imports, no-console */ +const child_process = require("child_process"); exports.default = async function (configuration) { - if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && configuration.path.slice(-4) == ".exe") { + const ext = configuration.path.split(".").at(-1); + if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && ["exe"].includes(ext)) { console.log(`[*] Signing file: ${configuration.path}`); - require("child_process").execSync( - `azuresigntool sign -v ` + - `-kvu ${process.env.SIGNING_VAULT_URL} ` + - `-kvi ${process.env.SIGNING_CLIENT_ID} ` + - `-kvt ${process.env.SIGNING_TENANT_ID} ` + - `-kvs ${process.env.SIGNING_CLIENT_SECRET} ` + - `-kvc ${process.env.SIGNING_CERT_NAME} ` + - `-fd ${configuration.hash} ` + - `-du ${configuration.site} ` + - `-tr http://timestamp.digicert.com ` + - `"${configuration.path}"`, + child_process.execFileSync( + "azuresigntool", + // prettier-ignore + [ + "sign", + "-v", + "-kvu", process.env.SIGNING_VAULT_URL, + "-kvi", process.env.SIGNING_CLIENT_ID, + "-kvt", process.env.SIGNING_TENANT_ID, + "-kvs", process.env.SIGNING_CLIENT_SECRET, + "-kvc", process.env.SIGNING_CERT_NAME, + "-fd", configuration.hash, + "-du", configuration.site, + "-tr", "http://timestamp.digicert.com", + configuration.path, + ], { stdio: "inherit", }, ); + } else if (process.env.ELECTRON_BUILDER_SIGN_CERT && ["exe", "appx"].includes(ext)) { + console.log(`[*] Signing file: ${configuration.path}`); + if (process.platform !== "win32") { + console.warn( + "Signing Windows executables on non-Windows platforms is not supported. Not signing.", + ); + return; + } + const certFile = process.env.ELECTRON_BUILDER_SIGN_CERT; + const certPw = process.env.ELECTRON_BUILDER_SIGN_CERT_PW; + if (!certPw) { + throw new Error( + "The certificate file password must be set in ELECTRON_BUILDER_SIGN_CERT_PW in order to sign files.", + ); + } + try { + child_process.execFileSync( + "signtool.exe", + ["sign", "/fd", "SHA256", "/a", "/f", certFile, "/p", certPw, configuration.path], + { + stdio: "inherit", + }, + ); + console.info(`Signed ${configuration.path} successfully.`); + } catch (error) { + throw new Error( + `Failed to sign ${configuration.path}: ${error.message}\n` + + `Check that ELECTRON_BUILDER_SIGN_CERT points to a valid PKCS12 file ` + + `and ELECTRON_BUILDER_SIGN_CERT_PW is correct.`, + ); + } } }; diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 3952335af48..f2e828b95ce 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -385,7 +385,7 @@ export class SettingsComponent implements OnInit, OnDestroy { this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(activeAccount.id), ), pin: this.userHasPinSet, - biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(), + biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(activeAccount.id), requireMasterPasswordOnAppRestart: !(await this.biometricsService.hasPersistentKey( activeAccount.id, )), diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index f75f6ccdc20..e9b6dfdc9e5 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -114,6 +114,8 @@ const routes: Routes = [ authGuard, canAccessFeature(FeatureFlag.DesktopUiMigrationMilestone1, false, "new-vault", false), ], + // Needed to ensure feature flag changes are picked up on account switching + runGuardsAndResolvers: "always", }, { path: "send", diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index d1919c77bb5..fdd5012f5ee 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -273,7 +273,7 @@ export class AppComponent implements OnInit, OnDestroy { this.loading = false; break; case "lockVault": - await this.lockService.lock(message.userId); + await this.lockService.lock(message.userId ?? this.activeUserId); break; case "lockAllVaults": { await this.lockService.lockAll(); @@ -492,9 +492,8 @@ export class AppComponent implements OnInit, OnDestroy { this.loading = true; await this.syncService.fullSync(false); this.loading = false; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["vault"]); + // Force reload to ensure route guards are activated + await this.router.navigate(["vault"], { onSameUrlNavigation: "reload" }); } this.messagingService.send("finishSwitchAccount"); break; diff --git a/apps/desktop/src/app/layout/desktop-layout.component.html b/apps/desktop/src/app/layout/desktop-layout.component.html index 7e101ae1b6e..cb969f573fc 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.html +++ b/apps/desktop/src/app/layout/desktop-layout.component.html @@ -2,7 +2,7 @@ - + diff --git a/apps/desktop/src/app/layout/desktop-layout.component.spec.ts b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts index 2fb49e723ef..c838f47a06c 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.spec.ts +++ b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts @@ -8,6 +8,7 @@ import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; import { DialogService, NavigationModule } from "@bitwarden/components"; import { GlobalStateProvider } from "@bitwarden/state"; +import { VaultFilterComponent } from "../../vault/app/vault-v3/vault-filter/vault-filter.component"; import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component"; import { DesktopLayoutComponent } from "./desktop-layout.component"; @@ -20,6 +21,13 @@ import { DesktopLayoutComponent } from "./desktop-layout.component"; }) class MockSendFiltersNavComponent {} +@Component({ + selector: "app-vault-filter", + template: "", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockVaultFiltersNavComponent {} + Object.defineProperty(window, "matchMedia", { writable: true, value: jest.fn().mockImplementation((query) => ({ @@ -59,8 +67,8 @@ describe("DesktopLayoutComponent", () => { ], }) .overrideComponent(DesktopLayoutComponent, { - remove: { imports: [SendFiltersNavComponent] }, - add: { imports: [MockSendFiltersNavComponent] }, + remove: { imports: [SendFiltersNavComponent, VaultFilterComponent] }, + add: { imports: [MockSendFiltersNavComponent, MockVaultFiltersNavComponent] }, }) .compileComponents(); @@ -93,4 +101,11 @@ describe("DesktopLayoutComponent", () => { expect(sendFiltersNav).toBeTruthy(); }); + + it("renders vault filters navigation component", () => { + const compiled = fixture.nativeElement; + const vaultFiltersNav = compiled.querySelector("app-vault-filter"); + + expect(vaultFiltersNav).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/app/layout/desktop-layout.component.ts b/apps/desktop/src/app/layout/desktop-layout.component.ts index 85339bc06c9..8d6ced2eb7d 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.ts +++ b/apps/desktop/src/app/layout/desktop-layout.component.ts @@ -5,6 +5,7 @@ import { PasswordManagerLogo } from "@bitwarden/assets/svg"; import { DialogService, LayoutComponent, NavigationModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +import { VaultFilterComponent } from "../../vault/app/vault-v3/vault-filter/vault-filter.component"; import { ExportDesktopComponent } from "../tools/export/export-desktop.component"; import { CredentialGeneratorComponent } from "../tools/generator/credential-generator.component"; import { ImportDesktopComponent } from "../tools/import/import-desktop.component"; @@ -22,6 +23,7 @@ import { DesktopSideNavComponent } from "./desktop-side-nav.component"; LayoutComponent, NavigationModule, DesktopSideNavComponent, + VaultFilterComponent, SendFiltersNavComponent, ], templateUrl: "./desktop-layout.component.html", diff --git a/apps/desktop/src/app/layout/header/desktop-header.component.html b/apps/desktop/src/app/layout/header/desktop-header.component.html index efee5e21d9b..ae578312535 100644 --- a/apps/desktop/src/app/layout/header/desktop-header.component.html +++ b/apps/desktop/src/app/layout/header/desktop-header.component.html @@ -1,21 +1,19 @@ -
- - - - + + + + - + - - - + + + - - - + + + - - - - -
+ + + + diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index 17115825bf6..a6fd40cb998 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -8,7 +8,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { DefaultVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; @@ -54,7 +53,6 @@ export class InitService { private autotypeService: DesktopAutotypeService, private sdkLoadService: SdkLoadService, private biometricMessageHandlerService: BiometricMessageHandlerService, - private configService: ConfigService, @Inject(DOCUMENT) private document: Document, private readonly migrationRunner: MigrationRunner, ) {} @@ -65,7 +63,6 @@ export class InitService { await this.sshAgentService.init(); this.nativeMessagingService.init(); await this.migrationRunner.waitForCompletion(); // Desktop will run migrations in the main process - this.encryptService.init(this.configService); const accounts = await firstValueFrom(this.accountService.accounts$); const setUserKeyInMemoryPromises = []; diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 752c09e2e92..c33ce73630e 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -1,10 +1,10 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { APP_INITIALIZER, NgModule } from "@angular/core"; -import { Router } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { Subject, merge } from "rxjs"; -import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { CollectionService, OrganizationUserApiService } from "@bitwarden/admin-console/common"; import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction"; import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { @@ -33,9 +33,11 @@ import { InternalUserDecryptionOptionsServiceAbstraction, LoginEmailService, SsoUrlService, + UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService as PolicyServiceAbstraction, InternalPolicyService, @@ -52,6 +54,7 @@ import { import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { 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 { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; @@ -88,6 +91,7 @@ import { PlatformUtilsService, PlatformUtilsService as PlatformUtilsServiceAbstraction, } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service"; import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; @@ -107,6 +111,7 @@ import { SystemService } from "@bitwarden/common/platform/services/system.servic import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state"; import { SyncService } from "@bitwarden/common/platform/sync"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { DialogService, ToastService } from "@bitwarden/components"; import { GeneratorServicesModule } from "@bitwarden/generator-components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; @@ -120,9 +125,19 @@ import { import { LockComponentService, SessionTimeoutSettingsComponentService, + WebAuthnPrfUnlockService, + DefaultWebAuthnPrfUnlockService, } from "@bitwarden/key-management-ui"; import { SerializedMemoryStorageService } from "@bitwarden/storage-core"; -import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; +import { + DefaultSshImportPromptService, + SshImportPromptService, + VaultFilterServiceAbstraction, + VaultFilterService, + RoutedVaultFilterService, + RoutedVaultFilterBridgeService, + VAULT_FILTER_BASE_ROUTE, +} from "@bitwarden/vault"; import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service"; import { DesktopAuthRequestAnsweringService } from "../../auth/services/auth-request-answering/desktop-auth-request-answering.service"; @@ -342,6 +357,7 @@ const safeProviders: SafeProvider[] = [ BiometricStateService, KdfConfigService, DesktopBiometricsService, + AccountCryptographicStateService, ], }), safeProvider({ @@ -402,6 +418,21 @@ const safeProviders: SafeProvider[] = [ useClass: DesktopLockComponentService, deps: [], }), + safeProvider({ + provide: WebAuthnPrfUnlockService, + useClass: DefaultWebAuthnPrfUnlockService, + deps: [ + WebAuthnLoginPrfKeyServiceAbstraction, + KeyServiceAbstraction, + UserDecryptionOptionsServiceAbstraction, + EncryptService, + EnvironmentService, + PlatformUtilsServiceAbstraction, + WINDOW, + LogServiceAbstraction, + ConfigService, + ], + }), safeProvider({ provide: CLIENT_TYPE, useValue: ClientType.Desktop, @@ -422,6 +453,7 @@ const safeProviders: SafeProvider[] = [ InternalUserDecryptionOptionsServiceAbstraction, MessagingServiceAbstraction, AccountCryptographicStateService, + RegisterSdkService, ], }), safeProvider({ @@ -508,6 +540,34 @@ const safeProviders: SafeProvider[] = [ useClass: SessionTimeoutSettingsComponentService, deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyServiceAbstraction], }), + safeProvider({ + provide: VaultFilterServiceAbstraction, + useClass: VaultFilterService, + deps: [ + OrganizationService, + FolderService, + CipherServiceAbstraction, + PolicyServiceAbstraction, + I18nServiceAbstraction, + StateProvider, + CollectionService, + AccountServiceAbstraction, + ], + }), + safeProvider({ + provide: VAULT_FILTER_BASE_ROUTE, + useValue: "/new-vault", + }), + safeProvider({ + provide: RoutedVaultFilterService, + useClass: RoutedVaultFilterService, + deps: [ActivatedRoute], + }), + safeProvider({ + provide: RoutedVaultFilterBridgeService, + useClass: RoutedVaultFilterBridgeService, + deps: [Router, RoutedVaultFilterService, VaultFilterServiceAbstraction], + }), safeProvider({ provide: AuthRequestAnsweringService, useClass: DesktopAuthRequestAnsweringService, diff --git a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts index 6b29a464e2c..430870a247b 100644 --- a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts +++ b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts @@ -1,8 +1,10 @@ -import { MockProxy, mock } from "jest-mock-extended"; +import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation"; import { + InitializeJitPasswordCredentials, SetInitialPasswordCredentials, SetInitialPasswordService, SetInitialPasswordUserType, @@ -19,12 +21,14 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types"; import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "@bitwarden/common/types/csprng"; -import { UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management"; @@ -45,6 +49,7 @@ describe("DesktopSetInitialPasswordService", () => { let userDecryptionOptionsService: MockProxy; let messagingService: MockProxy; let accountCryptographicStateService: MockProxy; + let registerSdkService: MockProxy; beforeEach(() => { apiService = mock(); @@ -59,6 +64,7 @@ describe("DesktopSetInitialPasswordService", () => { userDecryptionOptionsService = mock(); messagingService = mock(); accountCryptographicStateService = mock(); + registerSdkService = mock(); sut = new DesktopSetInitialPasswordService( apiService, @@ -73,6 +79,7 @@ describe("DesktopSetInitialPasswordService", () => { userDecryptionOptionsService, messagingService, accountCryptographicStateService, + registerSdkService, ); }); @@ -80,6 +87,10 @@ describe("DesktopSetInitialPasswordService", () => { 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; @@ -109,6 +120,8 @@ describe("DesktopSetInitialPasswordService", () => { 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; @@ -179,4 +192,36 @@ describe("DesktopSetInitialPasswordService", () => { }); }); }); + + describe("initializePasswordJitPasswordUserV2Encryption(...)", () => { + it("should send a 'redrawMenu' message", async () => { + // Arrange + const credentials: InitializeJitPasswordCredentials = { + newPasswordHint: "newPasswordHint", + orgSsoIdentifier: "orgSsoIdentifier", + orgId: "orgId" as OrganizationId, + resetPasswordAutoEnroll: false, + newPassword: "newPassword123!", + salt: "user@example.com" as MasterPasswordSalt, + }; + const userId = "userId" as UserId; + + const superSpy = jest + .spyOn( + DefaultSetInitialPasswordService.prototype, + "initializePasswordJitPasswordUserV2Encryption", + ) + .mockResolvedValue(undefined); + + // Act + await sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId); + + // Assert + expect(superSpy).toHaveBeenCalledWith(credentials, userId); + expect(messagingService.send).toHaveBeenCalledTimes(1); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + + superSpy.mockRestore(); + }); + }); }); diff --git a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts index cedfa3fe589..3b1562075f9 100644 --- a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts +++ b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts @@ -1,6 +1,7 @@ import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation"; import { + InitializeJitPasswordCredentials, SetInitialPasswordCredentials, SetInitialPasswordService, SetInitialPasswordUserType, @@ -14,6 +15,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service"; import { UserId } from "@bitwarden/common/types/guid"; import { KdfConfigService, KeyService } from "@bitwarden/key-management"; @@ -34,6 +36,7 @@ export class DesktopSetInitialPasswordService protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, private messagingService: MessagingService, protected accountCryptographicStateService: AccountCryptographicStateService, + protected registerSdkService: RegisterSdkService, ) { super( apiService, @@ -47,9 +50,13 @@ export class DesktopSetInitialPasswordService organizationUserApiService, userDecryptionOptionsService, accountCryptographicStateService, + registerSdkService, ); } + /** + * @deprecated To be removed in PM-28143 + */ override async setInitialPassword( credentials: SetInitialPasswordCredentials, userType: SetInitialPasswordUserType, @@ -59,4 +66,13 @@ export class DesktopSetInitialPasswordService this.messagingService.send("redrawMenu"); } + + override async initializePasswordJitPasswordUserV2Encryption( + credentials: InitializeJitPasswordCredentials, + userId: UserId, + ): Promise { + await super.initializePasswordJitPasswordUserV2Encryption(credentials, userId); + + this.messagingService.send("redrawMenu"); + } } diff --git a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts index ab881e5b57b..f22b94974d1 100644 --- a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts +++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts @@ -6,7 +6,7 @@ import { BehaviorSubject } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { NavigationModule } from "@bitwarden/components"; import { SendListFiltersService } from "@bitwarden/send-ui"; import { GlobalStateProvider } from "@bitwarden/state"; diff --git a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts index 28004f475e5..0dfdc1ee7c5 100644 --- a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts +++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts @@ -4,7 +4,7 @@ import { toSignal } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; import { filter, map, startWith } from "rxjs"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { NavigationModule } from "@bitwarden/components"; import { SendListFiltersService } from "@bitwarden/send-ui"; import { I18nPipe } from "@bitwarden/ui-common"; diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.html b/apps/desktop/src/app/tools/send-v2/send-v2.component.html index 05c1332f1e7..53dee854012 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.html +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.html @@ -1,55 +1,93 @@ -
-
+@if (useDrawerEditMode()) { +
+ @if (!disableSend()) { } -
- - - - -
+ + + +
- - - @if (action() == "add" || action() == "edit") { - - } - - - @if (!action()) { - -
-
- @if (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 d4342d24cd5..169c5d920ff 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 @@ -13,8 +13,8 @@ } @else { - @if (isRiskInsightsActivityTabFeatureEnabled && !(dataService.hasReportData$ | async)) { - + @if (!(dataService.hasReportData$ | async)) { +
@if (!hasCiphers) { @@ -44,10 +44,11 @@
-
- {{ "reviewAtRiskPasswords" | i18n }} -
- @let isRunningReport = dataService.isGeneratingReport$ | async; + @if (appsCount > 0) { +
+ {{ "reviewAccessIntelligence" | i18n }} +
+ }
@@ -62,7 +63,6 @@ } - - -
- @if (isRiskInsightsActivityTabFeatureEnabled) { - - + + + + @if (milestone11Enabled) { + + + + } @else { + + + + + + + {{ + "criticalApplicationsWithCount" + | i18n + : (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0 + }} + + } - - - - - - - {{ - "criticalApplicationsWithCount" - | i18n: (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0 - }} - - -
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 18afdf8c8ab..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 @@ -40,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"; @@ -55,6 +56,7 @@ type ProgressStep = ReportProgress | null; templateUrl: "./risk-insights.component.html", imports: [ AllApplicationsComponent, + ApplicationsComponent, AsyncActionsModule, ButtonModule, CommonModule, @@ -79,9 +81,9 @@ type ProgressStep = ReportProgress | null; export class RiskInsightsComponent implements OnInit, OnDestroy { private destroyRef = inject(DestroyRef); protected ReportStatusEnum = ReportStatus; + protected milestone11Enabled: boolean = false; - tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllApps; - isRiskInsightsActivityTabFeatureEnabled: boolean = false; + tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllActivity; appsCount: number = 0; @@ -112,27 +114,23 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { constructor( private route: ActivatedRoute, private router: Router, - private configService: ConfigService, protected dataService: RiskInsightsDataService, protected i18nService: I18nService, 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.AllApps; + this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllActivity; }); - - this.configService - .getFeatureFlag$(FeatureFlag.PM22887_RiskInsightsActivityTab) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((isEnabled) => { - this.isRiskInsightsActivityTabFeatureEnabled = isEnabled; - this.tabIndex = 0; // default to first tab - }); } 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..05dec048328 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html @@ -0,0 +1,139 @@ + + + + + + + + {{ "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 }} + +
+
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.spec.ts new file mode 100644 index 00000000000..42dcf4cfe28 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.spec.ts @@ -0,0 +1,181 @@ +import { DebugElement } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; + +import { ApplicationHealthReportDetailEnriched } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { TableDataSource } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { AppTableRowScrollableM11Component } from "./app-table-row-scrollable-m11.component"; + +// Mock ResizeObserver +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +const mockTableData: ApplicationHealthReportDetailEnriched[] = [ + { + applicationName: "google.com", + passwordCount: 5, + atRiskPasswordCount: 2, + atRiskCipherIds: ["cipher-1" as any, "cipher-2" as any], + memberCount: 3, + atRiskMemberCount: 1, + memberDetails: [ + { + userGuid: "user-1", + userName: "John Doe", + email: "john@google.com", + cipherId: "cipher-1", + }, + ], + atRiskMemberDetails: [ + { + userGuid: "user-2", + userName: "Jane Smith", + email: "jane@google.com", + cipherId: "cipher-2", + }, + ], + cipherIds: ["cipher-1" as any, "cipher-2" as any], + isMarkedAsCritical: true, + }, + { + applicationName: "facebook.com", + passwordCount: 3, + atRiskPasswordCount: 1, + atRiskCipherIds: ["cipher-3" as any], + memberCount: 2, + atRiskMemberCount: 1, + memberDetails: [ + { + userGuid: "user-3", + userName: "Alice Johnson", + email: "alice@facebook.com", + cipherId: "cipher-3", + }, + ], + atRiskMemberDetails: [ + { + userGuid: "user-4", + userName: "Bob Wilson", + email: "bob@facebook.com", + cipherId: "cipher-4", + }, + ], + cipherIds: ["cipher-3" as any, "cipher-4" as any], + isMarkedAsCritical: false, + }, + { + applicationName: "twitter.com", + passwordCount: 4, + atRiskPasswordCount: 0, + atRiskCipherIds: [], + memberCount: 4, + atRiskMemberCount: 0, + memberDetails: [], + atRiskMemberDetails: [], + cipherIds: ["cipher-5" as any, "cipher-6" as any], + isMarkedAsCritical: false, + }, +]; + +describe("AppTableRowScrollableM11Component", () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + const mockI18nService = mock(); + mockI18nService.t.mockImplementation((key: string) => key); + + await TestBed.configureTestingModule({ + imports: [AppTableRowScrollableM11Component], + providers: [ + { provide: I18nPipe, useValue: mock() }, + { provide: I18nService, useValue: mockI18nService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AppTableRowScrollableM11Component); + + await fixture.whenStable(); + }); + + describe("select all checkbox", () => { + let selectAllCheckboxEl: DebugElement; + + beforeEach(async () => { + selectAllCheckboxEl = fixture.debugElement.query(By.css('[data-testid="selectAll"]')); + }); + + it("should check all rows in table when checked", () => { + // arrange + const selectedUrls = new Set(); + const dataSource = new TableDataSource(); + dataSource.data = mockTableData; + + fixture.componentRef.setInput("selectedUrls", selectedUrls); + fixture.componentRef.setInput("dataSource", dataSource); + fixture.detectChanges(); + + // act + selectAllCheckboxEl.nativeElement.click(); + fixture.detectChanges(); + + // assert + expect(selectedUrls.has("google.com")).toBe(true); + expect(selectedUrls.has("facebook.com")).toBe(true); + expect(selectedUrls.has("twitter.com")).toBe(true); + expect(selectedUrls.size).toBe(3); + }); + + it("should uncheck all rows in table when unchecked", () => { + // arrange + const selectedUrls = new Set(["google.com", "facebook.com", "twitter.com"]); + const dataSource = new TableDataSource(); + dataSource.data = mockTableData; + + fixture.componentRef.setInput("selectedUrls", selectedUrls); + fixture.componentRef.setInput("dataSource", dataSource); + fixture.detectChanges(); + + // act + selectAllCheckboxEl.nativeElement.click(); + fixture.detectChanges(); + + // assert + expect(selectedUrls.size).toBe(0); + }); + + it("should become checked when all rows in table are checked", () => { + // arrange + const selectedUrls = new Set(["google.com", "facebook.com", "twitter.com"]); + const dataSource = new TableDataSource(); + dataSource.data = mockTableData; + + fixture.componentRef.setInput("selectedUrls", selectedUrls); + fixture.componentRef.setInput("dataSource", dataSource); + fixture.detectChanges(); + + // assert + expect(selectAllCheckboxEl.nativeElement.checked).toBe(true); + }); + + it("should become unchecked when any row in table is unchecked", () => { + // arrange + const selectedUrls = new Set(["google.com", "facebook.com"]); + const dataSource = new TableDataSource(); + dataSource.data = mockTableData; + + fixture.componentRef.setInput("selectedUrls", selectedUrls); + fixture.componentRef.setInput("dataSource", dataSource); + fixture.detectChanges(); + + // assert + expect(selectAllCheckboxEl.nativeElement.checked).toBe(false); + }); + }); +}); 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..a23d1855ba5 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.ts @@ -0,0 +1,62 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { MenuModule, TableDataSource, TableModule, TooltipDirective } 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 + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "app-table-row-scrollable-m11", + imports: [ + CommonModule, + JslibModule, + TableModule, + SharedModule, + PipesModule, + MenuModule, + TooltipDirective, + ], + templateUrl: "./app-table-row-scrollable-m11.component.html", +}) +export class AppTableRowScrollableM11Component { + readonly dataSource = input>(); + readonly selectedUrls = input>(); + readonly openApplication = input(""); + readonly showAppAtRiskMembers = input<(applicationName: string) => void>(); + readonly checkboxChange = input<(applicationName: string, $event: Event) => void>(); + + allAppsSelected(): boolean { + const tableData = this.dataSource()?.filteredData; + const selectedUrls = this.selectedUrls(); + + if (!tableData || !selectedUrls) { + return false; + } + + return tableData.length > 0 && tableData.every((row) => selectedUrls.has(row.applicationName)); + } + + selectAllChanged(target: HTMLInputElement) { + const checked = target.checked; + + const tableData = this.dataSource()?.filteredData; + const selectedUrls = this.selectedUrls(); + + if (!tableData || !selectedUrls) { + return false; + } + + if (checked) { + tableData.forEach((row) => selectedUrls.add(row.applicationName)); + } else { + selectedUrls.clear(); + } + } +} 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/access-intelligence/shared/security-tasks.service.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts index f6fb41cdbb0..4ee784337de 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts @@ -1,7 +1,10 @@ import { TestBed } from "@angular/core/testing"; import { mock } from "jest-mock-extended"; -import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { + RiskInsightsDataService, + SecurityTasksApiService, +} from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; import { SecurityTaskType } from "@bitwarden/common/vault/tasks"; @@ -13,12 +16,14 @@ describe("AccessIntelligenceSecurityTasksService", () => { let service: AccessIntelligenceSecurityTasksService; const defaultAdminTaskServiceMock = mock(); const securityTasksApiServiceMock = mock(); + const riskInsightsDataServiceMock = mock(); beforeEach(() => { TestBed.configureTestingModule({}); service = new AccessIntelligenceSecurityTasksService( defaultAdminTaskServiceMock, securityTasksApiServiceMock, + riskInsightsDataServiceMock, ); }); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts index 688ab039ca9..65a31896341 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts @@ -1,8 +1,10 @@ -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, combineLatest, Observable } from "rxjs"; +import { map, shareReplay } from "rxjs/operators"; +import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; -import { SecurityTask, SecurityTaskType } from "@bitwarden/common/vault/tasks"; +import { SecurityTask, SecurityTaskStatus, SecurityTaskType } from "@bitwarden/common/vault/tasks"; import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-task.abstraction"; import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; @@ -14,10 +16,57 @@ export class AccessIntelligenceSecurityTasksService { private _tasksSubject$ = new BehaviorSubject([]); tasks$ = this._tasksSubject$.asObservable(); + /** + * Observable stream of unassigned critical cipher IDs. + * Returns cipher IDs from critical applications that don't have an associated task + * (either pending or completed after the report was generated). + */ + readonly unassignedCriticalCipherIds$: Observable; + constructor( private adminTaskService: DefaultAdminTaskService, private securityTasksApiService: SecurityTasksApiService, - ) {} + private riskInsightsDataService: RiskInsightsDataService, + ) { + this.unassignedCriticalCipherIds$ = combineLatest([ + this.tasks$, + this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$, + this.riskInsightsDataService.enrichedReportData$, + ]).pipe( + map(([tasks, atRiskCipherIds, reportData]) => { + // If no tasks exist, return all at-risk cipher IDs + if (tasks.length === 0) { + return atRiskCipherIds; + } + + // Get in-progress tasks (awaiting password reset) + const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending); + const inProgressTaskIds = new Set(inProgressTasks.map((task) => task.cipherId)); + + // Get completed tasks after report generation + const reportGeneratedAt = reportData?.creationDate; + const completedTasksAfterReportGeneration = reportGeneratedAt + ? tasks.filter( + (task) => + task.status === SecurityTaskStatus.Completed && + new Date(task.revisionDate) >= reportGeneratedAt, + ) + : []; + const completedTaskIds = new Set( + completedTasksAfterReportGeneration.map((task) => task.cipherId), + ); + + // Filter out cipher IDs that have a corresponding in-progress or completed task + return atRiskCipherIds.filter( + (id) => !inProgressTaskIds.has(id) && !completedTaskIds.has(id), + ); + }), + shareReplay({ + bufferSize: 1, + refCount: true, + }), + ); + } /** * Gets security task metrics for the given organization diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.html new file mode 100644 index 00000000000..6c04ea87960 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.html @@ -0,0 +1,11 @@ +@let integrationsList = integrations(); + +
+

+ {{ "deviceManagement" | i18n }} +

+

{{ "deviceManagementDesc" | i18n }}

+ +
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.ts new file mode 100644 index 00000000000..18e6dc7e362 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/device-management/device-management.component.ts @@ -0,0 +1,25 @@ +import { Component } from "@angular/core"; + +import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { IntegrationGridComponent } from "../integration-grid/integration-grid.component"; +import { FilterIntegrationsPipe } from "../integrations.pipe"; +import { OrganizationIntegrationsState } from "../organization-integrations.state"; + +// 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: "device-management", + templateUrl: "device-management.component.html", + imports: [SharedModule, IntegrationGridComponent, FilterIntegrationsPipe], +}) +export class DeviceManagementComponent { + integrations = this.state.integrations; + + constructor(private state: OrganizationIntegrationsState) {} + + get IntegrationType(): typeof IntegrationType { + return IntegrationType; + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.html new file mode 100644 index 00000000000..9a767e52c8b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.html @@ -0,0 +1,11 @@ +@let integrationsList = integrations(); + +
+

+ {{ "eventManagement" | i18n }} +

+

{{ "eventManagementDesc" | i18n }}

+ +
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.ts new file mode 100644 index 00000000000..70b17cabd35 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/event-management/event-management.component.ts @@ -0,0 +1,24 @@ +import { Component } from "@angular/core"; + +import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { IntegrationGridComponent } from "../integration-grid/integration-grid.component"; +import { FilterIntegrationsPipe } from "../integrations.pipe"; +import { OrganizationIntegrationsState } from "../organization-integrations.state"; + +// 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: "event-management", + templateUrl: "event-management.component.html", + imports: [SharedModule, IntegrationGridComponent, FilterIntegrationsPipe], +}) +export class EventManagementComponent { + integrations = this.state.integrations; + constructor(private state: OrganizationIntegrationsState) {} + + get IntegrationType(): typeof IntegrationType { + return IntegrationType; + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts index 37bd504643c..928bb9488b3 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts @@ -16,13 +16,15 @@ import { DialogService, ToastService } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; -import { HecConnectDialogResultStatus, openHecConnectDialog } from "../integration-dialog"; +import { IntegrationDialogResultStatus, openHecConnectDialog } from "../integration-dialog"; import { IntegrationCardComponent } from "./integration-card.component"; jest.mock("../integration-dialog", () => ({ openHecConnectDialog: jest.fn(), - HecConnectDialogResultStatus: { Edited: "edit", Delete: "delete" }, + openDatadogConnectDialog: jest.fn(), + openHuntressConnectDialog: jest.fn(), + IntegrationDialogResultStatus: { Edited: "edit", Delete: "delete" }, })); describe("IntegrationCardComponent", () => { @@ -276,7 +278,7 @@ describe("IntegrationCardComponent", () => { it("should call updateHec if isUpdateAvailable is true", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, url: "test-url", bearerToken: "token", index: "index", @@ -317,7 +319,7 @@ describe("IntegrationCardComponent", () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, url: "test-url", bearerToken: "token", index: "index", @@ -354,7 +356,7 @@ describe("IntegrationCardComponent", () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Delete, + success: IntegrationDialogResultStatus.Delete, url: "test-url", bearerToken: "token", index: "index", @@ -382,7 +384,7 @@ describe("IntegrationCardComponent", () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Delete, + success: IntegrationDialogResultStatus.Delete, url: "test-url", bearerToken: "token", index: "index", @@ -404,7 +406,7 @@ describe("IntegrationCardComponent", () => { it("should show toast on error while saving", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, url: "test-url", bearerToken: "token", index: "index", @@ -427,7 +429,7 @@ describe("IntegrationCardComponent", () => { it("should show mustBeOwner toast on error while inserting data", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, url: "test-url", bearerToken: "token", index: "index", @@ -450,7 +452,7 @@ describe("IntegrationCardComponent", () => { it("should show mustBeOwner toast on error while updating data", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, url: "test-url", bearerToken: "token", index: "index", @@ -472,7 +474,7 @@ describe("IntegrationCardComponent", () => { it("should show toast on error while deleting", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Delete, + success: IntegrationDialogResultStatus.Delete, url: "test-url", bearerToken: "token", index: "index", @@ -495,7 +497,7 @@ describe("IntegrationCardComponent", () => { it("should show mustbeOwner toast on 404 while deleting", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Delete, + success: IntegrationDialogResultStatus.Delete, url: "test-url", bearerToken: "token", index: "index", diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts index 8026e14c2fc..f423a9b86d9 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts @@ -12,7 +12,12 @@ import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rx import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; -import { OrgIntegrationBuilder } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-builder"; +import { + OrgIntegrationBuilder, + OrgIntegrationConfiguration, + OrgIntegrationTemplate, + Schemas, +} from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-builder"; import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type"; import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; @@ -23,7 +28,6 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; import { BaseCardComponent, CardContentComponent, - DialogRef, DialogService, ToastService, } from "@bitwarden/components"; @@ -32,10 +36,11 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { HecConnectDialogResult, DatadogConnectDialogResult, - HecConnectDialogResultStatus, - DatadogConnectDialogResultStatus, + HuntressConnectDialogResult, + IntegrationDialogResultStatus, openDatadogConnectDialog, openHecConnectDialog, + openHuntressConnectDialog, } from "../integration-dialog/index"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -164,14 +169,12 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { } async setupConnection() { - let dialog: DialogRef; - if (this.integrationSettings?.integrationType === null) { return; } if (this.integrationSettings?.integrationType === OrganizationIntegrationType.Datadog) { - dialog = openDatadogConnectDialog(this.dialogService, { + const dialog = openDatadogConnectDialog(this.dialogService, { data: { settings: this.integrationSettings, }, @@ -179,37 +182,29 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { const result = await lastValueFrom(dialog.closed); - // the dialog was cancelled - if (!result || !result.success) { - return; - } + await this.handleIntegrationDialogResult( + result, + () => this.deleteDatadog(), + (res) => this.saveDatadog(res), + ); + } else if (this.integrationSettings.name === OrganizationIntegrationServiceName.Huntress) { + // Huntress uses HEC protocol but has its own dialog + const dialog = openHuntressConnectDialog(this.dialogService, { + data: { + settings: this.integrationSettings, + }, + }); - try { - if (result.success === HecConnectDialogResultStatus.Delete) { - await this.deleteDatadog(); - } - } catch { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("failedToDeleteIntegration"), - }); - } + const result = await lastValueFrom(dialog.closed); - try { - if (result.success === DatadogConnectDialogResultStatus.Edited) { - await this.saveDatadog(result as DatadogConnectDialogResult); - } - } catch { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("failedToSaveIntegration"), - }); - } + await this.handleIntegrationDialogResult( + result, + () => this.deleteHuntress(), + (res) => this.saveHuntress(res), + ); } else { // invoke the dialog to connect the integration - dialog = openHecConnectDialog(this.dialogService, { + const dialog = openHecConnectDialog(this.dialogService, { data: { settings: this.integrationSettings, }, @@ -217,15 +212,113 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { const result = await lastValueFrom(dialog.closed); - // the dialog was cancelled - if (!result || !result.success) { - return; + await this.handleIntegrationDialogResult( + result, + () => this.deleteHec(), + (res) => this.saveHec(res), + ); + } + } + + /** + * Generic save method + */ + private async saveIntegration( + integrationType: OrganizationIntegrationType, + config: OrgIntegrationConfiguration, + template: OrgIntegrationTemplate, + ): Promise { + let response = { mustBeOwner: false, success: false }; + + if (this.isUpdateAvailable) { + // retrieve org integration and configuration ids + const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; + const orgIntegrationConfigurationId = + this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; + + if (!orgIntegrationId || !orgIntegrationConfigurationId) { + throw Error("Organization Integration ID or Configuration ID is missing"); } + // update existing integration and configuration + response = await this.organizationIntegrationService.update( + this.organizationId, + orgIntegrationId, + integrationType, + orgIntegrationConfigurationId, + config, + template, + ); + } else { + // create new integration and configuration + response = await this.organizationIntegrationService.save( + this.organizationId, + integrationType, + config, + template, + ); + } + + if (response.mustBeOwner) { + this.showMustBeOwnerToast(); + return; + } + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("success"), + }); + } + + /** + * Generic delete method + */ + private async deleteIntegration(): Promise { + const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; + const orgIntegrationConfigurationId = + this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; + + if (!orgIntegrationId || !orgIntegrationConfigurationId) { + throw Error("Organization Integration ID or Configuration ID is missing"); + } + + const response = await this.organizationIntegrationService.delete( + this.organizationId, + orgIntegrationId, + orgIntegrationConfigurationId, + ); + + if (response.mustBeOwner) { + this.showMustBeOwnerToast(); + return; + } + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("success"), + }); + } + + /** + * Generic dialog result handler + * Handles both delete and edit actions with proper error handling + */ + private async handleIntegrationDialogResult( + result: T | undefined, + deleteCallback: () => Promise, + saveCallback: (result: T) => Promise, + ): Promise { + // User cancelled the dialog or closed it without saving + if (!result || !result.success) { + return; + } + + // Handle delete action + if (result.success === IntegrationDialogResultStatus.Delete) { try { - if (result.success === HecConnectDialogResultStatus.Delete) { - await this.deleteHec(); - } + await deleteCallback(); } catch { this.toastService.showToast({ variant: "error", @@ -233,11 +326,13 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { message: this.i18nService.t("failedToDeleteIntegration"), }); } + return; + } + // Handle edit/save action + if (result.success === IntegrationDialogResultStatus.Edited) { try { - if (result.success === HecConnectDialogResultStatus.Edited) { - await this.saveHec(result as HecConnectDialogResult); - } + await saveCallback(result); } catch { this.toastService.showToast({ variant: "error", @@ -249,8 +344,6 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { } async saveHec(result: HecConnectDialogResult) { - let response = { mustBeOwner: false, success: false }; - const config = OrgIntegrationBuilder.buildHecConfiguration( result.url, result.bearerToken, @@ -261,148 +354,45 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { this.integrationSettings.name as OrganizationIntegrationServiceName, ); - if (this.isUpdateAvailable) { - // retrieve org integration and configuration ids - const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; - const orgIntegrationConfigurationId = - this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; - - if (!orgIntegrationId || !orgIntegrationConfigurationId) { - throw Error("Organization Integration ID or Configuration ID is missing"); - } - - // update existing integration and configuration - response = await this.organizationIntegrationService.update( - this.organizationId, - orgIntegrationId, - OrganizationIntegrationType.Hec, - orgIntegrationConfigurationId, - config, - template, - ); - } else { - // create new integration and configuration - response = await this.organizationIntegrationService.save( - this.organizationId, - OrganizationIntegrationType.Hec, - config, - template, - ); - } - - if (response.mustBeOwner) { - this.showMustBeOwnerToast(); - return; - } - - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("success"), - }); + await this.saveIntegration(OrganizationIntegrationType.Hec, config, template); } async deleteHec() { - const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; - const orgIntegrationConfigurationId = - this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; + await this.deleteIntegration(); + } - if (!orgIntegrationId || !orgIntegrationConfigurationId) { - throw Error("Organization Integration ID or Configuration ID is missing"); - } - - const response = await this.organizationIntegrationService.delete( - this.organizationId, - orgIntegrationId, - orgIntegrationConfigurationId, + async saveHuntress(result: HuntressConnectDialogResult) { + // Huntress uses "Splunk" scheme for HEC protocol compatibility + const config = OrgIntegrationBuilder.buildHecConfiguration( + result.url, + result.token, + OrganizationIntegrationServiceName.Huntress, + Schemas.Splunk, + ); + // Huntress SIEM doesn't require the index field + const template = OrgIntegrationBuilder.buildHecTemplate( + "", + OrganizationIntegrationServiceName.Huntress, ); - if (response.mustBeOwner) { - this.showMustBeOwnerToast(); - return; - } + await this.saveIntegration(OrganizationIntegrationType.Hec, config, template); + } - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("success"), - }); + async deleteHuntress() { + await this.deleteIntegration(); } async saveDatadog(result: DatadogConnectDialogResult) { - let response = { mustBeOwner: false, success: false }; - const config = OrgIntegrationBuilder.buildDataDogConfiguration(result.url, result.apiKey); const template = OrgIntegrationBuilder.buildDataDogTemplate( this.integrationSettings.name as OrganizationIntegrationServiceName, ); - if (this.isUpdateAvailable) { - // retrieve org integration and configuration ids - const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; - const orgIntegrationConfigurationId = - this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; - - if (!orgIntegrationId || !orgIntegrationConfigurationId) { - throw Error("Organization Integration ID or Configuration ID is missing"); - } - - // update existing integration and configuration - response = await this.organizationIntegrationService.update( - this.organizationId, - orgIntegrationId, - OrganizationIntegrationType.Datadog, - orgIntegrationConfigurationId, - config, - template, - ); - } else { - // create new integration and configuration - response = await this.organizationIntegrationService.save( - this.organizationId, - OrganizationIntegrationType.Datadog, - config, - template, - ); - } - - if (response.mustBeOwner) { - this.showMustBeOwnerToast(); - return; - } - - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("success"), - }); + await this.saveIntegration(OrganizationIntegrationType.Datadog, config, template); } async deleteDatadog() { - const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; - const orgIntegrationConfigurationId = - this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; - - if (!orgIntegrationId || !orgIntegrationConfigurationId) { - throw Error("Organization Integration ID or Configuration ID is missing"); - } - - const response = await this.organizationIntegrationService.delete( - this.organizationId, - orgIntegrationId, - orgIntegrationConfigurationId, - ); - - if (response.mustBeOwner) { - this.showMustBeOwnerToast(); - return; - } - - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("success"), - }); + await this.deleteIntegration(); } private showMustBeOwnerToast() { diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html index ddc108201b0..523cbc66d56 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html @@ -23,7 +23,7 @@ {{ "apiKey" | i18n }} - + {{ "apiKey" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts index 7298087e7e4..76fc8144309 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts @@ -10,11 +10,12 @@ import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/ import { I18nPipe } from "@bitwarden/ui-common"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { IntegrationDialogResultStatus } from "../integration-dialog-result-status"; + import { ConnectDatadogDialogComponent, DatadogConnectDialogParams, DatadogConnectDialogResult, - DatadogConnectDialogResultStatus, openDatadogConnectDialog, } from "./connect-dialog-datadog.component"; @@ -149,7 +150,7 @@ describe("ConnectDialogDatadogComponent", () => { url: "https://test.com", apiKey: "token", service: "Test Service", - success: DatadogConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, }); }); }); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts index 47760c6311a..cedc8e5d3e3 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts @@ -7,6 +7,11 @@ import { HecTemplate } from "@bitwarden/bit-common/dirt/organization-integration import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { + IntegrationDialogResultStatus, + IntegrationDialogResultStatusType, +} from "../integration-dialog-result-status"; + export type DatadogConnectDialogParams = { settings: Integration; }; @@ -16,17 +21,9 @@ export interface DatadogConnectDialogResult { url: string; apiKey: string; service: string; - success: DatadogConnectDialogResultStatusType | null; + success: IntegrationDialogResultStatusType | null; } -export const DatadogConnectDialogResultStatus = { - Edited: "edit", - Delete: "delete", -} as const; - -export type DatadogConnectDialogResultStatusType = - (typeof DatadogConnectDialogResultStatus)[keyof typeof DatadogConnectDialogResultStatus]; - // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -78,7 +75,7 @@ export class ConnectDatadogDialogComponent implements OnInit { this.formGroup.markAllAsTouched(); return; } - const result = this.getDatadogConnectDialogResult(DatadogConnectDialogResultStatus.Edited); + const result = this.getDatadogConnectDialogResult(IntegrationDialogResultStatus.Edited); this.dialogRef.close(result); @@ -95,13 +92,13 @@ export class ConnectDatadogDialogComponent implements OnInit { }); if (confirmed) { - const result = this.getDatadogConnectDialogResult(DatadogConnectDialogResultStatus.Delete); + const result = this.getDatadogConnectDialogResult(IntegrationDialogResultStatus.Delete); this.dialogRef.close(result); } }; private getDatadogConnectDialogResult( - status: DatadogConnectDialogResultStatusType, + status: IntegrationDialogResultStatusType, ): DatadogConnectDialogResult { const formJson = this.formGroup.getRawValue(); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html index 0dad1621440..1cafd7c3211 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html @@ -23,7 +23,7 @@ {{ "bearerToken" | i18n }} - + {{ "apiKey" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts index 9f640ebbcc7..c337f2872d6 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts @@ -10,11 +10,12 @@ import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/ import { I18nPipe } from "@bitwarden/ui-common"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { IntegrationDialogResultStatus } from "../integration-dialog-result-status"; + import { ConnectHecDialogComponent, HecConnectDialogParams, HecConnectDialogResult, - HecConnectDialogResultStatus, openHecConnectDialog, } from "./connect-dialog-hec.component"; @@ -155,7 +156,7 @@ describe("ConnectDialogHecComponent", () => { bearerToken: "token", index: "1", service: "Test Service", - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, }); }); }); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts index 3612f2c76cb..3d38cfd1f79 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts @@ -7,6 +7,11 @@ import { HecTemplate } from "@bitwarden/bit-common/dirt/organization-integration import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { + IntegrationDialogResultStatus, + IntegrationDialogResultStatusType, +} from "../integration-dialog-result-status"; + export type HecConnectDialogParams = { settings: Integration; }; @@ -17,17 +22,9 @@ export interface HecConnectDialogResult { bearerToken: string; index: string; service: string; - success: HecConnectDialogResultStatusType | null; + success: IntegrationDialogResultStatusType | null; } -export const HecConnectDialogResultStatus = { - Edited: "edit", - Delete: "delete", -} as const; - -export type HecConnectDialogResultStatusType = - (typeof HecConnectDialogResultStatus)[keyof typeof HecConnectDialogResultStatus]; - // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -81,7 +78,7 @@ export class ConnectHecDialogComponent implements OnInit { this.formGroup.markAllAsTouched(); return; } - const result = this.getHecConnectDialogResult(HecConnectDialogResultStatus.Edited); + const result = this.getHecConnectDialogResult(IntegrationDialogResultStatus.Edited); this.dialogRef.close(result); @@ -98,13 +95,13 @@ export class ConnectHecDialogComponent implements OnInit { }); if (confirmed) { - const result = this.getHecConnectDialogResult(HecConnectDialogResultStatus.Delete); + const result = this.getHecConnectDialogResult(IntegrationDialogResultStatus.Delete); this.dialogRef.close(result); } }; private getHecConnectDialogResult( - status: HecConnectDialogResultStatusType, + status: IntegrationDialogResultStatusType, ): HecConnectDialogResult { const formJson = this.formGroup.getRawValue(); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.html new file mode 100644 index 00000000000..7c2894ff8b1 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.html @@ -0,0 +1,57 @@ +
+ + + {{ "connectIntegrationButtonDesc" | i18n: connectInfo.settings.name }} + +
+ @if (loading) { + + + + } + @if (!loading) { + + + {{ "httpEventCollectorUrl" | i18n }} + + + + + {{ "httpEventCollectorToken" | i18n }} + + + + } +
+ + + + + @if (canDelete) { +
+ +
+ } +
+
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.spec.ts new file mode 100644 index 00000000000..9c5dc58a762 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.spec.ts @@ -0,0 +1,206 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { mock } from "jest-mock-extended"; + +import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { IntegrationType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { IntegrationDialogResultStatus } from "../integration-dialog-result-status"; + +import { + ConnectHuntressDialogComponent, + HuntressConnectDialogParams, + HuntressConnectDialogResult, + openHuntressConnectDialog, +} from "./connect-dialog-huntress.component"; + +beforeAll(() => { + // Mock element.animate for jsdom + // the animate function is not available in jsdom, so we provide a mock implementation + // This is necessary for tests that rely on animations + // This mock does not perform any actual animations, it just provides a structure that allows tests + // to run without throwing errors related to missing animate function + if (!HTMLElement.prototype.animate) { + HTMLElement.prototype.animate = function () { + return { + play: () => {}, + pause: () => {}, + finish: () => {}, + cancel: () => {}, + reverse: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + onfinish: null, + oncancel: null, + startTime: 0, + currentTime: 0, + playbackRate: 1, + playState: "idle", + replaceState: "active", + effect: null, + finished: Promise.resolve(), + id: "", + remove: () => {}, + timeline: null, + ready: Promise.resolve(), + } as unknown as Animation; + }; + } +}); + +describe("ConnectHuntressDialogComponent", () => { + let component: ConnectHuntressDialogComponent; + let fixture: ComponentFixture; + let dialogRefMock = mock>(); + const mockI18nService = mock(); + + const integrationMock: Integration = { + name: "Huntress", + image: "test-image.png", + linkURL: "https://example.com", + imageDarkMode: "test-image-dark.png", + newBadgeExpiration: "2024-12-31", + description: "Test Description", + canSetupConnection: true, + type: IntegrationType.EVENT, + } as Integration; + + const connectInfo: HuntressConnectDialogParams = { + settings: integrationMock, + }; + + beforeEach(async () => { + dialogRefMock = mock>(); + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, SharedModule, BrowserAnimationsModule], + providers: [ + FormBuilder, + { provide: DIALOG_DATA, useValue: connectInfo }, + { provide: DialogRef, useValue: dialogRefMock }, + { provide: I18nPipe, useValue: mock() }, + { provide: I18nService, useValue: mockI18nService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ConnectHuntressDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + mockI18nService.t.mockImplementation((key) => key); + }); + + it("should create the component", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize form with empty values and service name", () => { + expect(component.formGroup.value).toEqual({ + url: "", + token: "", + service: "Huntress", + }); + }); + + it("should have required validators for url and token fields", () => { + component.formGroup.setValue({ url: "", token: "", service: "" }); + expect(component.formGroup.valid).toBeFalsy(); + + component.formGroup.setValue({ + url: "https://hec.huntress.io/services/collector", + token: "test-token", + service: "Huntress", + }); + expect(component.formGroup.valid).toBeTruthy(); + }); + + it("should require url to be at least 7 characters long", () => { + component.formGroup.setValue({ + url: "test", + token: "token", + service: "Huntress", + }); + expect(component.formGroup.valid).toBeFalsy(); + + component.formGroup.setValue({ + url: "https://hec.huntress.io", + token: "token", + service: "Huntress", + }); + expect(component.formGroup.valid).toBeTruthy(); + }); + + it("should call dialogRef.close with correct result on submit", async () => { + component.formGroup.setValue({ + url: "https://hec.huntress.io/services/collector", + token: "test-token", + service: "Huntress", + }); + + await component.submit(); + + expect(dialogRefMock.close).toHaveBeenCalledWith({ + integrationSettings: integrationMock, + url: "https://hec.huntress.io/services/collector", + token: "test-token", + service: "Huntress", + success: IntegrationDialogResultStatus.Edited, + }); + }); + + it("should not submit when form is invalid", async () => { + component.formGroup.setValue({ + url: "", + token: "", + service: "Huntress", + }); + + await component.submit(); + + expect(dialogRefMock.close).not.toHaveBeenCalled(); + expect(component.formGroup.touched).toBeTruthy(); + }); + + it("should return false for isUpdateAvailable when no config exists", () => { + component.huntressConfig = null; + expect(component.isUpdateAvailable).toBeFalsy(); + }); + + it("should return true for isUpdateAvailable when config exists", () => { + component.huntressConfig = { uri: "test", token: "test" } as any; + expect(component.isUpdateAvailable).toBeTruthy(); + }); + + it("should return false for canDelete when no config exists", () => { + component.huntressConfig = null; + expect(component.canDelete).toBeFalsy(); + }); + + it("should return true for canDelete when config exists", () => { + component.huntressConfig = { uri: "test", token: "test" } as any; + expect(component.canDelete).toBeTruthy(); + }); +}); + +describe("openHuntressConnectDialog", () => { + it("should call dialogService.open with correct params", () => { + const dialogServiceMock = mock(); + const config: DialogConfig< + HuntressConnectDialogParams, + DialogRef + > = { + data: { settings: { name: "Huntress" } as Integration }, + } as any; + + openHuntressConnectDialog(dialogServiceMock, config); + + expect(dialogServiceMock.open).toHaveBeenCalledWith(ConnectHuntressDialogComponent, config); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.ts new file mode 100644 index 00000000000..953a8cdb0ac --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.ts @@ -0,0 +1,114 @@ +import { ChangeDetectionStrategy, Component, Inject, OnInit } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; + +import { HecConfiguration } from "@bitwarden/bit-common/dirt/organization-integrations/models/configuration/hec-configuration"; +import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { + IntegrationDialogResultStatus, + IntegrationDialogResultStatusType, +} from "../integration-dialog-result-status"; + +export type HuntressConnectDialogParams = { + settings: Integration; +}; + +export interface HuntressConnectDialogResult { + integrationSettings: Integration; + url: string; + token: string; + service: string; + success: IntegrationDialogResultStatusType | null; +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./connect-dialog-huntress.component.html", + imports: [SharedModule], +}) +export class ConnectHuntressDialogComponent implements OnInit { + loading = false; + huntressConfig: HecConfiguration | null = null; + formGroup = this.formBuilder.group({ + url: ["", [Validators.required, Validators.minLength(7)]], + token: ["", Validators.required], + service: [""], // Programmatically set in ngOnInit, not shown to user + }); + + constructor( + @Inject(DIALOG_DATA) protected connectInfo: HuntressConnectDialogParams, + protected formBuilder: FormBuilder, + private dialogRef: DialogRef, + private dialogService: DialogService, + ) {} + + ngOnInit(): void { + this.huntressConfig = + this.connectInfo.settings.organizationIntegration?.getConfiguration() ?? + null; + + this.formGroup.patchValue({ + url: this.huntressConfig?.uri || "", + token: this.huntressConfig?.token || "", + service: this.connectInfo.settings.name, + }); + } + + get isUpdateAvailable(): boolean { + return !!this.huntressConfig; + } + + get canDelete(): boolean { + return !!this.huntressConfig; + } + + submit = async (): Promise => { + if (this.formGroup.invalid) { + this.formGroup.markAllAsTouched(); + return; + } + const result = this.getHuntressConnectDialogResult(IntegrationDialogResultStatus.Edited); + + this.dialogRef.close(result); + + return; + }; + + delete = async (): Promise => { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "deleteItem" }, + content: { + key: "deleteItemConfirmation", + }, + type: "warning", + }); + + if (confirmed) { + const result = this.getHuntressConnectDialogResult(IntegrationDialogResultStatus.Delete); + this.dialogRef.close(result); + } + }; + + private getHuntressConnectDialogResult( + status: IntegrationDialogResultStatusType, + ): HuntressConnectDialogResult { + const formJson = this.formGroup.getRawValue(); + + return { + integrationSettings: this.connectInfo.settings, + url: formJson.url || "", + token: formJson.token || "", + service: formJson.service || "", + success: status, + }; + } +} + +export function openHuntressConnectDialog( + dialogService: DialogService, + config: DialogConfig>, +) { + return dialogService.open(ConnectHuntressDialogComponent, config); +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts index 9852f3fe5c8..a41ee826cbc 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts @@ -1,2 +1,4 @@ export * from "./connect-dialog/connect-dialog-hec.component"; export * from "./connect-dialog/connect-dialog-datadog.component"; +export * from "./connect-dialog/connect-dialog-huntress.component"; +export * from "./integration-dialog-result-status"; diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/integration-dialog-result-status.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/integration-dialog-result-status.ts new file mode 100644 index 00000000000..1774088c203 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/integration-dialog-result-status.ts @@ -0,0 +1,11 @@ +/** + * Shared status types for integration dialog results + * Used across all SIEM integration dialogs (HEC, Datadog, Huntress, etc.) + */ +export const IntegrationDialogResultStatus = { + Edited: "edit", + Delete: "delete", +} as const; + +export type IntegrationDialogResultStatusType = + (typeof IntegrationDialogResultStatus)[keyof typeof IntegrationDialogResultStatus]; 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..fbff31f026e 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 @@ -1,78 +1,18 @@ - +@let org = organization(); -@let organization = organization$ | async; + + @if (org) { + + {{ "singleSignOn" | i18n }} + @if (org.useScim || org.useDirectory) { + {{ "userProvisioning" | i18n }} + } + @if (org.useEvents) { + {{ "eventManagement" | i18n }} + } + {{ "deviceManagement" | i18n }} + + } + -@if (organization) { - - @if (organization?.useSso) { - -
-

{{ "singleSignOn" | i18n }}

-

- {{ "ssoDescStart" | i18n }} - {{ - "singleSignOn" | i18n - }} - {{ "ssoDescEnd" | i18n }} -

- -
-
- } - - @if (organization?.useScim || organization?.useDirectory) { - -
-

- {{ "scimIntegration" | i18n }} -

-

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

- -
-
-

- {{ "bwdc" | i18n }} -

-

{{ "bwdcDesc" | i18n }}

- -
-
- } - - @if (organization?.useEvents) { - -
-

- {{ "eventManagement" | i18n }} -

-

{{ "eventManagementDesc" | i18n }}

- -
-
- } - - -
-

- {{ "deviceManagement" | i18n }} -

-

{{ "deviceManagementDesc" | i18n }}

- -
-
-
-} + diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts index 6517182b21e..786aa70bfc5 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts @@ -1,313 +1,22 @@ -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; -import { firstValueFrom, Observable, Subject, switchMap, takeUntil, takeWhile } from "rxjs"; +import { Component } from "@angular/core"; -import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; -import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; -import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type"; -import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { IntegrationType } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { getById } from "@bitwarden/common/platform/misc"; +import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; -import { IntegrationGridComponent } from "./integration-grid/integration-grid.component"; -import { FilterIntegrationsPipe } from "./integrations.pipe"; +import { OrganizationIntegrationsState } from "./organization-integrations.state"; -// attempted, but because bit-tab-group is not OnPush, caused more issues than it solved // 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: "ac-integrations", templateUrl: "./integrations.component.html", - imports: [SharedModule, IntegrationGridComponent, HeaderModule, FilterIntegrationsPipe], + imports: [SharedModule, HeaderModule], }) -export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { - tabIndex: number = 0; - organization$: Observable = new Observable(); - isEventManagementForDataDogAndCrowdStrikeEnabled: boolean = false; - private destroy$ = new Subject(); +export class AdminConsoleIntegrationsComponent { + organization = this.state.organization; - // initialize the integrations list with default integrations - integrationsList: Integration[] = [ - { - name: "AD FS", - linkURL: "https://bitwarden.com/help/saml-adfs/", - image: "../../../../../../../images/integrations/azure-active-directory.svg", - type: IntegrationType.SSO, - }, - { - name: "Auth0", - linkURL: "https://bitwarden.com/help/saml-auth0/", - image: "../../../../../../../images/integrations/logo-auth0-badge-color.svg", - type: IntegrationType.SSO, - }, - { - name: "AWS", - linkURL: "https://bitwarden.com/help/saml-aws/", - image: "../../../../../../../images/integrations/aws-color.svg", - imageDarkMode: "../../../../../../../images/integrations/aws-darkmode.svg", - type: IntegrationType.SSO, - }, - { - name: "Microsoft Entra ID", - linkURL: "https://bitwarden.com/help/saml-azure/", - image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg", - type: IntegrationType.SSO, - }, - { - name: "Duo", - linkURL: "https://bitwarden.com/help/saml-duo/", - image: "../../../../../../../images/integrations/logo-duo-color.svg", - type: IntegrationType.SSO, - }, - { - name: "Google", - linkURL: "https://bitwarden.com/help/saml-google/", - image: "../../../../../../../images/integrations/logo-google-badge-color.svg", - type: IntegrationType.SSO, - }, - { - name: "JumpCloud", - linkURL: "https://bitwarden.com/help/saml-jumpcloud/", - image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg", - imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg", - type: IntegrationType.SSO, - }, - { - name: "KeyCloak", - linkURL: "https://bitwarden.com/help/saml-keycloak/", - image: "../../../../../../../images/integrations/logo-keycloak-icon.svg", - type: IntegrationType.SSO, - }, - { - name: "Okta", - linkURL: "https://bitwarden.com/help/saml-okta/", - image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg", - imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg", - type: IntegrationType.SSO, - }, - { - name: "OneLogin", - linkURL: "https://bitwarden.com/help/saml-onelogin/", - image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg", - imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg", - type: IntegrationType.SSO, - }, - { - name: "PingFederate", - linkURL: "https://bitwarden.com/help/saml-pingfederate/", - image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg", - type: IntegrationType.SSO, - }, - { - name: "Microsoft Entra ID", - linkURL: "https://bitwarden.com/help/microsoft-entra-id-scim-integration/", - image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg", - type: IntegrationType.SCIM, - }, - { - name: "Okta", - linkURL: "https://bitwarden.com/help/okta-scim-integration/", - image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg", - imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg", - type: IntegrationType.SCIM, - }, - { - name: "OneLogin", - linkURL: "https://bitwarden.com/help/onelogin-scim-integration/", - image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg", - imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg", - type: IntegrationType.SCIM, - }, - { - name: "JumpCloud", - linkURL: "https://bitwarden.com/help/jumpcloud-scim-integration/", - image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg", - imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg", - type: IntegrationType.SCIM, - }, - { - name: "Ping Identity", - linkURL: "https://bitwarden.com/help/ping-identity-scim-integration/", - image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg", - type: IntegrationType.SCIM, - }, - { - name: "Active Directory", - linkURL: "https://bitwarden.com/help/ldap-directory/", - image: "../../../../../../../images/integrations/azure-active-directory.svg", - type: IntegrationType.BWDC, - }, - { - name: "Microsoft Entra ID", - linkURL: "https://bitwarden.com/help/microsoft-entra-id/", - image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg", - type: IntegrationType.BWDC, - }, - { - name: "Google Workspace", - linkURL: "https://bitwarden.com/help/workspace-directory/", - image: "../../../../../../../images/integrations/logo-google-badge-color.svg", - type: IntegrationType.BWDC, - }, - { - name: "Okta", - linkURL: "https://bitwarden.com/help/okta-directory/", - image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg", - imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg", - type: IntegrationType.BWDC, - }, - { - name: "OneLogin", - linkURL: "https://bitwarden.com/help/onelogin-directory/", - image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg", - imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg", - type: IntegrationType.BWDC, - }, - { - name: "Splunk", - linkURL: "https://bitwarden.com/help/splunk-siem/", - image: "../../../../../../../images/integrations/logo-splunk-black.svg", - imageDarkMode: "../../../../../../../images/integrations/splunk-darkmode.svg", - type: IntegrationType.EVENT, - }, - { - name: "Microsoft Sentinel", - linkURL: "https://bitwarden.com/help/microsoft-sentinel-siem/", - image: "../../../../../../../images/integrations/logo-microsoft-sentinel-color.svg", - type: IntegrationType.EVENT, - }, - { - name: "Rapid7", - linkURL: "https://bitwarden.com/help/rapid7-siem/", - image: "../../../../../../../images/integrations/logo-rapid7-black.svg", - imageDarkMode: "../../../../../../../images/integrations/rapid7-darkmode.svg", - type: IntegrationType.EVENT, - }, - { - name: "Elastic", - linkURL: "https://bitwarden.com/help/elastic-siem/", - image: "../../../../../../../images/integrations/logo-elastic-badge-color.svg", - type: IntegrationType.EVENT, - }, - { - name: "Panther", - linkURL: "https://bitwarden.com/help/panther-siem/", - image: "../../../../../../../images/integrations/logo-panther-round-color.svg", - type: IntegrationType.EVENT, - }, - { - name: "Sumo Logic", - linkURL: "https://bitwarden.com/help/sumo-logic-siem/", - image: "../../../../../../../images/integrations/logo-sumo-logic-siem.svg", - imageDarkMode: "../../../../../../../images/integrations/logo-sumo-logic-siem-darkmode.svg", - type: IntegrationType.EVENT, - newBadgeExpiration: "2025-12-31", - }, - { - name: "Microsoft Intune", - linkURL: "https://bitwarden.com/help/deploy-browser-extensions-with-intune/", - image: "../../../../../../../images/integrations/logo-microsoft-intune-color.svg", - type: IntegrationType.DEVICE, - }, - ]; - - async ngOnInit() { - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - if (!userId) { - throw new Error("User ID not found"); - } - - this.organization$ = this.route.params.pipe( - switchMap((params) => - this.organizationService.organizations$(userId).pipe( - getById(params.organizationId), - // Filter out undefined values - takeWhile((org: Organization | undefined) => !!org), - ), - ), - ); - - // Sets the organization ID which also loads the integrations$ - this.organization$ - .pipe( - switchMap((org) => this.organizationIntegrationService.setOrganizationId(org.id)), - takeUntil(this.destroy$), - ) - .subscribe(); - } - - constructor( - private route: ActivatedRoute, - private organizationService: OrganizationService, - private accountService: AccountService, - private configService: ConfigService, - private organizationIntegrationService: OrganizationIntegrationService, - ) { - this.configService - .getFeatureFlag$(FeatureFlag.EventManagementForDataDogAndCrowdStrike) - .pipe(takeUntil(this.destroy$)) - .subscribe((isEnabled) => { - this.isEventManagementForDataDogAndCrowdStrikeEnabled = isEnabled; - }); - - // Add the new event based items to the list - if (this.isEventManagementForDataDogAndCrowdStrikeEnabled) { - const crowdstrikeIntegration: Integration = { - name: OrganizationIntegrationServiceName.CrowdStrike, - linkURL: "https://bitwarden.com/help/crowdstrike-siem/", - image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg", - type: IntegrationType.EVENT, - description: "crowdstrikeEventIntegrationDesc", - canSetupConnection: true, - integrationType: OrganizationIntegrationType.Hec, - }; - - this.integrationsList.push(crowdstrikeIntegration); - - const datadogIntegration: Integration = { - name: OrganizationIntegrationServiceName.Datadog, - linkURL: "https://bitwarden.com/help/datadog-siem/", - image: "../../../../../../../images/integrations/logo-datadog-color.svg", - type: IntegrationType.EVENT, - description: "datadogEventIntegrationDesc", - canSetupConnection: true, - integrationType: OrganizationIntegrationType.Datadog, - }; - - this.integrationsList.push(datadogIntegration); - } - - // For all existing event based configurations loop through and assign the - // organizationIntegration for the correct services. - this.organizationIntegrationService.integrations$ - .pipe(takeUntil(this.destroy$)) - .subscribe((integrations) => { - // reset all event based integrations to null first - in case one was deleted - this.integrationsList.forEach((i) => { - i.organizationIntegration = null; - }); - - integrations.forEach((integration) => { - const item = this.integrationsList.find((i) => i.name === integration.serviceName); - if (item) { - item.organizationIntegration = integration; - } - }); - }); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } + constructor(private state: OrganizationIntegrationsState) {} // use in the view get IntegrationType(): typeof IntegrationType { diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.pipe.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.pipe.ts index 7a420ade4b5..10ee251a921 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.pipe.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.pipe.ts @@ -7,7 +7,10 @@ import { IntegrationType } from "@bitwarden/common/enums"; name: "filterIntegrations", }) export class FilterIntegrationsPipe implements PipeTransform { - transform(integrations: Integration[], type: IntegrationType): Integration[] { + transform(integrations: Integration[] | null | undefined, type: IntegrationType): Integration[] { + if (!integrations) { + return []; + } return integrations.filter((integration) => integration.type === type); } } diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations-routing.module.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations-routing.module.ts index 1667689b186..626fc5dee88 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations-routing.module.ts @@ -3,16 +3,31 @@ import { RouterModule, Routes } from "@angular/router"; import { organizationPermissionsGuard } from "@bitwarden/web-vault/app/admin-console/organizations/guards/org-permissions.guard"; +import { DeviceManagementComponent } from "./device-management/device-management.component"; +import { EventManagementComponent } from "./event-management/event-management.component"; import { AdminConsoleIntegrationsComponent } from "./integrations.component"; +import { OrganizationIntegrationsResolver } from "./organization-integrations.resolver"; +import { OrganizationIntegrationsState } from "./organization-integrations.state"; +import { SingleSignOnComponent } from "./single-sign-on/single-sign-on.component"; +import { UserProvisioningComponent } from "./user-provisioning/user-provisioning.component"; const routes: Routes = [ { path: "", canActivate: [organizationPermissionsGuard((org) => org.canAccessIntegrations)], - component: AdminConsoleIntegrationsComponent, data: { titleId: "integrations", }, + component: AdminConsoleIntegrationsComponent, + providers: [OrganizationIntegrationsState, OrganizationIntegrationsResolver], + resolve: { integrations: OrganizationIntegrationsResolver }, + children: [ + { path: "", redirectTo: "single-sign-on", pathMatch: "full" }, + { path: "single-sign-on", component: SingleSignOnComponent }, + { path: "user-provisioning", component: UserProvisioningComponent }, + { path: "event-management", component: EventManagementComponent }, + { path: "device-management", component: DeviceManagementComponent }, + ], }, ]; diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts index 789ae548521..33f389a92a9 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts @@ -1,17 +1,30 @@ import { NgModule } from "@angular/core"; +import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-management/device-management.component"; import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-api.service"; import { OrganizationIntegrationConfigurationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-configuration-api.service"; import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { safeProvider } from "@bitwarden/ui-common"; +import { EventManagementComponent } from "./event-management/event-management.component"; import { AdminConsoleIntegrationsComponent } from "./integrations.component"; import { OrganizationIntegrationsRoutingModule } from "./organization-integrations-routing.module"; +import { OrganizationIntegrationsResolver } from "./organization-integrations.resolver"; +import { SingleSignOnComponent } from "./single-sign-on/single-sign-on.component"; +import { UserProvisioningComponent } from "./user-provisioning/user-provisioning.component"; @NgModule({ - imports: [AdminConsoleIntegrationsComponent, OrganizationIntegrationsRoutingModule], + imports: [ + AdminConsoleIntegrationsComponent, + OrganizationIntegrationsRoutingModule, + SingleSignOnComponent, + UserProvisioningComponent, + DeviceManagementComponent, + EventManagementComponent, + ], providers: [ + OrganizationIntegrationsResolver, safeProvider({ provide: OrganizationIntegrationService, useClass: OrganizationIntegrationService, diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.resolver.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.resolver.ts new file mode 100644 index 00000000000..39bd0cc1dcc --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.resolver.ts @@ -0,0 +1,285 @@ +import { Injectable } from "@angular/core"; +import { ActivatedRouteSnapshot, Resolve } from "@angular/router"; +import { firstValueFrom } from "rxjs"; +import { take, takeWhile } from "rxjs/operators"; + +import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; +import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type"; +import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; +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 { IntegrationType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { getById } from "@bitwarden/common/platform/misc"; + +import { OrganizationIntegrationsState } from "./organization-integrations.state"; + +@Injectable() +export class OrganizationIntegrationsResolver implements Resolve { + constructor( + private organizationService: OrganizationService, + private accountService: AccountService, + private configService: ConfigService, + private organizationIntegrationService: OrganizationIntegrationService, + private state: OrganizationIntegrationsState, + ) {} + + async resolve(route: ActivatedRouteSnapshot): Promise { + const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + + if (!userId) { + throw new Error("User ID not found"); + } + + const orgId = route.paramMap.get("organizationId")!; + const org = await firstValueFrom( + this.organizationService.organizations$(userId).pipe(getById(orgId), takeWhile(Boolean)), + ); + + this.state.setOrganization(org); + + await firstValueFrom(this.organizationIntegrationService.setOrganizationId(org.id)); + + const integrations: Integration[] = [ + { + name: "AD FS", + linkURL: "https://bitwarden.com/help/saml-adfs/", + image: "../../../../../../../images/integrations/azure-active-directory.svg", + type: IntegrationType.SSO, + }, + { + name: "Auth0", + linkURL: "https://bitwarden.com/help/saml-auth0/", + image: "../../../../../../../images/integrations/logo-auth0-badge-color.svg", + type: IntegrationType.SSO, + }, + { + name: "AWS", + linkURL: "https://bitwarden.com/help/saml-aws/", + image: "../../../../../../../images/integrations/aws-color.svg", + imageDarkMode: "../../../../../../../images/integrations/aws-darkmode.svg", + type: IntegrationType.SSO, + }, + { + name: "Microsoft Entra ID", + linkURL: "https://bitwarden.com/help/saml-azure/", + image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg", + type: IntegrationType.SSO, + }, + { + name: "Duo", + linkURL: "https://bitwarden.com/help/saml-duo/", + image: "../../../../../../../images/integrations/logo-duo-color.svg", + type: IntegrationType.SSO, + }, + { + name: "Google", + linkURL: "https://bitwarden.com/help/saml-google/", + image: "../../../../../../../images/integrations/logo-google-badge-color.svg", + type: IntegrationType.SSO, + }, + { + name: "JumpCloud", + linkURL: "https://bitwarden.com/help/saml-jumpcloud/", + image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg", + imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg", + type: IntegrationType.SSO, + }, + { + name: "KeyCloak", + linkURL: "https://bitwarden.com/help/saml-keycloak/", + image: "../../../../../../../images/integrations/logo-keycloak-icon.svg", + type: IntegrationType.SSO, + }, + { + name: "Okta", + linkURL: "https://bitwarden.com/help/saml-okta/", + image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg", + imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg", + type: IntegrationType.SSO, + }, + { + name: "OneLogin", + linkURL: "https://bitwarden.com/help/saml-onelogin/", + image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg", + imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg", + type: IntegrationType.SSO, + }, + { + name: "PingFederate", + linkURL: "https://bitwarden.com/help/saml-pingfederate/", + image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg", + type: IntegrationType.SSO, + }, + { + name: "Microsoft Entra ID", + linkURL: "https://bitwarden.com/help/microsoft-entra-id-scim-integration/", + image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg", + type: IntegrationType.SCIM, + }, + { + name: "Okta", + linkURL: "https://bitwarden.com/help/okta-scim-integration/", + image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg", + imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg", + type: IntegrationType.SCIM, + }, + { + name: "OneLogin", + linkURL: "https://bitwarden.com/help/onelogin-scim-integration/", + image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg", + imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg", + type: IntegrationType.SCIM, + }, + { + name: "JumpCloud", + linkURL: "https://bitwarden.com/help/jumpcloud-scim-integration/", + image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg", + imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg", + type: IntegrationType.SCIM, + }, + { + name: "Ping Identity", + linkURL: "https://bitwarden.com/help/ping-identity-scim-integration/", + image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg", + type: IntegrationType.SCIM, + }, + { + name: "Active Directory", + linkURL: "https://bitwarden.com/help/ldap-directory/", + image: "../../../../../../../images/integrations/azure-active-directory.svg", + type: IntegrationType.BWDC, + }, + { + name: "Microsoft Entra ID", + linkURL: "https://bitwarden.com/help/microsoft-entra-id/", + image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg", + type: IntegrationType.BWDC, + }, + { + name: "Google Workspace", + linkURL: "https://bitwarden.com/help/workspace-directory/", + image: "../../../../../../../images/integrations/logo-google-badge-color.svg", + type: IntegrationType.BWDC, + }, + { + name: "Okta", + linkURL: "https://bitwarden.com/help/okta-directory/", + image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg", + imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg", + type: IntegrationType.BWDC, + }, + { + name: "OneLogin", + linkURL: "https://bitwarden.com/help/onelogin-directory/", + image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg", + imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg", + type: IntegrationType.BWDC, + }, + { + name: "Splunk", + linkURL: "https://bitwarden.com/help/splunk-siem/", + image: "../../../../../../../images/integrations/logo-splunk-black.svg", + imageDarkMode: "../../../../../../../images/integrations/splunk-darkmode.svg", + type: IntegrationType.EVENT, + }, + { + name: "Microsoft Sentinel", + linkURL: "https://bitwarden.com/help/microsoft-sentinel-siem/", + image: "../../../../../../../images/integrations/logo-microsoft-sentinel-color.svg", + type: IntegrationType.EVENT, + }, + { + name: "Rapid7", + linkURL: "https://bitwarden.com/help/rapid7-siem/", + image: "../../../../../../../images/integrations/logo-rapid7-black.svg", + imageDarkMode: "../../../../../../../images/integrations/rapid7-darkmode.svg", + type: IntegrationType.EVENT, + }, + { + name: "Elastic", + linkURL: "https://bitwarden.com/help/elastic-siem/", + image: "../../../../../../../images/integrations/logo-elastic-badge-color.svg", + type: IntegrationType.EVENT, + }, + { + name: "Panther", + linkURL: "https://bitwarden.com/help/panther-siem/", + image: "../../../../../../../images/integrations/logo-panther-round-color.svg", + type: IntegrationType.EVENT, + }, + { + name: "Sumo Logic", + linkURL: "https://bitwarden.com/help/sumo-logic-siem/", + image: "../../../../../../../images/integrations/logo-sumo-logic-siem.svg", + imageDarkMode: "../../../../../../../images/integrations/logo-sumo-logic-siem-darkmode.svg", + type: IntegrationType.EVENT, + newBadgeExpiration: "2025-12-31", + }, + { + name: "Microsoft Intune", + linkURL: "https://bitwarden.com/help/deploy-browser-extensions-with-intune/", + image: "../../../../../../../images/integrations/logo-microsoft-intune-color.svg", + type: IntegrationType.DEVICE, + }, + ]; + + const featureEnabled = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.EventManagementForDataDogAndCrowdStrike), + ); + + if (featureEnabled) { + integrations.push( + { + name: OrganizationIntegrationServiceName.CrowdStrike, + linkURL: "https://bitwarden.com/help/crowdstrike-siem/", + image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg", + type: IntegrationType.EVENT, + canSetupConnection: true, + integrationType: OrganizationIntegrationType.Hec, + }, + { + name: OrganizationIntegrationServiceName.Datadog, + linkURL: "https://bitwarden.com/help/datadog-siem/", + image: "../../../../../../../images/integrations/logo-datadog-color.svg", + type: IntegrationType.EVENT, + canSetupConnection: true, + integrationType: OrganizationIntegrationType.Datadog, + }, + ); + } + + // Add Huntress SIEM integration (separate feature flag) + const huntressFeatureEnabled = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.EventManagementForHuntress), + ); + + if (huntressFeatureEnabled) { + integrations.push({ + name: OrganizationIntegrationServiceName.Huntress, + linkURL: "https://bitwarden.com/help/huntress-siem/", + image: "../../../../../../../images/integrations/logo-huntress-siem.svg", + type: IntegrationType.EVENT, + description: "huntressEventIntegrationDesc", + canSetupConnection: true, + integrationType: OrganizationIntegrationType.Hec, + }); + } + + const orgIntegrations = await firstValueFrom( + this.organizationIntegrationService.integrations$.pipe(take(1)), + ); + + const merged = integrations.map((i) => ({ + ...i, + organizationIntegration: orgIntegrations.find((o) => o.serviceName === i.name) ?? null, + })); + + this.state.setIntegrations(merged); + + return true; + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.state.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.state.ts new file mode 100644 index 00000000000..5e7e6a78ba4 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.state.ts @@ -0,0 +1,22 @@ +import { Injectable, signal } from "@angular/core"; + +import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; + +@Injectable() +export class OrganizationIntegrationsState { + private readonly _integrations = signal([]); + private readonly _organization = signal(undefined); + + // Signals + integrations = this._integrations.asReadonly(); + organization = this._organization.asReadonly(); + + setOrganization(val: Organization | null) { + this._organization.set(val ?? undefined); + } + + setIntegrations(val: Integration[]) { + this._integrations.set(val); + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.html new file mode 100644 index 00000000000..ca5ed9ee30c --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.html @@ -0,0 +1,12 @@ +@let integrationsList = integrations(); +
+

{{ "singleSignOn" | i18n }}

+

+ {{ "ssoDescStart" | i18n }} + {{ "singleSignOn" | i18n }} + {{ "ssoDescEnd" | i18n }} +

+ +
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.ts new file mode 100644 index 00000000000..d0d2a1666f2 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/single-sign-on/single-sign-on.component.ts @@ -0,0 +1,22 @@ +import { Component } from "@angular/core"; + +import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { IntegrationGridComponent } from "../integration-grid/integration-grid.component"; +import { FilterIntegrationsPipe } from "../integrations.pipe"; +import { OrganizationIntegrationsState } from "../organization-integrations.state"; + +// 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: "single-sign-on", + templateUrl: "single-sign-on.component.html", + imports: [SharedModule, IntegrationGridComponent, FilterIntegrationsPipe], +}) +export class SingleSignOnComponent { + integrations = this.state.integrations; + IntegrationType = IntegrationType; + + constructor(private state: OrganizationIntegrationsState) {} +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.html new file mode 100644 index 00000000000..a254f334e21 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.html @@ -0,0 +1,25 @@ +@let org = organization(); +@let integrationsList = integrations(); + +
+

+ {{ "scimIntegration" | i18n }} +

+

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

+ +
+
+

+ {{ "bwdc" | i18n }} +

+

{{ "bwdcDesc" | i18n }}

+ +
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.ts new file mode 100644 index 00000000000..f484674d224 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/user-provisioning/user-provisioning.component.ts @@ -0,0 +1,26 @@ +import { Component } from "@angular/core"; + +import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { IntegrationGridComponent } from "../integration-grid/integration-grid.component"; +import { FilterIntegrationsPipe } from "../integrations.pipe"; +import { OrganizationIntegrationsState } from "../organization-integrations.state"; + +// 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: "user-provisioning", + templateUrl: "user-provisioning.component.html", + imports: [SharedModule, IntegrationGridComponent, FilterIntegrationsPipe], +}) +export class UserProvisioningComponent { + organization = this.state.organization; + integrations = this.state.integrations; + + constructor(private state: OrganizationIntegrationsState) {} + + get IntegrationType(): typeof IntegrationType { + return IntegrationType; + } +} 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/dirt/reports/member-access-report/services/member-access-report.service.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts index 3bd74391419..bb14b61006e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts @@ -3,7 +3,7 @@ import { Injectable } from "@angular/core"; import { firstValueFrom, map } from "rxjs"; -import { CollectionAccessSelectionView } from "@bitwarden/admin-console/common"; +import { CollectionAccessSelectionView } from "@bitwarden/common/admin-console/models/collections"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; 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/collections/abstractions/collection-admin.service.ts b/libs/admin-console/src/common/collections/abstractions/collection-admin.service.ts index 9fde1b2090b..93e1aa1f32f 100644 --- a/libs/admin-console/src/common/collections/abstractions/collection-admin.service.ts +++ b/libs/admin-console/src/common/collections/abstractions/collection-admin.service.ts @@ -1,10 +1,12 @@ import { Observable } from "rxjs"; -import { CollectionDetailsResponse } from "@bitwarden/admin-console/common"; +import { + CollectionAdminView, + CollectionAccessSelectionView, + CollectionDetailsResponse, +} from "@bitwarden/common/admin-console/models/collections"; import { UserId } from "@bitwarden/common/types/guid"; -import { CollectionAccessSelectionView, CollectionAdminView } from "../models"; - export abstract class CollectionAdminService { abstract collectionAdminViews$( organizationId: string, diff --git a/libs/admin-console/src/common/collections/abstractions/collection.service.ts b/libs/admin-console/src/common/collections/abstractions/collection.service.ts index f0f02ee377e..2c44c8e095a 100644 --- a/libs/admin-console/src/common/collections/abstractions/collection.service.ts +++ b/libs/admin-console/src/common/collections/abstractions/collection.service.ts @@ -1,11 +1,14 @@ import { Observable } from "rxjs"; +import { + CollectionView, + Collection, + CollectionData, +} from "@bitwarden/common/admin-console/models/collections"; import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; -import { CollectionData, Collection, CollectionView } from "../models"; - export abstract class CollectionService { abstract encryptedCollections$(userId: UserId): Observable; abstract decryptedCollections$(userId: UserId): Observable; diff --git a/libs/admin-console/src/common/collections/models/collection-with-id.request.ts b/libs/admin-console/src/common/collections/models/collection-with-id.request.ts index 0f7b83705ef..f8f363e1fa8 100644 --- a/libs/admin-console/src/common/collections/models/collection-with-id.request.ts +++ b/libs/admin-console/src/common/collections/models/collection-with-id.request.ts @@ -1,4 +1,5 @@ -import { Collection } from "./collection"; +import { Collection } from "@bitwarden/common/admin-console/models/collections"; + import { BaseCollectionRequest } from "./collection.request"; export class CollectionWithIdRequest extends BaseCollectionRequest { diff --git a/libs/admin-console/src/common/collections/models/collection.spec.ts b/libs/admin-console/src/common/collections/models/collection.spec.ts index 16066f88ce1..ab81756ccdd 100644 --- a/libs/admin-console/src/common/collections/models/collection.spec.ts +++ b/libs/admin-console/src/common/collections/models/collection.spec.ts @@ -1,15 +1,17 @@ import { MockProxy, mock } from "jest-mock-extended"; +import { + CollectionDetailsResponse, + Collection, + CollectionTypes, + CollectionData, +} from "@bitwarden/common/admin-console/models/collections"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { makeSymmetricCryptoKey } from "@bitwarden/common/spec"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; -import { Collection, CollectionTypes } from "./collection"; -import { CollectionData } from "./collection.data"; -import { CollectionDetailsResponse } from "./collection.response"; - describe("Collection", () => { let data: CollectionData; let encService: MockProxy; diff --git a/libs/admin-console/src/common/collections/models/index.ts b/libs/admin-console/src/common/collections/models/index.ts index d04ec663306..c36e48fbf98 100644 --- a/libs/admin-console/src/common/collections/models/index.ts +++ b/libs/admin-console/src/common/collections/models/index.ts @@ -1,9 +1,3 @@ export * from "./bulk-collection-access.request"; -export * from "./collection-access-selection.view"; -export * from "./collection-admin.view"; -export * from "./collection"; -export * from "./collection.data"; -export * from "./collection.view"; export * from "./collection.request"; -export * from "./collection.response"; export * from "./collection-with-id.request"; diff --git a/libs/admin-console/src/common/collections/services/collection.state.ts b/libs/admin-console/src/common/collections/services/collection.state.ts index 9ca6faac75b..9a96a7015b1 100644 --- a/libs/admin-console/src/common/collections/services/collection.state.ts +++ b/libs/admin-console/src/common/collections/services/collection.state.ts @@ -1,5 +1,6 @@ import { Jsonify } from "type-fest"; +import { CollectionView, CollectionData } from "@bitwarden/common/admin-console/models/collections"; import { COLLECTION_DISK, COLLECTION_MEMORY, @@ -7,8 +8,6 @@ import { } from "@bitwarden/common/platform/state"; import { CollectionId } from "@bitwarden/common/types/guid"; -import { CollectionData, CollectionView } from "../models"; - export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record( COLLECTION_DISK, "collections", diff --git a/libs/admin-console/src/common/collections/services/default-collection-admin.service.ts b/libs/admin-console/src/common/collections/services/default-collection-admin.service.ts index ca797a0f9ae..f7f3274a648 100644 --- a/libs/admin-console/src/common/collections/services/default-collection-admin.service.ts +++ b/libs/admin-console/src/common/collections/services/default-collection-admin.service.ts @@ -5,6 +5,14 @@ import { getOrganizationById, OrganizationService, } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { + CollectionAccessSelectionView, + CollectionAdminView, + CollectionAccessDetailsResponse, + CollectionDetailsResponse, + CollectionResponse, + CollectionData, +} from "@bitwarden/common/admin-console/models/collections"; import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; @@ -13,13 +21,7 @@ import { KeyService } from "@bitwarden/key-management"; import { CollectionAdminService, CollectionService } from "../abstractions"; import { - CollectionData, - CollectionAccessDetailsResponse, - CollectionDetailsResponse, - CollectionResponse, BulkCollectionAccessRequest, - CollectionAccessSelectionView, - CollectionAdminView, BaseCollectionRequest, UpdateCollectionRequest, CreateCollectionRequest, diff --git a/libs/admin-console/src/common/collections/services/default-collection.service.spec.ts b/libs/admin-console/src/common/collections/services/default-collection.service.spec.ts index 2eaaa48594e..950b6a59dcd 100644 --- a/libs/admin-console/src/common/collections/services/default-collection.service.spec.ts +++ b/libs/admin-console/src/common/collections/services/default-collection.service.spec.ts @@ -1,6 +1,11 @@ import { mock, MockProxy } from "jest-mock-extended"; import { combineLatest, first, firstValueFrom, of, ReplaySubject, takeWhile } from "rxjs"; +import { + CollectionView, + CollectionTypes, + CollectionData, +} from "@bitwarden/common/admin-console/models/collections"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -18,8 +23,6 @@ import { OrgKey } from "@bitwarden/common/types/key"; import { newGuid } from "@bitwarden/guid"; import { KeyService } from "@bitwarden/key-management"; -import { CollectionData, CollectionTypes, CollectionView } from "../models"; - import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state"; import { DefaultCollectionService } from "./default-collection.service"; diff --git a/libs/admin-console/src/common/collections/services/default-collection.service.ts b/libs/admin-console/src/common/collections/services/default-collection.service.ts index ccc2e6f0de5..9519e39504e 100644 --- a/libs/admin-console/src/common/collections/services/default-collection.service.ts +++ b/libs/admin-console/src/common/collections/services/default-collection.service.ts @@ -12,6 +12,11 @@ import { switchMap, } from "rxjs"; +import { + CollectionView, + Collection, + CollectionData, +} from "@bitwarden/common/admin-console/models/collections"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -23,7 +28,6 @@ import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { KeyService } from "@bitwarden/key-management"; import { CollectionService } from "../abstractions/collection.service"; -import { Collection, CollectionData, CollectionView } from "../models"; import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state"; 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 71d228ff822..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, @@ -264,6 +266,13 @@ export abstract class OrganizationUserApiService { ids: string[], ): Promise>; + /** + * Revoke the current user's access to the organization + * if they decline an item transfer under the Organization Data Ownership policy. + * @param organizationId - Identifier for the organization the user belongs to + */ + abstract revokeSelf(organizationId: string): Promise; + /** * Restore an organization user's access to the organization * @param organizationId - Identifier for the organization the user belongs to @@ -271,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 @@ -282,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..f2b51d6747a --- /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 { + ids: string[]; + defaultUserCollectionName: EncString | undefined; + + constructor(ids: string[], defaultUserCollectionName?: EncString) { + this.ids = ids; + 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 869d84a8c8e..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, @@ -339,6 +341,16 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer return new ListResponse(r, OrganizationUserBulkResponse); } + revokeSelf(organizationId: string): Promise { + return this.apiService.send( + "PUT", + "/organizations/" + organizationId + "/users/revoke-self", + null, + true, + false, + ); + } + restoreOrganizationUser(organizationId: string, id: string): Promise { return this.apiService.send( "PUT", @@ -349,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[], @@ -363,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..7eca35fd36e 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({ + ids: 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 @@
- +
{ + if (userId == null) { + throw new Error("User ID is required."); + } + + for (const [key, value] of Object.entries(credentials)) { + if (value == null) { + throw new Error(`${key} is required.`); + } + } + + const { newPasswordHint, orgSsoIdentifier, orgId, resetPasswordAutoEnroll, newPassword, salt } = + credentials; + + const organizationKeys = await this.organizationApiService.getKeys(orgId); + if (organizationKeys == null) { + throw new Error("Organization keys response is null."); + } + + const registerResult = await firstValueFrom( + this.registerSdkService.registerClient$(userId).pipe( + concatMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + + using ref = sdk.take(); + return await ref.value + .auth() + .registration() + .post_keys_for_jit_password_registration({ + org_id: asUuid(orgId), + org_public_key: organizationKeys.publicKey, + master_password: newPassword, + master_password_hint: newPasswordHint, + salt: salt, + organization_sso_identifier: orgSsoIdentifier, + user_id: asUuid(userId), + reset_password_enroll: resetPasswordAutoEnroll, + }); + }), + ), + ); + + if (!("V2" in registerResult.account_cryptographic_state)) { + throw new Error("Unexpected V2 account cryptographic state"); + } + + // Note: When SDK state management matures, these should be moved into post_keys_for_tde_registration + // Set account cryptography state + await this.accountCryptographicStateService.setAccountCryptographicState( + registerResult.account_cryptographic_state, + userId, + ); + + // Clear force set password reason to allow navigation back to vault. + await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId); + + const masterPasswordUnlockData = MasterPasswordUnlockData.fromSdk( + registerResult.master_password_unlock, + ); + await this.masterPasswordService.setMasterPasswordUnlockData(masterPasswordUnlockData, userId); + + await this.keyService.setUserKey( + SymmetricCryptoKey.fromString(registerResult.user_key) as UserKey, + userId, + ); + + await this.updateLegacyState( + newPassword, + fromSdkKdfConfig(registerResult.master_password_unlock.kdf), + new EncString(registerResult.master_password_unlock.masterKeyWrappedUserKey), + userId, + masterPasswordUnlockData, + ); + } + + /** + * @deprecated To be removed in PM-28143 + */ private async makeMasterKeyEncryptedUserKey( masterKey: MasterKey, userId: UserId, @@ -244,7 +384,40 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi await this.keyService.setUserKey(masterKeyEncryptedUserKey[0], userId); } + // Deprecated legacy support - to be removed in future + private async updateLegacyState( + newPassword: string, + kdfConfig: KdfConfig, + masterKeyWrappedUserKey: EncString, + userId: UserId, + masterPasswordUnlockData: MasterPasswordUnlockData, + ) { + // TODO Remove HasMasterPassword from UserDecryptionOptions https://bitwarden.atlassian.net/browse/PM-23475 + const userDecryptionOpts = await firstValueFrom( + this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), + ); + userDecryptionOpts.hasMasterPassword = true; + await this.userDecryptionOptionsService.setUserDecryptionOptionsById( + userId, + userDecryptionOpts, + ); + + // TODO Remove KDF state https://bitwarden.atlassian.net/browse/PM-30661 + await this.kdfConfigService.setKdfConfig(userId, kdfConfig); + // TODO Remove master key memory state https://bitwarden.atlassian.net/browse/PM-23477 + await this.masterPasswordService.setMasterKeyEncryptedUserKey(masterKeyWrappedUserKey, userId); + + // TODO Removed with https://bitwarden.atlassian.net/browse/PM-30676 + await this.masterPasswordService.setLegacyMasterKeyFromUnlockData( + newPassword, + masterPasswordUnlockData, + userId, + ); + } + /** + * @deprecated To be removed in PM-28143 + * * As part of [PM-28494], adding this setting path to accommodate the changes that are * emerging with pm-23246-unlock-with-master-password-unlock-data. * Without this, immediately locking/unlocking the vault with the new password _may_ still fail @@ -310,44 +483,4 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi enrollmentRequest, ); } - - async setInitialPasswordTdeOffboarding( - credentials: SetInitialPasswordTdeOffboardingCredentials, - userId: UserId, - ) { - const { newMasterKey, newServerMasterKeyHash, newPasswordHint } = credentials; - for (const [key, value] of Object.entries(credentials)) { - if (value == null) { - throw new Error(`${key} not found. Could not set password.`); - } - } - - if (userId == null) { - throw new Error("userId not found. Could not set password."); - } - - const userKey = await firstValueFrom(this.keyService.userKey$(userId)); - if (userKey == null) { - throw new Error("userKey not found. Could not set password."); - } - - const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey( - newMasterKey, - userKey, - ); - - if (!newMasterKeyEncryptedUserKey[1].encryptedString) { - throw new Error("newMasterKeyEncryptedUserKey not found. Could not set password."); - } - - const request = new UpdateTdeOffboardingPasswordRequest(); - request.key = newMasterKeyEncryptedUserKey[1].encryptedString; - request.newMasterPasswordHash = newServerMasterKeyHash; - request.masterPasswordHint = newPasswordHint; - - await this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request); - - // Clear force set password reason to allow navigation back to vault. - await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId); - } } diff --git a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts index af4505371d3..8b64e20ce7b 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts @@ -1,5 +1,8 @@ -import { MockProxy, mock } from "jest-mock-extended"; -import { BehaviorSubject, of } from "rxjs"; +// Polyfill for Symbol.dispose required by the service's use of `using` keyword +import "core-js/proposals/explicit-resource-management"; + +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, Observable, of } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports @@ -27,17 +30,35 @@ import { EncString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { + MasterPasswordSalt, + MasterPasswordUnlockData, +} from "@bitwarden/common/key-management/master-password/types/master-password.types"; import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service"; +import { Rc } from "@bitwarden/common/platform/misc/reference-counting/rc"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { makeEncString, makeSymmetricCryptoKey } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; -import { UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey, UserPrivateKey, UserPublicKey } from "@bitwarden/common/types/key"; -import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management"; +import { + DEFAULT_KDF_CONFIG, + fromSdkKdfConfig, + KdfConfigService, + KeyService, +} from "@bitwarden/key-management"; +import { + AuthClient, + BitwardenClient, + WrappedAccountCryptographicState, +} from "@bitwarden/sdk-internal"; import { DefaultSetInitialPasswordService } from "./default-set-initial-password.service.implementation"; import { + InitializeJitPasswordCredentials, SetInitialPasswordCredentials, SetInitialPasswordService, SetInitialPasswordTdeOffboardingCredentials, @@ -58,6 +79,7 @@ describe("DefaultSetInitialPasswordService", () => { let organizationUserApiService: MockProxy; let userDecryptionOptionsService: MockProxy; let accountCryptographicStateService: MockProxy; + const registerSdkService = mock(); let userId: UserId; let userKey: UserKey; @@ -94,6 +116,7 @@ describe("DefaultSetInitialPasswordService", () => { organizationUserApiService, userDecryptionOptionsService, accountCryptographicStateService, + registerSdkService, ); }); @@ -101,6 +124,10 @@ describe("DefaultSetInitialPasswordService", () => { 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; @@ -397,7 +424,6 @@ describe("DefaultSetInitialPasswordService", () => { // Assert expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); - expect(keyService.setPrivateKey).toHaveBeenCalledWith(keyPair[1].encryptedString, userId); expect( accountCryptographicStateService.setAccountCryptographicState, ).toHaveBeenCalledWith( @@ -640,7 +666,9 @@ describe("DefaultSetInitialPasswordService", () => { // Assert expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); - expect(keyService.setPrivateKey).not.toHaveBeenCalled(); + expect( + accountCryptographicStateService.setAccountCryptographicState, + ).not.toHaveBeenCalled(); }); it("should set the local master key hash to state", async () => { @@ -834,4 +862,246 @@ describe("DefaultSetInitialPasswordService", () => { }); }); }); + + describe("initializePasswordJitPasswordUserV2Encryption()", () => { + let mockSdkRef: { + value: MockProxy; + [Symbol.dispose]: jest.Mock; + }; + let mockSdk: { + take: jest.Mock; + }; + let mockRegistration: jest.Mock; + + const userId = "d4e2e3a1-1b5e-4c3b-8d7a-9f8e7d6c5b4a" as UserId; + const orgId = "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d" as OrganizationId; + + const credentials: InitializeJitPasswordCredentials = { + newPasswordHint: "test-hint", + orgSsoIdentifier: "org-sso-id", + orgId: orgId, + resetPasswordAutoEnroll: false, + newPassword: "Test@Password123!", + salt: "user@example.com" as unknown as MasterPasswordSalt, + }; + + const orgKeys: OrganizationKeysResponse = { + publicKey: "org-public-key-base64", + privateKey: "org-private-key-encrypted", + } as OrganizationKeysResponse; + + const sdkRegistrationResult = { + account_cryptographic_state: { + V2: { + private_key: makeEncString().encryptedString!, + signed_public_key: "test-signed-public-key", + signing_key: makeEncString().encryptedString!, + security_state: "test-security-state", + }, + }, + master_password_unlock: { + kdf: { + pBKDF2: { + iterations: 600000, + }, + }, + masterKeyWrappedUserKey: makeEncString().encryptedString!, + salt: "user@example.com" as unknown as MasterPasswordSalt, + }, + user_key: makeSymmetricCryptoKey(64).keyB64, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockSdkRef = { + value: mock(), + [Symbol.dispose]: jest.fn(), + }; + + mockSdkRef.value.auth.mockReturnValue({ + registration: jest.fn().mockReturnValue({ + post_keys_for_jit_password_registration: jest.fn(), + }), + } as unknown as AuthClient); + + mockSdk = { + take: jest.fn().mockReturnValue(mockSdkRef), + }; + + registerSdkService.registerClient$.mockReturnValue( + of(mockSdk) as unknown as Observable>, + ); + + organizationApiService.getKeys.mockResolvedValue(orgKeys); + + mockRegistration = mockSdkRef.value.auth().registration() + .post_keys_for_jit_password_registration as unknown as jest.Mock; + mockRegistration.mockResolvedValue(sdkRegistrationResult); + + const mockUserDecryptionOpts = new UserDecryptionOptions({ hasMasterPassword: false }); + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of(mockUserDecryptionOpts), + ); + }); + + it("should successfully initialize JIT password user", async () => { + await sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId); + + expect(organizationApiService.getKeys).toHaveBeenCalledWith(credentials.orgId); + + expect(registerSdkService.registerClient$).toHaveBeenCalledWith(userId); + expect(mockRegistration).toHaveBeenCalledWith( + expect.objectContaining({ + org_id: credentials.orgId, + org_public_key: orgKeys.publicKey, + master_password: credentials.newPassword, + master_password_hint: credentials.newPasswordHint, + salt: credentials.salt, + organization_sso_identifier: credentials.orgSsoIdentifier, + user_id: userId, + reset_password_enroll: credentials.resetPasswordAutoEnroll, + }), + ); + + expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith( + sdkRegistrationResult.account_cryptographic_state, + userId, + ); + + expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith( + ForceSetPasswordReason.None, + userId, + ); + + expect(masterPasswordService.setMasterPasswordUnlockData).toHaveBeenCalledWith( + MasterPasswordUnlockData.fromSdk(sdkRegistrationResult.master_password_unlock), + userId, + ); + + expect(keyService.setUserKey).toHaveBeenCalledWith( + SymmetricCryptoKey.fromString(sdkRegistrationResult.user_key) as UserKey, + userId, + ); + + // Verify legacy state updates below + expect(userDecryptionOptionsService.userDecryptionOptionsById$).toHaveBeenCalledWith(userId); + expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith( + userId, + expect.objectContaining({ hasMasterPassword: true }), + ); + + expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith( + userId, + fromSdkKdfConfig(sdkRegistrationResult.master_password_unlock.kdf), + ); + + expect(masterPasswordService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( + new EncString(sdkRegistrationResult.master_password_unlock.masterKeyWrappedUserKey), + userId, + ); + + expect(masterPasswordService.setLegacyMasterKeyFromUnlockData).toHaveBeenCalledWith( + credentials.newPassword, + MasterPasswordUnlockData.fromSdk(sdkRegistrationResult.master_password_unlock), + userId, + ); + }); + + describe("input validation", () => { + it.each([ + "newPasswordHint", + "orgSsoIdentifier", + "orgId", + "resetPasswordAutoEnroll", + "newPassword", + "salt", + ])("should throw error when %s is null", async (field) => { + const invalidCredentials = { + ...credentials, + [field]: null, + } as unknown as InitializeJitPasswordCredentials; + + const promise = sut.initializePasswordJitPasswordUserV2Encryption( + invalidCredentials, + userId, + ); + + await expect(promise).rejects.toThrow(`${field} is required.`); + + expect(organizationApiService.getKeys).not.toHaveBeenCalled(); + expect(registerSdkService.registerClient$).not.toHaveBeenCalled(); + }); + + it("should throw error when userId is null", async () => { + const nullUserId = null as unknown as UserId; + + const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, nullUserId); + + await expect(promise).rejects.toThrow("User ID is required."); + expect(organizationApiService.getKeys).not.toHaveBeenCalled(); + }); + }); + + describe("organization API error handling", () => { + it("should throw when organizationApiService.getKeys returns null", async () => { + organizationApiService.getKeys.mockResolvedValue( + null as unknown as OrganizationKeysResponse, + ); + + const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId); + + await expect(promise).rejects.toThrow("Organization keys response is null."); + expect(organizationApiService.getKeys).toHaveBeenCalledWith(credentials.orgId); + expect(registerSdkService.registerClient$).not.toHaveBeenCalled(); + }); + + it("should throw when organizationApiService.getKeys rejects", async () => { + const apiError = new Error("API network error"); + organizationApiService.getKeys.mockRejectedValue(apiError); + + const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId); + + await expect(promise).rejects.toThrow("API network error"); + expect(registerSdkService.registerClient$).not.toHaveBeenCalled(); + }); + }); + + describe("SDK error handling", () => { + it("should throw when SDK is not available", async () => { + organizationApiService.getKeys.mockResolvedValue(orgKeys); + registerSdkService.registerClient$.mockReturnValue( + of(null) as unknown as Observable>, + ); + + const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId); + + await expect(promise).rejects.toThrow("SDK not available"); + }); + + it("should throw when SDK registration fails", async () => { + const sdkError = new Error("SDK crypto operation failed"); + + organizationApiService.getKeys.mockResolvedValue(orgKeys); + mockRegistration.mockRejectedValue(sdkError); + + const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId); + + await expect(promise).rejects.toThrow("SDK crypto operation failed"); + }); + }); + + it("should throw when account_cryptographic_state is not V2", async () => { + const invalidResult = { + ...sdkRegistrationResult, + account_cryptographic_state: { V1: {} } as unknown as WrappedAccountCryptographicState, + }; + + mockRegistration.mockResolvedValue(invalidResult); + + const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId); + + await expect(promise).rejects.toThrow("Unexpected V2 account cryptographic state"); + }); + }); }); 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 0e0bae62b9a..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 @@ -21,14 +21,17 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; -import { assertTruthy, assertNonNullish } from "@bitwarden/common/auth/utils"; +import { assertNonNullish, assertTruthy } from "@bitwarden/common/auth/utils"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { HashPurpose } from "@bitwarden/common/platform/enums"; import { SyncService } from "@bitwarden/common/platform/sync"; -import { UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { AnonLayoutWrapperDataService, ButtonModule, @@ -36,9 +39,11 @@ import { DialogService, ToastService, } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; import { I18nPipe } from "@bitwarden/ui-common"; import { + InitializeJitPasswordCredentials, SetInitialPasswordCredentials, SetInitialPasswordService, SetInitialPasswordTdeOffboardingCredentials, @@ -73,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, @@ -86,6 +92,7 @@ export class SetInitialPasswordComponent implements OnInit { private syncService: SyncService, private toastService: ToastService, private validationService: ValidationService, + private configService: ConfigService, ) {} async ngOnInit() { @@ -101,6 +108,107 @@ export class SetInitialPasswordComponent implements OnInit { this.initializing = false; } + protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) { + this.submitting = true; + + 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; + } + + // 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; + } + case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP: + await this.setInitialPassword(passwordInputResult); + break; + case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER: + await this.setInitialPasswordTdeOffboarding(passwordInputResult); + break; + default: + this.logService.error( + `Unexpected user type: ${this.userType}. Could not set initial password.`, + ); + this.validationService.showError("Unexpected user type. Could not set initial password."); + } + } + + protected async logout() { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "logOut" }, + content: { key: "logOutConfirmation" }, + acceptButtonText: { key: "logOut" }, + type: "warning", + }); + + if (confirmed) { + this.messagingService.send("logout"); + } + } + private async establishUserType() { if (!this.userId) { throw new Error("userId not found. Could not determine user type."); @@ -189,25 +297,45 @@ export class SetInitialPasswordComponent implements OnInit { } } - protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) { - this.submitting = true; + private async setInitialPasswordJitMPUserV2Encryption(passwordInputResult: PasswordInputResult) { + const ctx = "Could not set initial password for SSO JIT master password encryption user."; + assertTruthy(passwordInputResult.newPassword, "newPassword", ctx); + assertTruthy(passwordInputResult.salt, "salt", ctx); + assertTruthy(this.orgSsoIdentifier, "orgSsoIdentifier", ctx); + assertTruthy(this.orgId, "orgId", ctx); + assertTruthy(this.userId, "userId", ctx); + assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish + assertNonNullish(this.resetPasswordAutoEnroll, "resetPasswordAutoEnroll", ctx); // can have `false` as a valid value, so check non-nullish - switch (this.userType) { - case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER: - case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP: - await this.setInitialPassword(passwordInputResult); - break; - case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER: - await this.setInitialPasswordTdeOffboarding(passwordInputResult); - break; - default: - this.logService.error( - `Unexpected user type: ${this.userType}. Could not set initial password.`, - ); - this.validationService.showError("Unexpected user type. Could not set initial password."); + try { + const credentials: InitializeJitPasswordCredentials = { + newPasswordHint: passwordInputResult.newPasswordHint, + orgSsoIdentifier: this.orgSsoIdentifier, + orgId: this.orgId as OrganizationId, + resetPasswordAutoEnroll: this.resetPasswordAutoEnroll, + newPassword: passwordInputResult.newPassword, + salt: passwordInputResult.salt, + }; + + await this.setInitialPasswordService.initializePasswordJitPasswordUserV2Encryption( + credentials, + this.userId, + ); + + this.showSuccessToastByUserType(); + + this.submitting = false; + await this.router.navigate(["vault"]); + } catch (e) { + this.logService.error("Error setting initial password", e); + this.validationService.showError(e); + this.submitting = false; } } + /** + * @deprecated To be removed in PM-28143 + */ private async setInitialPassword(passwordInputResult: PasswordInputResult) { const ctx = "Could not set initial password."; assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx); @@ -307,17 +435,4 @@ export class SetInitialPasswordComponent implements OnInit { }); } } - - protected async logout() { - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "logOut" }, - content: { key: "logOutConfirmation" }, - acceptButtonText: { key: "logOut" }, - type: "warning", - }); - - if (confirmed) { - this.messagingService.send("logout"); - } - } } 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 5620194e1bb..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 @@ -1,5 +1,5 @@ import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types"; -import { UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { MasterKey } from "@bitwarden/common/types/key"; import { KdfConfig } from "@bitwarden/key-management"; @@ -61,6 +61,24 @@ export interface SetInitialPasswordTdeOffboardingCredentials { newPasswordHint: string; } +/** + * Credentials required to initialize a just-in-time (JIT) provisioned user with a master password. + */ +export interface InitializeJitPasswordCredentials { + /** Hint for the new master password */ + newPasswordHint: string; + /** SSO identifier for the organization */ + orgSsoIdentifier: string; + /** Organization ID */ + orgId: OrganizationId; + /** Whether to auto-enroll the user in account recovery (reset password) */ + resetPasswordAutoEnroll: boolean; + /** The new master password */ + newPassword: string; + /** Master password salt (typically the user's email) */ + salt: MasterPasswordSalt; +} + /** * Handles setting an initial password for an existing authed user. * @@ -69,6 +87,8 @@ export interface SetInitialPasswordTdeOffboardingCredentials { */ 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} @@ -95,4 +115,14 @@ export abstract class SetInitialPasswordService { credentials: SetInitialPasswordTdeOffboardingCredentials, userId: UserId, ) => Promise; + + /** + * Initializes a JIT-provisioned user's cryptographic state and enrolls them in master password unlock. + * @param credentials The credentials needed to initialize the JIT password user + * @param userId The account userId + */ + abstract initializePasswordJitPasswordUserV2Encryption( + credentials: InitializeJitPasswordCredentials, + userId: UserId, + ): Promise; } diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html index 52cd36e9356..1e35b731dfc 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html @@ -50,6 +50,7 @@
{{ 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/auth-request-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts index 4703472d480..275f2d97aa4 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts @@ -180,7 +180,10 @@ describe("AuthRequestLoginStrategy", () => { ); expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, mockUserId); expect(deviceTrustService.trustDeviceIfRequired).toHaveBeenCalled(); - expect(keyService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, mockUserId); + expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith( + { V1: { private_key: tokenResponse.privateKey } }, + mockUserId, + ); }); it("sets keys after a successful authentication when only userKey provided in login credentials", async () => { @@ -207,7 +210,10 @@ describe("AuthRequestLoginStrategy", () => { mockUserId, ); expect(keyService.setUserKey).toHaveBeenCalledWith(decUserKey, mockUserId); - expect(keyService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, mockUserId); + expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith( + { V1: { private_key: tokenResponse.privateKey } }, + mockUserId, + ); // trustDeviceIfRequired should be called expect(deviceTrustService.trustDeviceIfRequired).not.toHaveBeenCalled(); diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts index 16af5fa77dc..66b9ee83919 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts @@ -120,20 +120,14 @@ export class AuthRequestLoginStrategy extends LoginStrategy { } } - protected override async setPrivateKey( + protected override async setAccountCryptographicState( response: IdentityTokenResponse, userId: UserId, ): Promise { - await this.keyService.setPrivateKey( - response.privateKey ?? (await this.createKeyPairForOldAccount(userId)), + await this.accountCryptographicStateService.setAccountCryptographicState( + response.accountKeysResponseModel.toWrappedAccountCryptographicState(), userId, ); - if (response.accountKeysResponseModel) { - await this.accountCryptographicStateService.setAccountCryptographicState( - response.accountKeysResponseModel.toWrappedAccountCryptographicState(), - userId, - ); - } } exportCache(): CacheData { diff --git a/libs/auth/src/common/login-strategies/login.strategy.spec.ts b/libs/auth/src/common/login-strategies/login.strategy.spec.ts index 113f9f3f0d9..5ff440d1218 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -19,7 +19,6 @@ import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.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, @@ -38,15 +37,12 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { PasswordStrengthServiceAbstraction, PasswordStrengthService, } from "@bitwarden/common/tools/password-strength"; -import { CsprngArray } from "@bitwarden/common/types/csprng"; import { UserId } from "@bitwarden/common/types/guid"; -import { UserKey, MasterKey } from "@bitwarden/common/types/key"; import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; import { LoginStrategyServiceAbstraction } from "../abstractions"; @@ -109,6 +105,12 @@ export function identityTokenResponseFactory( token_type: "Bearer", MasterPasswordPolicy: masterPasswordPolicyResponse, UserDecryptionOptions: userDecryptionOptions || defaultUserDecryptionOptionsServerResponse, + AccountKeys: { + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: privateKey, + publicKey: "PUBLIC_KEY", + }, + }, }); } @@ -201,19 +203,7 @@ describe("LoginStrategy", () => { }); describe("base class", () => { - const userKeyBytesLength = 64; - const masterKeyBytesLength = 64; - let userKey: UserKey; - let masterKey: MasterKey; - beforeEach(() => { - userKey = new SymmetricCryptoKey( - new Uint8Array(userKeyBytesLength).buffer as CsprngArray, - ) as UserKey; - masterKey = new SymmetricCryptoKey( - new Uint8Array(masterKeyBytesLength).buffer as CsprngArray, - ) as MasterKey; - const mockVaultTimeoutAction = VaultTimeoutAction.Lock; const mockVaultTimeoutActionBSub = new BehaviorSubject( mockVaultTimeoutAction, @@ -335,39 +325,6 @@ describe("LoginStrategy", () => { userId, ); }); - - it("makes a new public and private key for an old account", async () => { - const tokenResponse = identityTokenResponseFactory(); - tokenResponse.privateKey = null; - keyService.makeKeyPair.mockResolvedValue(["PUBLIC_KEY", new EncString("PRIVATE_KEY")]); - keyService.userKey$.mockReturnValue(new BehaviorSubject(userKey).asObservable()); - - apiService.postIdentityToken.mockResolvedValue(tokenResponse); - masterPasswordService.masterKeySubject.next(masterKey); - masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); - - await passwordLoginStrategy.logIn(credentials); - - // User symmetric key must be set before the new RSA keypair is generated - expect(keyService.setUserKey).toHaveBeenCalled(); - expect(keyService.makeKeyPair).toHaveBeenCalled(); - expect(keyService.setUserKey.mock.invocationCallOrder[0]).toBeLessThan( - keyService.makeKeyPair.mock.invocationCallOrder[0], - ); - - expect(apiService.postAccountKeys).toHaveBeenCalled(); - }); - - it("throws if userKey is CoseEncrypt0 (V2 encryption) in createKeyPairForOldAccount", async () => { - keyService.userKey$.mockReturnValue( - new BehaviorSubject({ - inner: () => ({ type: 7 }), - } as unknown as UserKey).asObservable(), - ); - await expect(passwordLoginStrategy["createKeyPairForOldAccount"](userId)).resolves.toBe( - undefined, - ); - }); }); describe("Two-factor authentication", () => { diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index 16f5e1e4320..62121d8c937 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -25,7 +25,6 @@ import { VaultTimeoutAction, VaultTimeoutSettingsService, } from "@bitwarden/common/key-management/vault-timeout"; -import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -33,7 +32,6 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { EncryptionType } from "@bitwarden/common/platform/enums"; import { UserId } from "@bitwarden/common/types/guid"; import { KeyService, KdfConfigService } from "@bitwarden/key-management"; @@ -265,7 +263,7 @@ export abstract class LoginStrategy { await this.setMasterKey(response, userId); await this.setUserKey(response, userId); - await this.setPrivateKey(response, userId); + await this.setAccountCryptographicState(response, userId); // This needs to run after the keys are set because it checks for the existence of the encrypted private key await this.processForceSetPasswordReason(response.forcePasswordReset, userId); @@ -283,7 +281,10 @@ export abstract class LoginStrategy { protected abstract setUserKey(response: IdentityTokenResponse, userId: UserId): Promise; - protected abstract setPrivateKey(response: IdentityTokenResponse, userId: UserId): Promise; + protected abstract setAccountCryptographicState( + response: IdentityTokenResponse, + userId: UserId, + ): Promise; // Old accounts used master key for encryption. We are forcing migrations but only need to // check on password logins @@ -315,28 +316,6 @@ export abstract class LoginStrategy { return true; } - protected async createKeyPairForOldAccount(userId: UserId) { - try { - const userKey = await firstValueFrom(this.keyService.userKey$(userId)); - if (userKey === null) { - throw new Error("User key is null when creating key pair for old account"); - } - - if (userKey.inner().type == EncryptionType.CoseEncrypt0) { - throw new Error("Cannot create key pair for account on V2 encryption"); - } - - const [publicKey, privateKey] = await this.keyService.makeKeyPair(userKey); - if (!privateKey.encryptedString) { - throw new Error("Failed to create encrypted private key"); - } - await this.apiService.postAccountKeys(new KeysRequest(publicKey, privateKey.encryptedString)); - return privateKey.encryptedString; - } catch (e) { - this.logService.error(e); - } - } - /** * Handles the response from the server when a 2FA is required. * It clears any existing 2FA token, as it's no longer valid, and sets up the necessary data for the 2FA process. diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts index 4188b779d81..00f43fd0423 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts @@ -216,7 +216,10 @@ describe("PasswordLoginStrategy", () => { userId, ); expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId); - expect(keyService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, userId); + expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith( + { V1: { private_key: tokenResponse.privateKey } }, + userId, + ); }); it("does not force the user to update their master password when there are no requirements", async () => { diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index 63fff52194b..ac74f976e77 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -148,20 +148,14 @@ export class PasswordLoginStrategy extends LoginStrategy { } } - protected override async setPrivateKey( + protected override async setAccountCryptographicState( response: IdentityTokenResponse, userId: UserId, ): Promise { - await this.keyService.setPrivateKey( - response.privateKey ?? (await this.createKeyPairForOldAccount(userId)), + await this.accountCryptographicStateService.setAccountCryptographicState( + response.accountKeysResponseModel.toWrappedAccountCryptographicState(), userId, ); - if (response.accountKeysResponseModel) { - await this.accountCryptographicStateService.setAccountCryptographicState( - response.accountKeysResponseModel.toWrappedAccountCryptographicState(), - userId, - ); - } } protected override encryptionKeyMigrationRequired(response: IdentityTokenResponse): boolean { diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts index 484beb785d3..4f0a6bbf73f 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts @@ -196,13 +196,14 @@ describe("SsoLoginStrategy", () => { const tokenResponse = identityTokenResponseFactory(); tokenResponse.key = null; tokenResponse.privateKey = null; + tokenResponse.accountKeysResponseModel = null; apiService.postIdentityToken.mockResolvedValue(tokenResponse); await ssoLoginStrategy.logIn(credentials); expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled(); expect(keyService.setUserKey).not.toHaveBeenCalled(); - expect(keyService.setPrivateKey).not.toHaveBeenCalled(); + expect(accountCryptographicStateService.setAccountCryptographicState).not.toHaveBeenCalled(); }); it("sets master key encrypted user key for existing SSO users", async () => { diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.ts index a9d60fca21a..6a57d11e29d 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -335,7 +335,7 @@ export class SsoLoginStrategy extends LoginStrategy { await this.keyService.setUserKey(userKey, userId); } - protected override async setPrivateKey( + protected override async setAccountCryptographicState( tokenResponse: IdentityTokenResponse, userId: UserId, ): Promise { @@ -345,20 +345,6 @@ export class SsoLoginStrategy extends LoginStrategy { userId, ); } - - if (tokenResponse.hasMasterKeyEncryptedUserKey()) { - // User has masterKeyEncryptedUserKey, so set the userKeyEncryptedPrivateKey - // Note: new JIT provisioned SSO users will not yet have a user asymmetric key pair - // and so we don't want them falling into the createKeyPairForOldAccount flow - await this.keyService.setPrivateKey( - tokenResponse.privateKey ?? (await this.createKeyPairForOldAccount(userId)), - userId, - ); - } else if (tokenResponse.privateKey) { - // User doesn't have masterKeyEncryptedUserKey but they do have a userKeyEncryptedPrivateKey - // This is just existing TDE users or a TDE offboarder on an untrusted device - await this.keyService.setPrivateKey(tokenResponse.privateKey, userId); - } } exportCache(): CacheData { diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts index 02613e527ec..af3f295df57 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts @@ -188,7 +188,10 @@ describe("UserApiLoginStrategy", () => { tokenResponse.key, userId, ); - expect(keyService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, userId); + expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith( + { V1: { private_key: tokenResponse.privateKey } }, + userId, + ); }); it("gets and sets the master key if Key Connector is enabled", async () => { diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts index c5a9110d63e..0aaefdbcfe0 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts @@ -79,20 +79,14 @@ export class UserApiLoginStrategy extends LoginStrategy { } } - protected override async setPrivateKey( + protected override async setAccountCryptographicState( response: IdentityTokenResponse, userId: UserId, ): Promise { - await this.keyService.setPrivateKey( - response.privateKey ?? (await this.createKeyPairForOldAccount(userId)), + await this.accountCryptographicStateService.setAccountCryptographicState( + response.accountKeysResponseModel.toWrappedAccountCryptographicState(), userId, ); - if (response.accountKeysResponseModel) { - await this.accountCryptographicStateService.setAccountCryptographicState( - response.accountKeysResponseModel.toWrappedAccountCryptographicState(), - userId, - ); - } } // Overridden to save client ID and secret to token service 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..e23722261fe 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"], }, }; @@ -261,7 +263,10 @@ describe("WebAuthnLoginStrategy", () => { mockPrfPrivateKey, ); expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, userId); - expect(keyService.setPrivateKey).toHaveBeenCalledWith(idTokenResponse.privateKey, userId); + expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith( + { V1: { private_key: idTokenResponse.privateKey } }, + userId, + ); // Master key and private key should not be set expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled(); 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..0dc70a9bf4f 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, @@ -99,20 +100,14 @@ export class WebAuthnLoginStrategy extends LoginStrategy { } } - protected override async setPrivateKey( + protected override async setAccountCryptographicState( response: IdentityTokenResponse, userId: UserId, ): Promise { - await this.keyService.setPrivateKey( - response.privateKey ?? (await this.createKeyPairForOldAccount(userId)), + await this.accountCryptographicStateService.setAccountCryptographicState( + response.accountKeysResponseModel.toWrappedAccountCryptographicState(), userId, ); - if (response.accountKeysResponseModel) { - await this.accountCryptographicStateService.setAccountCryptographicState( - response.accountKeysResponseModel.toWrappedAccountCryptographicState(), - userId, - ); - } } exportCache(): CacheData { 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/auth/src/common/services/login-strategies/login-strategy.service.spec.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts index a79d6bb0514..46ee0f5cfa5 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts @@ -495,6 +495,12 @@ describe("LoginStrategyService", () => { KdfParallelism: 1, Key: "KEY", PrivateKey: "PRIVATE_KEY", + AccountKeys: { + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: "PRIVATE_KEY", + publicKey: "PUBLIC_KEY", + }, + }, access_token: "ACCESS_TOKEN", expires_in: 3600, refresh_token: "REFRESH_TOKEN", @@ -562,6 +568,12 @@ describe("LoginStrategyService", () => { KdfParallelism: 1, Key: "KEY", PrivateKey: "PRIVATE_KEY", + AccountKeys: { + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: "PRIVATE_KEY", + publicKey: "PUBLIC_KEY", + }, + }, access_token: "ACCESS_TOKEN", expires_in: 3600, refresh_token: "REFRESH_TOKEN", @@ -627,6 +639,12 @@ describe("LoginStrategyService", () => { KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN - 1, Key: "KEY", PrivateKey: "PRIVATE_KEY", + AccountKeys: { + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: "PRIVATE_KEY", + publicKey: "PUBLIC_KEY", + }, + }, access_token: "ACCESS_TOKEN", expires_in: 3600, refresh_token: "REFRESH_TOKEN", @@ -690,6 +708,12 @@ describe("LoginStrategyService", () => { KdfParallelism: 1, Key: "KEY", PrivateKey: "PRIVATE_KEY", + AccountKeys: { + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: "PRIVATE_KEY", + publicKey: "PUBLIC_KEY", + }, + }, access_token: "ACCESS_TOKEN", expires_in: 3600, refresh_token: "REFRESH_TOKEN", diff --git a/libs/auto-confirm/src/components/auto-confirm-extension-dialog.component.ts b/libs/auto-confirm/src/components/auto-confirm-extension-dialog.component.ts new file mode 100644 index 00000000000..c04d8b5209b --- /dev/null +++ b/libs/auto-confirm/src/components/auto-confirm-extension-dialog.component.ts @@ -0,0 +1,78 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + BadgeComponent, + ButtonModule, + CenterPositionStrategy, + DialogModule, + DialogService, +} from "@bitwarden/components"; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + +
+
+ {{ "availableNow" | i18n }} +
+
+

+ + {{ "autoConfirmSetup" | i18n }} + +

+ + {{ "autoConfirmSetupDesc" | i18n }} + +
+
+ +
+ + + + + {{ "autoConfirmSetupHint" | i18n }} + + + +
+
+
+ `, + imports: [ButtonModule, DialogModule, CommonModule, JslibModule, BadgeComponent], +}) +export class AutoConfirmExtensionSetupDialogComponent { + constructor(public dialogRef: DialogRef) {} + + static open(dialogService: DialogService) { + return dialogService.open(AutoConfirmExtensionSetupDialogComponent, { + positionStrategy: new CenterPositionStrategy(), + }); + } +} diff --git a/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts b/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts index f126ce3b92c..877a0fe918a 100644 --- a/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts +++ b/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts @@ -2,7 +2,12 @@ import { DialogRef } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component } from "@angular/core"; -import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; +import { + ButtonModule, + CenterPositionStrategy, + DialogModule, + DialogService, +} from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; @Component({ @@ -14,6 +19,8 @@ export class AutoConfirmWarningDialogComponent { constructor(public dialogRef: DialogRef) {} static open(dialogService: DialogService) { - return dialogService.open(AutoConfirmWarningDialogComponent); + return dialogService.open(AutoConfirmWarningDialogComponent, { + positionStrategy: new CenterPositionStrategy(), + }); } } diff --git a/libs/auto-confirm/src/components/index.ts b/libs/auto-confirm/src/components/index.ts index a0310e805c6..1cddd1d7e59 100644 --- a/libs/auto-confirm/src/components/index.ts +++ b/libs/auto-confirm/src/components/index.ts @@ -1 +1,2 @@ +export * from "./auto-confirm-extension-dialog.component"; export * from "./auto-confirm-warning-dialog.component"; diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 7e4ff031ef2..afca5b63703 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -1,12 +1,11 @@ // 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 { CreateCollectionRequest, UpdateCollectionRequest } from "@bitwarden/admin-console/common"; import { CollectionAccessDetailsResponse, CollectionDetailsResponse, CollectionResponse, - CreateCollectionRequest, - UpdateCollectionRequest, -} from "@bitwarden/admin-console/common"; +} from "@bitwarden/common/admin-console/models/collections"; import { OrganizationConnectionType } from "../admin-console/enums"; import { OrganizationSponsorshipCreateRequest } from "../admin-console/models/request/organization/organization-sponsorship-create.request"; 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/admin-console/src/common/collections/models/collection-access-selection.view.ts b/libs/common/src/admin-console/models/collections/collection-access-selection.view.ts similarity index 100% rename from libs/admin-console/src/common/collections/models/collection-access-selection.view.ts rename to libs/common/src/admin-console/models/collections/collection-access-selection.view.ts diff --git a/libs/admin-console/src/common/collections/models/collection-admin.view.ts b/libs/common/src/admin-console/models/collections/collection-admin.view.ts similarity index 97% rename from libs/admin-console/src/common/collections/models/collection-admin.view.ts rename to libs/common/src/admin-console/models/collections/collection-admin.view.ts index d5effaad3aa..5ae901d5089 100644 --- a/libs/admin-console/src/common/collections/models/collection-admin.view.ts +++ b/libs/common/src/admin-console/models/collections/collection-admin.view.ts @@ -1,9 +1,9 @@ +import { CollectionAccessSelectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { OrgKey } from "@bitwarden/common/types/key"; -import { CollectionAccessSelectionView } from "./collection-access-selection.view"; import { CollectionAccessDetailsResponse, CollectionResponse } from "./collection.response"; import { CollectionView } from "./collection.view"; @@ -121,13 +121,13 @@ export class CollectionAdminView extends CollectionView { try { view.name = await encryptService.decryptString(new EncString(view.name), orgKey); } catch (e) { + view.name = "[error: cannot decrypt]"; // Note: This should be replaced by the owning team with appropriate, domain-specific behavior. // eslint-disable-next-line no-console console.error( "[CollectionAdminView/fromCollectionAccessDetails] Error decrypting collection name", e, ); - throw e; } view.assigned = collection.assigned; view.readOnly = collection.readOnly; diff --git a/libs/admin-console/src/common/collections/models/collection.data.ts b/libs/common/src/admin-console/models/collections/collection.data.ts similarity index 89% rename from libs/admin-console/src/common/collections/models/collection.data.ts rename to libs/common/src/admin-console/models/collections/collection.data.ts index a783a3c9ab1..ad67615d068 100644 --- a/libs/admin-console/src/common/collections/models/collection.data.ts +++ b/libs/common/src/admin-console/models/collections/collection.data.ts @@ -1,10 +1,12 @@ import { Jsonify } from "type-fest"; +import { + CollectionDetailsResponse, + CollectionType, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; -import { CollectionType, CollectionTypes } from "./collection"; -import { CollectionDetailsResponse } from "./collection.response"; - export class CollectionData { id: CollectionId; organizationId: OrganizationId; diff --git a/libs/admin-console/src/common/collections/models/collection.response.ts b/libs/common/src/admin-console/models/collections/collection.response.ts similarity index 95% rename from libs/admin-console/src/common/collections/models/collection.response.ts rename to libs/common/src/admin-console/models/collections/collection.response.ts index e6722635984..134e4c8d56d 100644 --- a/libs/admin-console/src/common/collections/models/collection.response.ts +++ b/libs/common/src/admin-console/models/collections/collection.response.ts @@ -1,9 +1,11 @@ +import { + CollectionType, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { SelectionReadOnlyResponse } from "@bitwarden/common/admin-console/models/response/selection-read-only.response"; import { BaseResponse } from "@bitwarden/common/models/response/base.response"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; -import { CollectionType, CollectionTypes } from "./collection"; - export class CollectionResponse extends BaseResponse { id: CollectionId; organizationId: OrganizationId; diff --git a/libs/admin-console/src/common/collections/models/collection.ts b/libs/common/src/admin-console/models/collections/collection.ts similarity index 97% rename from libs/admin-console/src/common/collections/models/collection.ts rename to libs/common/src/admin-console/models/collections/collection.ts index cf5573b8f4f..24c4d882732 100644 --- a/libs/admin-console/src/common/collections/models/collection.ts +++ b/libs/common/src/admin-console/models/collections/collection.ts @@ -1,3 +1,4 @@ +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import Domain from "@bitwarden/common/platform/models/domain/domain-base"; @@ -5,7 +6,6 @@ import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { CollectionData } from "./collection.data"; -import { CollectionView } from "./collection.view"; export const CollectionTypes = { SharedCollection: 0, diff --git a/libs/admin-console/src/common/collections/models/collection.view.ts b/libs/common/src/admin-console/models/collections/collection.view.ts similarity index 95% rename from libs/admin-console/src/common/collections/models/collection.view.ts rename to libs/common/src/admin-console/models/collections/collection.view.ts index 2991e8bb171..eabd4dc7b36 100644 --- a/libs/admin-console/src/common/collections/models/collection.view.ts +++ b/libs/common/src/admin-console/models/collections/collection.view.ts @@ -126,7 +126,14 @@ export class CollectionView implements View, ITreeNodeObject { ): Promise { const view = new CollectionView({ ...collection, name: "" }); - view.name = await encryptService.decryptString(collection.name, key); + try { + view.name = await encryptService.decryptString(collection.name, key); + } catch (e) { + view.name = "[error: cannot decrypt]"; + // eslint-disable-next-line no-console + console.error("[CollectionView] Error decrypting collection name", e); + } + view.assigned = true; view.externalId = collection.externalId; view.readOnly = collection.readOnly; diff --git a/libs/common/src/admin-console/models/collections/index.ts b/libs/common/src/admin-console/models/collections/index.ts new file mode 100644 index 00000000000..74b92715eb0 --- /dev/null +++ b/libs/common/src/admin-console/models/collections/index.ts @@ -0,0 +1,6 @@ +export * from "./collection-access-selection.view"; +export * from "./collection-admin.view"; +export * from "./collection.view"; +export * from "./collection.response"; +export * from "./collection"; +export * from "./collection.data"; diff --git a/libs/common/src/admin-console/models/request/provider/provider-user-confirm.request.ts b/libs/common/src/admin-console/models/request/provider/provider-user-confirm.request.ts index bdcd91f8de9..006ba7dbc66 100644 --- a/libs/common/src/admin-console/models/request/provider/provider-user-confirm.request.ts +++ b/libs/common/src/admin-console/models/request/provider/provider-user-confirm.request.ts @@ -1,5 +1,7 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore export class ProviderUserConfirmRequest { - key: string; + protected key: string; + + constructor(key: string) { + this.key = key; + } } diff --git a/libs/common/src/admin-console/models/response/organization-export.response.ts b/libs/common/src/admin-console/models/response/organization-export.response.ts index 19a8dd9ad94..9666bca839a 100644 --- a/libs/common/src/admin-console/models/response/organization-export.response.ts +++ b/libs/common/src/admin-console/models/response/organization-export.response.ts @@ -1,8 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { CollectionResponse } from "@bitwarden/admin-console/common"; +import { CollectionResponse } from "@bitwarden/common/admin-console/models/collections"; import { BaseResponse } from "../../../models/response/base.response"; import { CipherResponse } from "../../../vault/models/response/cipher.response"; 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/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.spec.ts b/libs/common/src/admin-console/utils/collection-utils.spec.ts similarity index 97% rename from apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.spec.ts rename to libs/common/src/admin-console/utils/collection-utils.spec.ts index ad3d0d8169a..e6aa1b96d54 100644 --- a/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.spec.ts +++ b/libs/common/src/admin-console/utils/collection-utils.spec.ts @@ -1,4 +1,4 @@ -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { newGuid } from "@bitwarden/guid"; diff --git a/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts b/libs/common/src/admin-console/utils/collection-utils.ts similarity index 98% rename from apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts rename to libs/common/src/admin-console/utils/collection-utils.ts index 33325b3a4bd..e4ad05c0798 100644 --- a/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts +++ b/libs/common/src/admin-console/utils/collection-utils.ts @@ -1,10 +1,10 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { - CollectionAdminView, CollectionView, NestingDelimiter, -} from "@bitwarden/admin-console/common"; + CollectionAdminView, +} from "@bitwarden/common/admin-console/models/collections"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; diff --git a/apps/web/src/app/admin-console/organizations/collections/utils/index.ts b/libs/common/src/admin-console/utils/index.ts similarity index 100% rename from apps/web/src/app/admin-console/organizations/collections/utils/index.ts rename to libs/common/src/admin-console/utils/index.ts 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/billing/models/response/bitwarden-subscription.response.ts b/libs/common/src/billing/models/response/bitwarden-subscription.response.ts new file mode 100644 index 00000000000..870c4de7e3a --- /dev/null +++ b/libs/common/src/billing/models/response/bitwarden-subscription.response.ts @@ -0,0 +1,102 @@ +import { CartResponse } from "@bitwarden/common/billing/models/response/cart.response"; +import { StorageResponse } from "@bitwarden/common/billing/models/response/storage.response"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Cart } from "@bitwarden/pricing"; +import { + BitwardenSubscription, + Storage, + SubscriptionStatus, + SubscriptionStatuses, +} from "@bitwarden/subscription"; + +export class BitwardenSubscriptionResponse extends BaseResponse { + status: SubscriptionStatus; + cart: Cart; + storage: Storage; + cancelAt?: Date; + canceled?: Date; + nextCharge?: Date; + suspension?: Date; + gracePeriod?: number; + + constructor(response: any) { + super(response); + + const status = this.getResponseProperty("Status"); + if ( + status !== SubscriptionStatuses.Incomplete && + status !== SubscriptionStatuses.IncompleteExpired && + status !== SubscriptionStatuses.Trialing && + status !== SubscriptionStatuses.Active && + status !== SubscriptionStatuses.PastDue && + status !== SubscriptionStatuses.Canceled && + status !== SubscriptionStatuses.Unpaid + ) { + throw new Error(`Failed to parse invalid subscription status: ${status}`); + } + this.status = status; + + this.cart = new CartResponse(this.getResponseProperty("Cart")); + this.storage = new StorageResponse(this.getResponseProperty("Storage")); + + const suspension = this.getResponseProperty("Suspension"); + if (suspension) { + this.suspension = new Date(suspension); + } + + const gracePeriod = this.getResponseProperty("GracePeriod"); + if (gracePeriod) { + this.gracePeriod = gracePeriod; + } + + const nextCharge = this.getResponseProperty("NextCharge"); + if (nextCharge) { + this.nextCharge = new Date(nextCharge); + } + + const cancelAt = this.getResponseProperty("CancelAt"); + if (cancelAt) { + this.cancelAt = new Date(cancelAt); + } + + const canceled = this.getResponseProperty("Canceled"); + if (canceled) { + this.canceled = new Date(canceled); + } + } + + toDomain = (): BitwardenSubscription => { + switch (this.status) { + case SubscriptionStatuses.Incomplete: + case SubscriptionStatuses.IncompleteExpired: + case SubscriptionStatuses.PastDue: + case SubscriptionStatuses.Unpaid: { + return { + cart: this.cart, + storage: this.storage, + status: this.status, + suspension: this.suspension!, + gracePeriod: this.gracePeriod!, + }; + } + case SubscriptionStatuses.Trialing: + case SubscriptionStatuses.Active: { + return { + cart: this.cart, + storage: this.storage, + status: this.status, + nextCharge: this.nextCharge!, + cancelAt: this.cancelAt, + }; + } + case SubscriptionStatuses.Canceled: { + return { + cart: this.cart, + storage: this.storage, + status: this.status, + canceled: this.canceled!, + }; + } + } + }; +} diff --git a/libs/common/src/billing/models/response/cart.response.ts b/libs/common/src/billing/models/response/cart.response.ts new file mode 100644 index 00000000000..c1a1d17521a --- /dev/null +++ b/libs/common/src/billing/models/response/cart.response.ts @@ -0,0 +1,97 @@ +import { + SubscriptionCadence, + SubscriptionCadenceIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Cart, CartItem, Discount } from "@bitwarden/pricing"; + +import { DiscountResponse } from "./discount.response"; + +export class CartItemResponse extends BaseResponse implements CartItem { + translationKey: string; + quantity: number; + cost: number; + discount?: Discount; + + constructor(response: any) { + super(response); + + this.translationKey = this.getResponseProperty("TranslationKey"); + this.quantity = this.getResponseProperty("Quantity"); + this.cost = this.getResponseProperty("Cost"); + const discount = this.getResponseProperty("Discount"); + if (discount) { + this.discount = discount; + } + } +} + +class PasswordManagerCartItemResponse extends BaseResponse { + seats: CartItem; + additionalStorage?: CartItem; + + constructor(response: any) { + super(response); + + this.seats = new CartItemResponse(this.getResponseProperty("Seats")); + const additionalStorage = this.getResponseProperty("AdditionalStorage"); + if (additionalStorage) { + this.additionalStorage = new CartItemResponse(additionalStorage); + } + } +} + +class SecretsManagerCartItemResponse extends BaseResponse { + seats: CartItem; + additionalServiceAccounts?: CartItem; + + constructor(response: any) { + super(response); + + this.seats = new CartItemResponse(this.getResponseProperty("Seats")); + const additionalServiceAccounts = this.getResponseProperty("AdditionalServiceAccounts"); + if (additionalServiceAccounts) { + this.additionalServiceAccounts = new CartItemResponse(additionalServiceAccounts); + } + } +} + +export class CartResponse extends BaseResponse implements Cart { + passwordManager: { + seats: CartItem; + additionalStorage?: CartItem; + }; + secretsManager?: { + seats: CartItem; + additionalServiceAccounts?: CartItem; + }; + cadence: SubscriptionCadence; + discount?: Discount; + estimatedTax: number; + + constructor(response: any) { + super(response); + + this.passwordManager = new PasswordManagerCartItemResponse( + this.getResponseProperty("PasswordManager"), + ); + + const secretsManager = this.getResponseProperty("SecretsManager"); + if (secretsManager) { + this.secretsManager = new SecretsManagerCartItemResponse(secretsManager); + } + + const cadence = this.getResponseProperty("Cadence"); + if (cadence !== SubscriptionCadenceIds.Annually && cadence !== SubscriptionCadenceIds.Monthly) { + throw new Error(`Failed to parse invalid cadence: ${cadence}`); + } + this.cadence = cadence; + + const discount = this.getResponseProperty("Discount"); + if (discount) { + this.discount = new DiscountResponse(discount); + } + + this.estimatedTax = this.getResponseProperty("EstimatedTax"); + } +} diff --git a/libs/common/src/billing/models/response/discount.response.ts b/libs/common/src/billing/models/response/discount.response.ts new file mode 100644 index 00000000000..03460a10df8 --- /dev/null +++ b/libs/common/src/billing/models/response/discount.response.ts @@ -0,0 +1,18 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Discount, DiscountType, DiscountTypes } from "@bitwarden/pricing"; + +export class DiscountResponse extends BaseResponse implements Discount { + type: DiscountType; + value: number; + + constructor(response: any) { + super(response); + + const type = this.getResponseProperty("Type"); + if (type !== DiscountTypes.AmountOff && type !== DiscountTypes.PercentOff) { + throw new Error(`Failed to parse invalid discount type: ${type}`); + } + this.type = type; + this.value = this.getResponseProperty("Value"); + } +} diff --git a/libs/common/src/billing/models/response/storage.response.ts b/libs/common/src/billing/models/response/storage.response.ts new file mode 100644 index 00000000000..7e270ccc934 --- /dev/null +++ b/libs/common/src/billing/models/response/storage.response.ts @@ -0,0 +1,16 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Storage } from "@bitwarden/subscription"; + +export class StorageResponse extends BaseResponse implements Storage { + available: number; + used: number; + readableUsed: string; + + constructor(response: any) { + super(response); + + this.available = this.getResponseProperty("Available"); + this.used = this.getResponseProperty("Used"); + this.readableUsed = this.getResponseProperty("ReadableUsed"); + } +} diff --git a/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts index e6363b490cb..077d28f5954 100644 --- a/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts +++ b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts @@ -8,6 +8,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { ProductTierType } from "@bitwarden/common/billing/enums"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { LogService } from "@bitwarden/logging"; import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; import { UserId } from "../../../types/guid"; @@ -54,6 +55,8 @@ describe("PhishingDetectionSettingsService", () => { usePhishingBlocker: true, }); + const mockLogService = mock(); + const mockUserId = "mock-user-id" as UserId; const account = mock({ id: mockUserId }); const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); @@ -85,6 +88,7 @@ describe("PhishingDetectionSettingsService", () => { mockAccountService, mockBillingService, mockConfigService, + mockLogService, mockOrganizationService, mockPlatformService, stateProvider, diff --git a/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts index e30592b2f68..91ae7c6227e 100644 --- a/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts +++ b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts @@ -1,5 +1,5 @@ import { combineLatest, Observable, of, switchMap } from "rxjs"; -import { catchError, distinctUntilChanged, map, shareReplay } from "rxjs/operators"; +import { catchError, distinctUntilChanged, map, shareReplay, tap } from "rxjs/operators"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -9,6 +9,7 @@ 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { LogService } from "@bitwarden/logging"; import { UserId } from "@bitwarden/user-core"; import { PHISHING_DETECTION_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state"; @@ -32,27 +33,47 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin private accountService: AccountService, private billingService: BillingAccountProfileStateService, private configService: ConfigService, + private logService: LogService, private organizationService: OrganizationService, private platformService: PlatformUtilsService, private stateProvider: StateProvider, ) { + this.logService.debug(`[PhishingDetectionSettingsService] Initializing service...`); this.available$ = this.buildAvailablePipeline$().pipe( distinctUntilChanged(), + tap((available) => + this.logService.debug( + `[PhishingDetectionSettingsService] Phishing detection available: ${available}`, + ), + ), shareReplay({ bufferSize: 1, refCount: true }), ); this.enabled$ = this.buildEnabledPipeline$().pipe( distinctUntilChanged(), + tap((enabled) => + this.logService.debug( + `[PhishingDetectionSettingsService] Phishing detection enabled: ${{ enabled }}`, + ), + ), shareReplay({ bufferSize: 1, refCount: true }), ); this.on$ = combineLatest([this.available$, this.enabled$]).pipe( map(([available, enabled]) => available && enabled), distinctUntilChanged(), - shareReplay({ bufferSize: 1, refCount: true }), + tap((on) => + this.logService.debug( + `[PhishingDetectionSettingsService] Phishing detection is on: ${{ on }}`, + ), + ), + shareReplay({ bufferSize: 1, refCount: false }), ); } async setEnabled(userId: UserId, enabled: boolean): Promise { + this.logService.debug( + `[PhishingDetectionSettingsService] Setting phishing detection enabled: ${{ enabled, userId }}`, + ); await this.stateProvider.getUser(userId, ENABLE_PHISHING_DETECTION).update(() => enabled); } @@ -64,6 +85,9 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin private buildAvailablePipeline$(): Observable { // Phishing detection is unavailable on Safari due to platform limitations. if (this.platformService.isSafari()) { + this.logService.warning( + `[PhishingDetectionSettingsService] Phishing detection is unavailable on Safari due to platform limitations`, + ); return of(false); } @@ -97,6 +121,9 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin if (!account) { return of(false); } + this.logService.debug( + `[PhishingDetectionSettingsService] Refreshing phishing detection enabled state`, + ); return this.stateProvider.getUserState$(ENABLE_PHISHING_DETECTION, account.id); }), map((enabled) => enabled ?? true), diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 00ee2d5326e..5298ed34eda 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -12,16 +12,21 @@ import { ServerConfig } from "../platform/abstractions/config/server-config"; 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", + BulkReinviteUI = "pm-28416-bulk-reinvite-ux-improvements", /* 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", + SSHAgentV2 = "ssh-agent-v2", /* Billing */ TrialPaymentOptional = "PM-8163-trial-payment", @@ -31,22 +36,24 @@ export enum FeatureFlag { PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog", PM26462_Milestone_3 = "pm-26462-milestone-3", PM23341_Milestone_2 = "pm-23341-milestone-2", + PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page", + PM29593_PremiumToOrganizationUpgrade = "pm-29593-premium-to-organization-upgrade", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation", ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings", SdkKeyRotation = "pm-30144-sdk-key-rotation", - PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption", 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", EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration", + EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration", /* Tools */ - DesktopSendUIRefresh = "desktop-send-ui-refresh", UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators", ChromiumImporterWithABE = "pm-25855-chromium-importer-abe", SendUIRefresh = "pm-28175-send-ui-refresh", @@ -54,27 +61,30 @@ export enum FeatureFlag { /* DIRT */ EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike", + EventManagementForHuntress = "event-management-for-huntress", PhishingDetection = "phishing-detection", - PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab", + 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", + PM31039ItemActionInExtension = "pm-31039-item-action-in-extension", /* Platform */ - IpcChannelFramework = "ipc-channel-framework", + ContentScriptIpcChannelFramework = "content-script-ipc-channel-framework", + WebAuthnRelatedOrigins = "pm-30529-webauthn-related-origins", /* Innovation */ PM19148_InnovationArchive = "pm-19148-innovation-archive", /* Desktop */ DesktopUiMigrationMilestone1 = "desktop-ui-migration-milestone-1", + DesktopUiMigrationMilestone2 = "desktop-ui-migration-milestone-2", /* UIF */ RouterFocusManagement = "router-focus-management", @@ -99,16 +109,19 @@ const FALSE = false as boolean; export const DefaultFeatureFlagValue = { /* Admin Console Team */ [FeatureFlag.AutoConfirm]: FALSE, - [FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE, - [FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE, + [FeatureFlag.DefaultUserCollectionRestore]: FALSE, + [FeatureFlag.MembersComponentRefactor]: FALSE, + [FeatureFlag.BulkReinviteUI]: FALSE, /* Autofill */ + [FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic]: FALSE, [FeatureFlag.MacOsNativeCredentialSync]: FALSE, [FeatureFlag.WindowsDesktopAutotype]: FALSE, [FeatureFlag.WindowsDesktopAutotypeGA]: FALSE, + [FeatureFlag.SSHAgentV2]: FALSE, + [FeatureFlag.PM31039ItemActionInExtension]: FALSE, /* Tools */ - [FeatureFlag.DesktopSendUIRefresh]: FALSE, [FeatureFlag.UseSdkPasswordGenerators]: FALSE, [FeatureFlag.ChromiumImporterWithABE]: FALSE, [FeatureFlag.SendUIRefresh]: FALSE, @@ -116,21 +129,23 @@ export const DefaultFeatureFlagValue = { /* DIRT */ [FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE, + [FeatureFlag.EventManagementForHuntress]: FALSE, [FeatureFlag.PhishingDetection]: FALSE, - [FeatureFlag.PM22887_RiskInsightsActivityTab]: 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, @@ -140,28 +155,33 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE, [FeatureFlag.PM26462_Milestone_3]: FALSE, [FeatureFlag.PM23341_Milestone_2]: FALSE, + [FeatureFlag.PM29594_UpdateIndividualSubscriptionPage]: FALSE, + [FeatureFlag.PM29593_PremiumToOrganizationUpgrade]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, [FeatureFlag.EnrollAeadOnKeyRotation]: FALSE, [FeatureFlag.ForceUpdateKDFSettings]: FALSE, [FeatureFlag.SdkKeyRotation]: FALSE, - [FeatureFlag.PM25174_DisableType0Decryption]: FALSE, [FeatureFlag.LinuxBiometricsV2]: FALSE, [FeatureFlag.NoLogoutOnKdfChange]: FALSE, + [FeatureFlag.PasskeyUnlock]: FALSE, [FeatureFlag.DataRecoveryTool]: FALSE, [FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE, [FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE, [FeatureFlag.EnableAccountEncryptionV2KeyConnectorRegistration]: FALSE, + [FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration]: FALSE, /* Platform */ - [FeatureFlag.IpcChannelFramework]: FALSE, + [FeatureFlag.ContentScriptIpcChannelFramework]: FALSE, + [FeatureFlag.WebAuthnRelatedOrigins]: FALSE, /* Innovation */ [FeatureFlag.PM19148_InnovationArchive]: FALSE, /* Desktop */ [FeatureFlag.DesktopUiMigrationMilestone1]: FALSE, + [FeatureFlag.DesktopUiMigrationMilestone2]: FALSE, /* UIF */ [FeatureFlag.RouterFocusManagement]: FALSE, diff --git a/libs/common/src/key-management/account-cryptography/account-cryptographic-state.service.ts b/libs/common/src/key-management/account-cryptography/account-cryptographic-state.service.ts index cc3306a849a..87925514a23 100644 --- a/libs/common/src/key-management/account-cryptography/account-cryptographic-state.service.ts +++ b/libs/common/src/key-management/account-cryptography/account-cryptographic-state.service.ts @@ -19,4 +19,9 @@ export abstract class AccountCryptographicStateService { accountCryptographicState: WrappedAccountCryptographicState, userId: UserId, ): Promise; + + /** + * Clears the account cryptographic state. + */ + abstract clearAccountCryptographicState(userId: UserId): Promise; } diff --git a/libs/common/src/key-management/account-cryptography/default-account-cryptographic-state.service.ts b/libs/common/src/key-management/account-cryptography/default-account-cryptographic-state.service.ts index c37ac3c4fd1..177267a9e96 100644 --- a/libs/common/src/key-management/account-cryptography/default-account-cryptographic-state.service.ts +++ b/libs/common/src/key-management/account-cryptography/default-account-cryptographic-state.service.ts @@ -32,4 +32,8 @@ export class DefaultAccountCryptographicStateService implements AccountCryptogra userId, ); } + + async clearAccountCryptographicState(userId: UserId): Promise { + await this.stateProvider.setUserState(ACCOUNT_CRYPTOGRAPHIC_STATE, null, userId); + } } diff --git a/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts b/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts index b16371198b3..4399fc6dbb4 100644 --- a/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts +++ b/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts @@ -7,7 +7,7 @@ import { CsprngArray } from "../../../types/csprng"; export abstract class CryptoFunctionService { /** - * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * @deprecated ⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. */ abstract pbkdf2( @@ -17,7 +17,7 @@ export abstract class CryptoFunctionService { iterations: number, ): Promise; /** - * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * @deprecated ⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. */ abstract hkdf( @@ -28,7 +28,7 @@ export abstract class CryptoFunctionService { algorithm: "sha256" | "sha512", ): Promise; /** - * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * @deprecated ⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. */ abstract hkdfExpand( @@ -38,7 +38,7 @@ export abstract class CryptoFunctionService { algorithm: "sha256" | "sha512", ): Promise; /** - * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. */ abstract hash( @@ -46,7 +46,7 @@ export abstract class CryptoFunctionService { algorithm: "sha1" | "sha256" | "sha512" | "md5", ): Promise; /** - * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. */ abstract hmacFast( @@ -56,7 +56,7 @@ export abstract class CryptoFunctionService { ): Promise; abstract compareFast(a: Uint8Array | string, b: Uint8Array | string): Promise; /** - * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. */ abstract aesDecryptFastParameters( @@ -66,7 +66,7 @@ export abstract class CryptoFunctionService { key: SymmetricCryptoKey, ): CbcDecryptParameters; /** - * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. */ abstract aesDecryptFast({ @@ -76,7 +76,7 @@ export abstract class CryptoFunctionService { | { mode: "cbc"; parameters: CbcDecryptParameters } | { mode: "ecb"; parameters: EcbDecryptParameters }): Promise; /** - * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Only used by DDG integration until DDG uses PKCS#7 padding, and by lastpass importer. + * @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Only used by DDG integration until DDG uses PKCS#7 padding, and by lastpass importer. */ abstract aesDecrypt( data: Uint8Array, @@ -85,7 +85,7 @@ export abstract class CryptoFunctionService { mode: "cbc" | "ecb", ): Promise; /** - * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. */ abstract rsaEncrypt( @@ -94,7 +94,7 @@ export abstract class CryptoFunctionService { algorithm: "sha1", ): Promise; /** - * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. */ abstract rsaDecrypt( diff --git a/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts b/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts index 25e5f949b40..03cfd173a4d 100644 --- a/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts +++ b/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts @@ -1,24 +1,24 @@ -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; - import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { EncString } from "../models/enc-string"; export abstract class EncryptService { - /** - * A temporary init method to make the encrypt service listen to feature-flag changes. - * This will be removed once the feature flag has been rolled out. - */ - abstract init(configService: ConfigService): void; - /** * Encrypts a string to an EncString + * + * @deprecated NOTE: For new use-cases, prefer using the DataEnvelope inside the SDK instead. This + * is both safer and more maintainable. + * * @param plainValue - The value to encrypt * @param key - The key to encrypt the value with */ abstract encryptString(plainValue: string, key: SymmetricCryptoKey): Promise; /** * Encrypts bytes to an EncString + * + * @deprecated NOTE: You probably do not want to encrypt raw bytes. Please contact the Key-Management team if you think + * you need to. + * * @param plainValue - The value to encrypt * @param key - The key to encrypt the value with * @deprecated Bytes are not the right abstraction to encrypt in. Use e.g. key wrapping or file encryption instead @@ -137,6 +137,9 @@ export abstract class EncryptService { * Encapsulates a symmetric key with an asymmetric public key * Note: This does not establish sender authenticity * @see {@link https://en.wikipedia.org/wiki/Key_encapsulation_mechanism} + * + * @deprecated NOTE: You probably do not want to use this. Please contact the Key-Management team if you think you need to. + * * @param sharedKey - The symmetric key that is to be shared * @param encapsulationKey - The encapsulation key (public key) of the receiver that the key is shared with */ diff --git a/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts b/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts index 5f5da741707..ef1057c51e6 100644 --- a/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts +++ b/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts @@ -30,7 +30,7 @@ export class DefaultKeyGenerationService implements KeyGenerationService { ): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }> { if (salt == null) { const bytes = await this.cryptoFunctionService.randomBytes(32); - salt = Utils.fromBufferToUtf8(bytes); + salt = Utils.fromBufferToUtf8(bytes.buffer as ArrayBuffer); } const material = await this.cryptoFunctionService.aesGenerateKey(bitLength); const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256"); diff --git a/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts b/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts index ddc3829aa9f..1114e892bb8 100644 --- a/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts +++ b/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts @@ -27,7 +27,7 @@ export abstract class KeyGenerationService { * Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869} * for details. * - * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function. + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. This is a low-level cryptographic function. * New functionality should not be built on top of it, and instead should be built in the sdk. * * @param bitLength Length of key material. @@ -44,7 +44,7 @@ export abstract class KeyGenerationService { /** * Derives a 64 byte key from key material. * - * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function. + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. This is a low-level cryptographic function. * New functionality should not be built on top of it, and instead should be built in the sdk. * * @remark The key material should be generated from {@link createKey}, or {@link createKeyWithPurpose}. @@ -63,7 +63,7 @@ export abstract class KeyGenerationService { /** * Derives a 32 byte key from a password using a key derivation function. * - * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function. + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. This is a low-level cryptographic function. * New functionality should not be built on top of it, and instead should be built in the sdk. * * @param password Password to derive the key from. @@ -80,7 +80,7 @@ export abstract class KeyGenerationService { /** * Derives a 64 byte key from a 32 byte key using a key derivation function. * - * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function. + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. This is a low-level cryptographic function. * New functionality should not be built on top of it, and instead should be built in the sdk. * * @param key 32 byte key. diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts index a5da0c82382..b14211b5b72 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts @@ -1,9 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { EncryptionType } from "@bitwarden/common/platform/enums"; @@ -15,28 +13,12 @@ import { PureCrypto } from "@bitwarden/sdk-internal"; import { EncryptService } from "../abstractions/encrypt.service"; export class EncryptServiceImplementation implements EncryptService { - private disableType0Decryption = false; - constructor( protected cryptoFunctionService: CryptoFunctionService, protected logService: LogService, protected logMacFailures: boolean, ) {} - init(configService: ConfigService): void { - configService.serverConfig$.subscribe((newConfig) => { - if (newConfig != null) { - this.setDisableType0Decryption( - newConfig.featureStates[FeatureFlag.PM25174_DisableType0Decryption] === true, - ); - } - }); - } - - setDisableType0Decryption(disable: boolean): void { - this.disableType0Decryption = disable; - } - async encryptString(plainValue: string, key: SymmetricCryptoKey): Promise { if (plainValue == null) { this.logService.warning( @@ -60,7 +42,7 @@ export class EncryptServiceImplementation implements EncryptService { } async decryptString(encString: EncString, key: SymmetricCryptoKey): Promise { - if (this.disableType0Decryption && encString.encryptionType === EncryptionType.AesCbc256_B64) { + if (encString.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } await SdkLoadService.Ready; @@ -68,7 +50,7 @@ export class EncryptServiceImplementation implements EncryptService { } async decryptBytes(encString: EncString, key: SymmetricCryptoKey): Promise { - if (this.disableType0Decryption && encString.encryptionType === EncryptionType.AesCbc256_B64) { + if (encString.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } await SdkLoadService.Ready; @@ -76,7 +58,7 @@ export class EncryptServiceImplementation implements EncryptService { } async decryptFileData(encBuffer: EncArrayBuffer, key: SymmetricCryptoKey): Promise { - if (this.disableType0Decryption && encBuffer.encryptionType === EncryptionType.AesCbc256_B64) { + if (encBuffer.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } await SdkLoadService.Ready; @@ -148,10 +130,7 @@ export class EncryptServiceImplementation implements EncryptService { throw new Error("No wrappingKey provided for unwrapping."); } - if ( - this.disableType0Decryption && - wrappedDecapsulationKey.encryptionType === EncryptionType.AesCbc256_B64 - ) { + if (wrappedDecapsulationKey.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } @@ -171,10 +150,7 @@ export class EncryptServiceImplementation implements EncryptService { if (wrappingKey == null) { throw new Error("No wrappingKey provided for unwrapping."); } - if ( - this.disableType0Decryption && - wrappedEncapsulationKey.encryptionType === EncryptionType.AesCbc256_B64 - ) { + if (wrappedEncapsulationKey.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } @@ -194,10 +170,7 @@ export class EncryptServiceImplementation implements EncryptService { if (wrappingKey == null) { throw new Error("No wrappingKey provided for unwrapping."); } - if ( - this.disableType0Decryption && - keyToBeUnwrapped.encryptionType === EncryptionType.AesCbc256_B64 - ) { + if (keyToBeUnwrapped.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts index 466f59da7c9..ac1f4d6ada0 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts @@ -163,7 +163,7 @@ describe("EncryptService", () => { describe("decryptString", () => { it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString("encrypted_string"); + const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "encrypted_string"); const result = await encryptService.decryptString(encString, key); expect(result).toEqual("decrypted_string"); expect(PureCrypto.symmetric_decrypt_string).toHaveBeenCalledWith( @@ -172,8 +172,7 @@ describe("EncryptService", () => { ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encString = new EncString(EncryptionType.AesCbc256_B64, "encrypted_string"); await expect(encryptService.decryptString(encString, key)).rejects.toThrow( @@ -185,7 +184,7 @@ describe("EncryptService", () => { describe("decryptBytes", () => { it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString("encrypted_bytes"); + const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "encrypted_bytes"); const result = await encryptService.decryptBytes(encString, key); expect(result).toEqual(new Uint8Array(3)); expect(PureCrypto.symmetric_decrypt_bytes).toHaveBeenCalledWith( @@ -194,8 +193,7 @@ describe("EncryptService", () => { ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encString = new EncString(EncryptionType.AesCbc256_B64, "encrypted_bytes"); await expect(encryptService.decryptBytes(encString, key)).rejects.toThrow( @@ -216,8 +214,7 @@ describe("EncryptService", () => { ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encBuffer = EncArrayBuffer.fromParts( EncryptionType.AesCbc256_B64, @@ -234,7 +231,10 @@ describe("EncryptService", () => { describe("unwrapDecapsulationKey", () => { it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString("wrapped_decapsulation_key"); + const encString = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "wrapped_decapsulation_key", + ); const result = await encryptService.unwrapDecapsulationKey(encString, key); expect(result).toEqual(new Uint8Array(4)); expect(PureCrypto.unwrap_decapsulation_key).toHaveBeenCalledWith( @@ -242,8 +242,7 @@ describe("EncryptService", () => { key.toEncoded(), ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_decapsulation_key"); await expect(encryptService.unwrapDecapsulationKey(encString, key)).rejects.toThrow( @@ -267,7 +266,10 @@ describe("EncryptService", () => { describe("unwrapEncapsulationKey", () => { it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString("wrapped_encapsulation_key"); + const encString = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "wrapped_encapsulation_key", + ); const result = await encryptService.unwrapEncapsulationKey(encString, key); expect(result).toEqual(new Uint8Array(5)); expect(PureCrypto.unwrap_encapsulation_key).toHaveBeenCalledWith( @@ -275,8 +277,7 @@ describe("EncryptService", () => { key.toEncoded(), ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_encapsulation_key"); await expect(encryptService.unwrapEncapsulationKey(encString, key)).rejects.toThrow( @@ -300,7 +301,10 @@ describe("EncryptService", () => { describe("unwrapSymmetricKey", () => { it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString("wrapped_symmetric_key"); + const encString = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "wrapped_symmetric_key", + ); const result = await encryptService.unwrapSymmetricKey(encString, key); expect(result).toEqual(new SymmetricCryptoKey(new Uint8Array(64))); expect(PureCrypto.unwrap_symmetric_key).toHaveBeenCalledWith( @@ -308,8 +312,7 @@ describe("EncryptService", () => { key.toEncoded(), ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_symmetric_key"); await expect(encryptService.unwrapSymmetricKey(encString, key)).rejects.toThrow( diff --git a/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts b/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts index b8ee5d7df64..329ae547160 100644 --- a/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts +++ b/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts @@ -524,14 +524,6 @@ describe("KeyConnectorService", () => { }, mockUserId, ); - expect(keyService.setPrivateKey).toHaveBeenCalledWith(mockPrivateKey, mockUserId); - expect(keyService.setUserSigningKey).toHaveBeenCalledWith(mockSigningKey, mockUserId); - expect(securityStateService.setAccountSecurityState).toHaveBeenCalledWith( - mockSecurityState, - mockUserId, - ); - expect(keyService.setSignedPublicKey).toHaveBeenCalledWith(mockSignedPublicKey, mockUserId); - expect(await firstValueFrom(conversionState.state$)).toBeNull(); }); @@ -557,10 +549,6 @@ describe("KeyConnectorService", () => { expect( accountCryptographicStateService.setAccountCryptographicState, ).not.toHaveBeenCalled(); - expect(keyService.setPrivateKey).not.toHaveBeenCalled(); - expect(keyService.setUserSigningKey).not.toHaveBeenCalled(); - expect(securityStateService.setAccountSecurityState).not.toHaveBeenCalled(); - expect(keyService.setSignedPublicKey).not.toHaveBeenCalled(); }); it("should throw error when account cryptographic state is not V2", async () => { @@ -595,10 +583,6 @@ describe("KeyConnectorService", () => { expect( accountCryptographicStateService.setAccountCryptographicState, ).not.toHaveBeenCalled(); - expect(keyService.setPrivateKey).not.toHaveBeenCalled(); - expect(keyService.setUserSigningKey).not.toHaveBeenCalled(); - expect(securityStateService.setAccountSecurityState).not.toHaveBeenCalled(); - expect(keyService.setSignedPublicKey).not.toHaveBeenCalled(); }); it("should throw error when post_keys_for_key_connector_registration fails", async () => { @@ -625,10 +609,6 @@ describe("KeyConnectorService", () => { expect( accountCryptographicStateService.setAccountCryptographicState, ).not.toHaveBeenCalled(); - expect(keyService.setPrivateKey).not.toHaveBeenCalled(); - expect(keyService.setUserSigningKey).not.toHaveBeenCalled(); - expect(securityStateService.setAccountSecurityState).not.toHaveBeenCalled(); - expect(keyService.setSignedPublicKey).not.toHaveBeenCalled(); }); }); diff --git a/libs/common/src/key-management/key-connector/services/key-connector.service.ts b/libs/common/src/key-management/key-connector/services/key-connector.service.ts index 751f1ec8594..606e9c3bfcd 100644 --- a/libs/common/src/key-management/key-connector/services/key-connector.service.ts +++ b/libs/common/src/key-management/key-connector/services/key-connector.service.ts @@ -38,7 +38,6 @@ import { KeyGenerationService } from "../../crypto"; import { EncString } from "../../crypto/models/enc-string"; import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction"; import { SecurityStateService } from "../../security-state/abstractions/security-state.service"; -import { SignedPublicKey, SignedSecurityState, WrappedSigningKey } from "../../types"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service"; import { KeyConnectorDomainConfirmation } from "../models/key-connector-domain-confirmation"; import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request"; @@ -246,22 +245,6 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { result.account_cryptographic_state, userId, ); - // Legacy states - await this.keyService.setPrivateKey(result.account_cryptographic_state.V2.private_key, userId); - await this.keyService.setUserSigningKey( - result.account_cryptographic_state.V2.signing_key as WrappedSigningKey, - userId, - ); - await this.securityStateService.setAccountSecurityState( - result.account_cryptographic_state.V2.security_state as SignedSecurityState, - userId, - ); - if (result.account_cryptographic_state.V2.signed_public_key != null) { - await this.keyService.setSignedPublicKey( - result.account_cryptographic_state.V2.signed_public_key as SignedPublicKey, - userId, - ); - } } async convertNewSsoUserToKeyConnectorV1( diff --git a/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts b/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts index 0e86761685f..9a5b39993e8 100644 --- a/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts +++ b/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts @@ -113,6 +113,23 @@ export abstract class MasterPasswordServiceAbstraction { * @throws If the user ID is missing. */ abstract userHasMasterPassword(userId: UserId): Promise; + + /** + * Derives a master key from the provided password and master password unlock data, + * then sets it to state for the specified user. This is a temporary backwards compatibility function + * to support existing code that relies on direct master key access. + * Note: This will be removed in https://bitwarden.atlassian.net/browse/PM-30676 + * + * @param password The master password. + * @param masterPasswordUnlockData The master password unlock data containing the KDF settings and salt. + * @param userId The user ID. + * @throws If the password, master password unlock data, or user ID is missing. + */ + abstract setLegacyMasterKeyFromUnlockData( + password: string, + masterPasswordUnlockData: MasterPasswordUnlockData, + userId: UserId, + ): Promise; } export abstract class InternalMasterPasswordServiceAbstraction extends MasterPasswordServiceAbstraction { diff --git a/libs/common/src/key-management/master-password/services/fake-master-password.service.ts b/libs/common/src/key-management/master-password/services/fake-master-password.service.ts index 90fcaddb1a5..40be7025d89 100644 --- a/libs/common/src/key-management/master-password/services/fake-master-password.service.ts +++ b/libs/common/src/key-management/master-password/services/fake-master-password.service.ts @@ -127,4 +127,12 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA masterPasswordUnlockData$(userId: UserId): Observable { return this.mock.masterPasswordUnlockData$(userId); } + + setLegacyMasterKeyFromUnlockData( + password: string, + masterPasswordUnlockData: MasterPasswordUnlockData, + userId: UserId, + ): Promise { + return this.mock.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId); + } } diff --git a/libs/common/src/key-management/master-password/services/master-password.service.spec.ts b/libs/common/src/key-management/master-password/services/master-password.service.spec.ts index e3d0bf51d67..f72ae0e7c5e 100644 --- a/libs/common/src/key-management/master-password/services/master-password.service.spec.ts +++ b/libs/common/src/key-management/master-password/services/master-password.service.spec.ts @@ -3,6 +3,7 @@ import { firstValueFrom } from "rxjs"; import { Jsonify } from "type-fest"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { HashPurpose } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; // eslint-disable-next-line no-restricted-imports import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management"; @@ -415,6 +416,125 @@ describe("MasterPasswordService", () => { ); }); + describe("setLegacyMasterKeyFromUnlockData", () => { + const password = "test-password"; + + it("derives master key from password and sets it in state", async () => { + const masterKey = makeSymmetricCryptoKey(32, 5) as MasterKey; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey); + cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32)); + + const masterPasswordUnlockData = new MasterPasswordUnlockData( + salt, + kdfPBKDF2, + makeEncString().toSdk() as MasterKeyWrappedUserKey, + ); + + await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId); + + expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith( + password, + masterPasswordUnlockData.salt, + masterPasswordUnlockData.kdf, + ); + + const state = await firstValueFrom(stateProvider.getUser(userId, MASTER_KEY).state$); + expect(state).toEqual(masterKey); + }); + + it("works with argon2 kdf config", async () => { + const masterKey = makeSymmetricCryptoKey(32, 6) as MasterKey; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey); + cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32)); + + const masterPasswordUnlockData = new MasterPasswordUnlockData( + salt, + kdfArgon2, + makeEncString().toSdk() as MasterKeyWrappedUserKey, + ); + + await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId); + + expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith( + password, + masterPasswordUnlockData.salt, + masterPasswordUnlockData.kdf, + ); + + const state = await firstValueFrom(stateProvider.getUser(userId, MASTER_KEY).state$); + expect(state).toEqual(masterKey); + }); + + it("computes and sets master key hash in state", async () => { + const masterKey = makeSymmetricCryptoKey(32, 7) as MasterKey; + const expectedHashBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + const expectedHashB64 = "AQIDBAUGBwg="; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey); + cryptoFunctionService.pbkdf2.mockResolvedValue(expectedHashBytes); + jest.spyOn(Utils, "fromBufferToB64").mockReturnValue(expectedHashB64); + + const masterPasswordUnlockData = new MasterPasswordUnlockData( + salt, + kdfPBKDF2, + makeEncString().toSdk() as MasterKeyWrappedUserKey, + ); + + await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId); + + expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledWith( + masterKey.inner().encryptionKey, + password, + "sha256", + HashPurpose.LocalAuthorization, + ); + + const hashState = await firstValueFrom(sut.masterKeyHash$(userId)); + expect(hashState).toEqual(expectedHashB64); + }); + + it("throws if password is null", async () => { + const masterPasswordUnlockData = new MasterPasswordUnlockData( + salt, + kdfPBKDF2, + makeEncString().toSdk() as MasterKeyWrappedUserKey, + ); + + await expect( + sut.setLegacyMasterKeyFromUnlockData( + null as unknown as string, + masterPasswordUnlockData, + userId, + ), + ).rejects.toThrow("password is null or undefined."); + }); + + it("throws if masterPasswordUnlockData is null", async () => { + await expect( + sut.setLegacyMasterKeyFromUnlockData( + password, + null as unknown as MasterPasswordUnlockData, + userId, + ), + ).rejects.toThrow("masterPasswordUnlockData is null or undefined."); + }); + + it("throws if userId is null", async () => { + const masterPasswordUnlockData = new MasterPasswordUnlockData( + salt, + kdfPBKDF2, + makeEncString().toSdk() as MasterKeyWrappedUserKey, + ); + + await expect( + sut.setLegacyMasterKeyFromUnlockData( + password, + masterPasswordUnlockData, + null as unknown as UserId, + ), + ).rejects.toThrow("userId is null or undefined."); + }); + }); + describe("MASTER_PASSWORD_UNLOCK_KEY", () => { it("has the correct configuration", () => { expect(MASTER_PASSWORD_UNLOCK_KEY.stateDefinition).toBeDefined(); diff --git a/libs/common/src/key-management/master-password/services/master-password.service.ts b/libs/common/src/key-management/master-password/services/master-password.service.ts index c2947b2263d..28d4f58d7dc 100644 --- a/libs/common/src/key-management/master-password/services/master-password.service.ts +++ b/libs/common/src/key-management/master-password/services/master-password.service.ts @@ -5,6 +5,7 @@ import { firstValueFrom, map, Observable } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { assertNonNullish } from "@bitwarden/common/auth/utils"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { HashPurpose } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; // eslint-disable-next-line no-restricted-imports import { KdfConfig } from "@bitwarden/key-management"; @@ -342,4 +343,51 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr return this.stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$; } + + async setLegacyMasterKeyFromUnlockData( + password: string, + masterPasswordUnlockData: MasterPasswordUnlockData, + userId: UserId, + ): Promise { + assertNonNullish(password, "password"); + assertNonNullish(masterPasswordUnlockData, "masterPasswordUnlockData"); + assertNonNullish(userId, "userId"); + + const masterKey = (await this.keyGenerationService.deriveKeyFromPassword( + password, + masterPasswordUnlockData.salt, + masterPasswordUnlockData.kdf, + )) as MasterKey; + const localKeyHash = await this.hashMasterKey( + password, + masterKey, + HashPurpose.LocalAuthorization, + ); + + await this.setMasterKey(masterKey, userId); + await this.setMasterKeyHash(localKeyHash, userId); + } + + // Copied from KeyService to avoid circular dependency. This will be dropped together with `setLegacyMatserKeyFromUnlockData`. + private async hashMasterKey( + password: string, + key: MasterKey, + hashPurpose: HashPurpose, + ): Promise { + if (password == null) { + throw new Error("password is required."); + } + if (key == null) { + throw new Error("key is required."); + } + + const iterations = hashPurpose === HashPurpose.LocalAuthorization ? 2 : 1; + const hash = await this.cryptoFunctionService.pbkdf2( + key.inner().encryptionKey, + password, + "sha256", + iterations, + ); + return Utils.fromBufferToB64(hash); + } } 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/security-state/abstractions/security-state.service.ts b/libs/common/src/key-management/security-state/abstractions/security-state.service.ts index 466095c2f45..108f012f076 100644 --- a/libs/common/src/key-management/security-state/abstractions/security-state.service.ts +++ b/libs/common/src/key-management/security-state/abstractions/security-state.service.ts @@ -11,11 +11,4 @@ export abstract class SecurityStateService { * must be used. This security state is validated on initialization of the SDK. */ abstract accountSecurityState$(userId: UserId): Observable; - /** - * Sets the security state for the provided user. - */ - abstract setAccountSecurityState( - accountSecurityState: SignedSecurityState, - userId: UserId, - ): Promise; } diff --git a/libs/common/src/key-management/security-state/services/security-state.service.ts b/libs/common/src/key-management/security-state/services/security-state.service.ts index 789d5171072..5e6bafb9e4e 100644 --- a/libs/common/src/key-management/security-state/services/security-state.service.ts +++ b/libs/common/src/key-management/security-state/services/security-state.service.ts @@ -1,26 +1,28 @@ -import { Observable } from "rxjs"; +import { map, Observable } from "rxjs"; -import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; +import { AccountCryptographicStateService } from "../../account-cryptography/account-cryptographic-state.service"; import { SignedSecurityState } from "../../types"; import { SecurityStateService } from "../abstractions/security-state.service"; -import { ACCOUNT_SECURITY_STATE } from "../state/security-state.state"; export class DefaultSecurityStateService implements SecurityStateService { - constructor(protected stateProvider: StateProvider) {} + constructor(private accountCryptographicStateService: AccountCryptographicStateService) {} // Emits the provided user's security state, or null if there is no security state present for the user. accountSecurityState$(userId: UserId): Observable { - return this.stateProvider.getUserState$(ACCOUNT_SECURITY_STATE, userId); - } + return this.accountCryptographicStateService.accountCryptographicState$(userId).pipe( + map((cryptographicState) => { + if (cryptographicState == null) { + return null; + } - // Sets the security state for the provided user. - // This is not yet validated, and is only validated upon SDK initialization. - async setAccountSecurityState( - accountSecurityState: SignedSecurityState, - userId: UserId, - ): Promise { - await this.stateProvider.setUserState(ACCOUNT_SECURITY_STATE, accountSecurityState, userId); + if ("V2" in cryptographicState) { + return cryptographicState.V2.security_state as SignedSecurityState; + } else { + return null; + } + }), + ); } } diff --git a/libs/common/src/key-management/security-state/state/security-state.state.ts b/libs/common/src/key-management/security-state/state/security-state.state.ts deleted file mode 100644 index e471ef17d76..00000000000 --- a/libs/common/src/key-management/security-state/state/security-state.state.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { CRYPTO_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; - -import { SignedSecurityState } from "../../types"; - -export const ACCOUNT_SECURITY_STATE = new UserKeyDefinition( - CRYPTO_DISK, - "accountSecurityState", - { - deserializer: (obj) => obj, - clearOn: ["logout"], - }, -); 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 ccb66a4dff4..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 }), @@ -260,6 +310,13 @@ describe("VaultTimeoutSettingsService", () => { }); describe("getVaultTimeoutByUserId$", () => { + beforeEach(() => { + // Return the input value unchanged + sessionTimeoutTypeService.getOrPromoteToAvailable.mockImplementation( + async (timeout) => timeout, + ); + }); + it("should throw an error if no user id is provided", async () => { expect(() => vaultTimeoutSettingsService.getVaultTimeoutByUserId$(null)).toThrow( "User id required. Cannot get vault timeout.", @@ -277,6 +334,9 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + defaultVaultTimeout, + ); expect(result).toBe(defaultVaultTimeout); }); @@ -299,8 +359,31 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + vaultTimeout, + ); expect(result).toBe(vaultTimeout); }); + + it("promotes timeout when unavailable on client", async () => { + const determinedTimeout = VaultTimeoutNumberType.OnMinute; + const promotedValue = VaultTimeoutStringType.OnRestart; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + policyService.policiesByType$.mockReturnValue(of([])); + + await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + determinedTimeout, + ); + expect(result).toBe(promotedValue); + }); }); describe("policy type: custom", () => { @@ -327,6 +410,9 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + policyMinutes, + ); expect(result).toBe(policyMinutes); }, ); @@ -345,6 +431,9 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + vaultTimeout, + ); expect(result).toBe(vaultTimeout); }, ); @@ -365,8 +454,36 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutNumberType.Immediately, + ); expect(result).toBe(VaultTimeoutNumberType.Immediately); }); + + it("promotes policy minutes when unavailable on client", async () => { + const promotedValue = VaultTimeoutStringType.Never; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState( + VAULT_TIMEOUT, + VaultTimeoutNumberType.EightHours, + mockUserId, + ); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + policyMinutes, + ); + expect(result).toBe(promotedValue); + }); }); describe("policy type: immediately", () => { @@ -383,7 +500,6 @@ describe("VaultTimeoutSettingsService", () => { "when current timeout is %s, returns immediately or promoted value", async (currentTimeout) => { const expectedTimeout = VaultTimeoutNumberType.Immediately; - sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout); policyService.policiesByType$.mockReturnValue( of([{ data: { type: "immediately" } }] as unknown as Policy[]), ); @@ -400,6 +516,26 @@ describe("VaultTimeoutSettingsService", () => { expect(result).toBe(expectedTimeout); }, ); + + it("promotes immediately when unavailable on client", async () => { + const promotedValue = VaultTimeoutNumberType.OnMinute; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "immediately" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutNumberType.Immediately, + ); + expect(result).toBe(promotedValue); + }); }); describe("policy type: onSystemLock", () => { @@ -413,7 +549,6 @@ describe("VaultTimeoutSettingsService", () => { "when current timeout is %s, returns onLocked or promoted value", async (currentTimeout) => { const expectedTimeout = VaultTimeoutStringType.OnLocked; - sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout); policyService.policiesByType$.mockReturnValue( of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]), ); @@ -446,9 +581,31 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + currentTimeout, + ); expect(result).toBe(currentTimeout); }); + + it("promotes onLocked when unavailable on client", async () => { + const promotedValue = VaultTimeoutStringType.OnRestart; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutStringType.OnLocked, + ); + expect(result).toBe(promotedValue); + }); }); describe("policy type: onAppRestart", () => { @@ -468,7 +625,9 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutStringType.OnRestart, + ); expect(result).toBe(VaultTimeoutStringType.OnRestart); }); @@ -488,32 +647,40 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + currentTimeout, + ); expect(result).toBe(currentTimeout); }); - }); - describe("policy type: never", () => { - it("when current timeout is never, returns never or promoted value", async () => { - const expectedTimeout = VaultTimeoutStringType.Never; - sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout); + it("promotes onRestart when unavailable on client", async () => { + const promotedValue = VaultTimeoutStringType.Never; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); policyService.policiesByType$.mockReturnValue( - of([{ data: { type: "never" } }] as unknown as Policy[]), + of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]), ); - await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId); + await stateProvider.setUserState( + VAULT_TIMEOUT, + VaultTimeoutStringType.OnLocked, + mockUserId, + ); const result = await firstValueFrom( vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( - VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnRestart, ); - expect(result).toBe(expectedTimeout); + expect(result).toBe(promotedValue); }); + }); + describe("policy type: never", () => { it.each([ + VaultTimeoutStringType.Never, VaultTimeoutStringType.OnRestart, VaultTimeoutStringType.OnLocked, VaultTimeoutStringType.OnIdle, @@ -532,9 +699,32 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + currentTimeout, + ); expect(result).toBe(currentTimeout); }); + + it("promotes timeout when unavailable on client", async () => { + const determinedTimeout = VaultTimeoutStringType.Never; + const promotedValue = VaultTimeoutStringType.OnRestart; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "never" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + determinedTimeout, + ); + expect(result).toBe(promotedValue); + }); }); }); 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 b8bc859d11c..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 { @@ -179,7 +196,20 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA private async determineVaultTimeout( currentVaultTimeout: VaultTimeout | null, maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null, - ): Promise { + ): Promise { + const determinedTimeout = await this.determineVaultTimeoutInternal( + currentVaultTimeout, + maxSessionTimeoutPolicyData, + ); + + // Ensures the timeout is available on this client + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(determinedTimeout); + } + + private async determineVaultTimeoutInternal( + currentVaultTimeout: VaultTimeout | null, + maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null, + ): Promise { // if current vault timeout is null, apply the client specific default currentVaultTimeout = currentVaultTimeout ?? this.defaultVaultTimeout; @@ -190,9 +220,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA switch (maxSessionTimeoutPolicyData.type) { case "immediately": - return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( - VaultTimeoutNumberType.Immediately, - ); + return VaultTimeoutNumberType.Immediately; case "custom": case null: case undefined: @@ -211,9 +239,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA currentVaultTimeout === VaultTimeoutStringType.OnIdle || currentVaultTimeout === VaultTimeoutStringType.OnSleep ) { - return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( - VaultTimeoutStringType.OnLocked, - ); + return VaultTimeoutStringType.OnLocked; } break; case "onAppRestart": @@ -227,11 +253,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA } break; case "never": - if (currentVaultTimeout === VaultTimeoutStringType.Never) { - return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( - VaultTimeoutStringType.Never, - ); - } + // Policy doesn't override user preference for "never" break; } return currentVaultTimeout; @@ -257,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]; } @@ -334,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/collection-with-id.export.ts b/libs/common/src/models/export/collection-with-id.export.ts index 9a372fbdfa9..eeb21d1b6d5 100644 --- a/libs/common/src/models/export/collection-with-id.export.ts +++ b/libs/common/src/models/export/collection-with-id.export.ts @@ -1,8 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { Collection as CollectionDomain, CollectionView } from "@bitwarden/admin-console/common"; +import { + CollectionView, + Collection as CollectionDomain, +} from "@bitwarden/common/admin-console/models/collections"; import { CollectionId } from "@bitwarden/common/types/guid"; import { CollectionExport } from "./collection.export"; diff --git a/libs/common/src/models/export/collection.export.ts b/libs/common/src/models/export/collection.export.ts index 631b31d8b7b..e02ae5fab49 100644 --- a/libs/common/src/models/export/collection.export.ts +++ b/libs/common/src/models/export/collection.export.ts @@ -1,8 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { Collection as CollectionDomain, CollectionView } from "@bitwarden/admin-console/common"; +import { + CollectionView, + Collection as CollectionDomain, +} from "@bitwarden/common/admin-console/models/collections"; import { EncString } from "../../key-management/crypto/models/enc-string"; import { CollectionId, emptyGuid, OrganizationId } from "../../types/guid"; 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/folder.export.ts b/libs/common/src/models/export/folder.export.ts index 96f0f1058b8..1bffcee8c2d 100644 --- a/libs/common/src/models/export/folder.export.ts +++ b/libs/common/src/models/export/folder.export.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { EncString } from "../../key-management/crypto/models/enc-string"; import { Folder as FolderDomain } from "../../vault/models/domain/folder"; import { FolderView } from "../../vault/models/view/folder.view"; @@ -7,6 +5,8 @@ import { FolderView } from "../../vault/models/view/folder.view"; import { safeGetString } from "./utils"; export class FolderExport { + name: string = ""; + static template(): FolderExport { const req = new FolderExport(); req.name = "Folder name"; @@ -19,14 +19,12 @@ export class FolderExport { } static toDomain(req: FolderExport, domain = new FolderDomain()) { - domain.name = req.name != null ? new EncString(req.name) : null; + domain.name = new EncString(req.name); return domain; } - name: string; - // Use build method instead of ctor so that we can control order of JSON stringify for pretty print build(o: FolderView | FolderDomain) { - this.name = safeGetString(o.name); + this.name = safeGetString(o.name ?? "") ?? ""; } } 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/abstractions/fido2/fido2-client.service.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-client.service.abstraction.ts index f1ad26673fd..058537af067 100644 --- a/libs/common/src/platform/abstractions/fido2/fido2-client.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/fido2/fido2-client.service.abstraction.ts @@ -136,11 +136,11 @@ export interface CreateCredentialResult { */ export interface AssertCredentialParams { allowedCredentialIds: string[]; - rpId: string; + rpId?: string; origin: string; challenge: string; userVerification?: UserVerification; - timeout: number; + timeout?: number; sameOriginWithAncestors: boolean; mediation?: "silent" | "optional" | "required" | "conditional"; fallbackSupported: boolean; 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/domain-utils.spec.ts b/libs/common/src/platform/services/fido2/domain-utils.spec.ts index 4b99c06cdec..df6132bac20 100644 --- a/libs/common/src/platform/services/fido2/domain-utils.spec.ts +++ b/libs/common/src/platform/services/fido2/domain-utils.spec.ts @@ -2,82 +2,377 @@ import { isValidRpId } from "./domain-utils"; // Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm. describe("validateRpId", () => { - it("should not be valid when rpId is more specific than origin", () => { - const rpId = "sub.login.bitwarden.com"; - const origin = "https://login.bitwarden.com:1337"; + let mockFetch: jest.Mock; + let webAuthnRelatedOriginsFeatureFlag = false; - expect(isValidRpId(rpId, origin)).toBe(false); + beforeEach(() => { + mockFetch = jest.fn(); + // Default: ROR requests fail (no .well-known/webauthn endpoint) + mockFetch.mockRejectedValue(new Error("Network error")); }); - it("should not be valid when effective domains of rpId and origin do not match", () => { - const rpId = "passwordless.dev"; - const origin = "https://login.bitwarden.com:1337"; + describe("classic domain validation", () => { + it("should not be valid when rpId is null", async () => { + const origin = "example.com"; - expect(isValidRpId(rpId, origin)).toBe(false); + expect(await isValidRpId(null, origin, webAuthnRelatedOriginsFeatureFlag)).toBe(false); + }); + + it("should not be valid when origin is null", async () => { + const rpId = "example.com"; + + expect(await isValidRpId(rpId, null, webAuthnRelatedOriginsFeatureFlag)).toBe(false); + }); + + it("should not be valid when rpId is more specific than origin", async () => { + const rpId = "sub.login.bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when effective domains of rpId and origin do not match", async () => { + const rpId = "passwordless.dev"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when subdomains are the same but effective domains of rpId and origin do not match", async () => { + const rpId = "login.passwordless.dev"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when rpId and origin are both different TLD", async () => { + const rpId = "bitwarden"; + const origin = "localhost"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + // Only allow localhost for rpId, need to properly investigate the implications of + // adding support for ip-addresses and other TLDs + it("should not be valid when rpId and origin are both the same TLD", async () => { + const rpId = "bitwarden"; + const origin = "bitwarden"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when rpId and origin are ip-addresses", async () => { + const rpId = "127.0.0.1"; + const origin = "127.0.0.1"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should be valid when domains of rpId and origin are localhost", async () => { + const rpId = "localhost"; + const origin = "https://localhost:8080"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should be valid when domains of rpId and origin are the same", async () => { + const rpId = "bitwarden.com"; + const origin = "https://bitwarden.com"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should be valid when origin is a subdomain of rpId", async () => { + const rpId = "bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should be valid when domains of rpId and origin are the same and they are both subdomains", async () => { + const rpId = "login.bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should be valid when origin is a subdomain of rpId and they are both subdomains", async () => { + const rpId = "login.bitwarden.com"; + const origin = "https://sub.login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should not be valid for a partial match of a subdomain", async () => { + const rpId = "accounts.example.com"; + const origin = "https://evilaccounts.example.com"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag)).toBe(false); + }); }); - it("should not be valid when subdomains are the same but effective domains of rpId and origin do not match", () => { - const rpId = "login.passwordless.dev"; - const origin = "https://login.bitwarden.com:1337"; + describe("Related Origin Requests (ROR)", () => { + // Helper to create a mock fetch response + function mockRorResponse(origins: string[], status = 200, contentType = "application/json") { + mockFetch.mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + headers: new Headers({ "content-type": contentType }), + json: async () => ({ origins }), + }); + } - expect(isValidRpId(rpId, origin)).toBe(false); - }); + it("should not proceed with ROR check when valid when feature flag disabled", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - it("should not be valid when rpId and origin are both different TLD", () => { - const rpId = "bitwarden"; - const origin = "localhost"; + mockRorResponse([origin, "https://www.facebook.com", "https://www.instagram.com"]); - expect(isValidRpId(rpId, origin)).toBe(false); - }); + expect(await isValidRpId(rpId, origin, false, mockFetch)).toBe(false); + expect(mockFetch).not.toHaveBeenCalledWith( + `https://${rpId}/.well-known/webauthn`, + expect.objectContaining({ + credentials: "omit", + referrerPolicy: "no-referrer", + }), + ); + }); - // Only allow localhost for rpId, need to properly investigate the implications of - // adding support for ip-addresses and other TLDs - it("should not be valid when rpId and origin are both the same TLD", () => { - const rpId = "bitwarden"; - const origin = "bitwarden"; + webAuthnRelatedOriginsFeatureFlag = true; - expect(isValidRpId(rpId, origin)).toBe(false); - }); + it("should be valid when origin is listed in .well-known/webauthn", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - it("should not be valid when rpId and origin are ip-addresses", () => { - const rpId = "127.0.0.1"; - const origin = "127.0.0.1"; + mockRorResponse([origin, "https://www.facebook.com", "https://www.instagram.com"]); - expect(isValidRpId(rpId, origin)).toBe(false); - }); + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + expect(mockFetch).toHaveBeenCalledWith( + `https://${rpId}/.well-known/webauthn`, + expect.objectContaining({ + credentials: "omit", + referrerPolicy: "no-referrer", + }), + ); + }); - it("should be valid when domains of rpId and origin are localhost", () => { - const rpId = "localhost"; - const origin = "https://localhost:8080"; + it("should not be valid when origin is not listed in .well-known/webauthn", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://evil.com"; - expect(isValidRpId(rpId, origin)).toBe(true); - }); + mockRorResponse(["https://www.facebook.com", "https://www.instagram.com"]); - it("should be valid when domains of rpId and origin are the same", () => { - const rpId = "bitwarden.com"; - const origin = "https://bitwarden.com"; + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - expect(isValidRpId(rpId, origin)).toBe(true); - }); + it("should not be valid when .well-known/webauthn returns non-200 status", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - it("should be valid when origin is a subdomain of rpId", () => { - const rpId = "bitwarden.com"; - const origin = "https://login.bitwarden.com:1337"; + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + headers: new Headers({ "content-type": "application/json" }), + }); - expect(isValidRpId(rpId, origin)).toBe(true); - }); + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - it("should be valid when domains of rpId and origin are the same and they are both subdomains", () => { - const rpId = "login.bitwarden.com"; - const origin = "https://login.bitwarden.com:1337"; + it("should not be valid when .well-known/webauthn returns non-JSON content-type", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - expect(isValidRpId(rpId, origin)).toBe(true); - }); + mockRorResponse([origin], 200, "text/html"); - it("should be valid when origin is a subdomain of rpId and they are both subdomains", () => { - const rpId = "login.bitwarden.com"; - const origin = "https://sub.login.bitwarden.com:1337"; + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - expect(isValidRpId(rpId, origin)).toBe(true); + it("should not be valid when .well-known/webauthn response has no origins array", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ notOrigins: "invalid" }), + }); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when .well-known/webauthn response has empty origins array", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockRorResponse([]); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when .well-known/webauthn response has non-string origins", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ origins: [123, { url: origin }] }), + }); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when fetch throws an error", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockFetch.mockRejectedValue(new Error("Network error")); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when fetch times out", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockFetch.mockRejectedValue(new DOMException("The operation was aborted.", "AbortError")); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should skip classic validation and use ROR when domains do not match", async () => { + // This is the Facebook/Meta use case + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockRorResponse([origin]); + + // Classic validation would fail (different domains), but ROR should succeed + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should not call ROR endpoint when classic validation succeeds", async () => { + const rpId = "bitwarden.com"; + const origin = "https://bitwarden.com"; + + // Classic validation succeeds, so ROR should not be called + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("should require exact origin match (including port)", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com:8443"; + + // Only the non-port version is listed + mockRorResponse(["https://accountscenter.facebook.com"]); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should handle invalid URLs in origins array gracefully", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ + origins: ["not-a-valid-url", "://also-invalid", origin], + }), + }); + + // Should still find the valid origin despite invalid entries + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should enforce max labels limit", async () => { + const rpId = "example.com"; + const origin = "https://site6.com"; + + // Create origins from 6 different eTLD+1 labels + // Only the first 5 should be processed + mockRorResponse([ + "https://site1.com", + "https://site2.com", + "https://site3.com", + "https://site4.com", + "https://site5.com", + "https://site6.com", // This is the 6th label, should be skipped + ]); + + // The origin is in the list but should be skipped due to max labels limit + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should allow multiple origins from the same eTLD+1", async () => { + const rpId = "example.com"; + const origin = "https://sub2.facebook.com"; + + // All these are from facebook.com (same eTLD+1), so they count as 1 label + mockRorResponse([ + "https://www.facebook.com", + "https://sub1.facebook.com", + "https://sub2.facebook.com", + "https://sub3.facebook.com", + ]); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); }); }); diff --git a/libs/common/src/platform/services/fido2/domain-utils.ts b/libs/common/src/platform/services/fido2/domain-utils.ts index 67874355908..dafc270ea9a 100644 --- a/libs/common/src/platform/services/fido2/domain-utils.ts +++ b/libs/common/src/platform/services/fido2/domain-utils.ts @@ -1,17 +1,237 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { parse } from "tldts"; -export function isValidRpId(rpId: string, origin: string) { +/** + * Maximum number of unique eTLD+1 labels to process when checking Related Origin Requests. + * This limit prevents malicious servers from causing excessive processing. + * Per WebAuthn spec recommendation. + */ +const ROR_MAX_LABELS = 5; + +/** + * Timeout in milliseconds for fetching the .well-known/webauthn endpoint. + */ +const ROR_FETCH_TIMEOUT_MS = 5000; + +/** + * Validates whether a Relying Party ID (rpId) is valid for a given origin according to classic + * WebAuthn specifications (before Related Origin Requests extension). + * + * This implements the core WebAuthn RP ID validation logic: + * - The origin must use the HTTPS scheme (except localhost) + * - Both rpId and origin must be valid domain names (not IP addresses) + * - Both must have the same registrable domain (eTLD+1) + * - The origin must either exactly match the rpId or be a subdomain of it + * - Single-label domains are rejected unless they are 'localhost' + * - Localhost is always valid when both rpId and origin are localhost + * + * This is used internally as the first validation step before falling back to + * Related Origin Requests (ROR) validation. + * + * @see https://www.w3.org/TR/webauthn-2/#rp-id + * + * @param rpId - The Relying Party identifier to validate + * @param origin - The origin URL to validate against (must start with https://) + * @returns `true` if the rpId is valid for the given origin, `false` otherwise + */ +function isValidRpIdInternal(rpId: string, origin: string) { + if (!rpId || !origin) { + return false; + } + const parsedOrigin = parse(origin, { allowPrivateDomains: true }); const parsedRpId = parse(rpId, { allowPrivateDomains: true }); - return ( - (parsedOrigin.domain == null && - parsedOrigin.hostname == parsedRpId.hostname && - parsedOrigin.hostname == "localhost") || - (parsedOrigin.domain != null && - parsedOrigin.domain == parsedRpId.domain && - parsedOrigin.subdomain.endsWith(parsedRpId.subdomain)) - ); + if (!parsedRpId || !parsedOrigin) { + return false; + } + + // Special case: localhost is always valid when both match + if (parsedRpId.hostname === "localhost" && parsedOrigin.hostname === "localhost") { + return true; + } + + // The origin's scheme must be https. + if (!origin.startsWith("https://")) { + return false; + } + + // Reject IP addresses (both must be domain names) + if (parsedRpId.isIp || parsedOrigin.isIp) { + return false; + } + + // Reject single-label domains (TLDs) unless it's localhost + // This ensures we have proper domains like "example.com" not just "example" + if (rpId !== "localhost" && !rpId.includes(".")) { + return false; + } + + if ( + parsedOrigin.hostname != null && + parsedOrigin.hostname !== "localhost" && + !parsedOrigin.hostname.includes(".") + ) { + return false; + } + + // The registrable domains must match + // This ensures a.example.com and b.example.com share base domain + if (parsedRpId.domain !== parsedOrigin.domain) { + return false; + } + + // Check exact match + if (parsedOrigin.hostname === rpId) { + return true; + } + + // Check if origin is a subdomain of rpId + // This prevents "evilaccounts.example.com" from matching "accounts.example.com" + if (parsedOrigin.hostname != null && parsedOrigin.hostname.endsWith("." + rpId)) { + return true; + } +} + +/** + * Checks if the origin is allowed to use the given rpId via Related Origin Requests (ROR). + * This implements the WebAuthn Related Origin Requests spec which allows an RP to + * authorize origins from different domains to use its rpId. + * + * @see https://w3c.github.io/webauthn/#sctn-related-origins + * + * @param rpId - The relying party ID being requested + * @param origin - The origin making the WebAuthn request + * @param fetchFn - Optional fetch function for testing, defaults to global fetch + * @returns Promise that resolves to true if the origin is allowed via ROR, false otherwise + */ +async function isAllowedByRor( + rpId: string, + origin: string, + fetchFn?: typeof fetch, +): Promise { + try { + const fetchImpl = fetchFn ?? globalThis.fetch; + + // Create abort signal with timeout - use AbortSignal.timeout if available, otherwise use AbortController + let signal: AbortSignal; + if (typeof AbortSignal.timeout === "function") { + signal = AbortSignal.timeout(ROR_FETCH_TIMEOUT_MS); + } else { + const controller = new AbortController(); + setTimeout(() => controller.abort(), ROR_FETCH_TIMEOUT_MS); + signal = controller.signal; + } + + const response = await fetchImpl(`https://${rpId}/.well-known/webauthn`, { + credentials: "omit", + referrerPolicy: "no-referrer", + signal, + }); + + if (!response.ok) { + return false; + } + + const contentType = response.headers.get("content-type"); + if (!contentType || !contentType.includes("application/json")) { + return false; + } + + const data = (await response.json()) as { origins?: unknown }; + + if ( + !data || + !Array.isArray(data.origins) || + !data.origins.every((o) => typeof o === "string") || + data.origins.length === 0 + ) { + return false; + } + + // Track unique labels (eTLD+1) to enforce the max labels limit + const labelsSeen = new Set(); + + for (const allowedOrigin of data.origins as string[]) { + try { + const url = new URL(allowedOrigin); + const hostname = url.hostname; + if (!hostname) { + continue; + } + + const parsed = parse(hostname, { allowPrivateDomains: true }); + if (!parsed.domain || !parsed.publicSuffix) { + continue; + } + + // Extract the label (the part before the public suffix) + const label = parsed.domain.slice(0, parsed.domain.length - parsed.publicSuffix.length - 1); + + if (!label) { + continue; + } + + // Skip if we've already seen max labels and this is a new one + if (labelsSeen.size >= ROR_MAX_LABELS && !labelsSeen.has(label)) { + continue; + } + + // Check for exact origin match + if (origin === allowedOrigin) { + return true; + } + + // Track the label if we haven't hit the limit + if (labelsSeen.size < ROR_MAX_LABELS) { + labelsSeen.add(label); + } + } catch { + // Invalid URL, skip this entry + continue; + } + } + + return false; + } catch { + // Network error, timeout, or other failure - fail closed + return false; + } +} + +/* Validates whether a Relying Party ID (rpId) is valid for a given origin according to WebAuthn specifications. + * If that fails, checks if the origin is authorized via Related Origin Requests (ROR). + * + * The validation enforces the following rules: + * - The origin must use the HTTPS scheme + * - Both rpId and origin must be valid domain names (not IP addresses) + * - Both must have the same registrable domain (e.g., example.com) + * - The origin must either exactly match the rpId or be a subdomain of it + * - Single-label domains are rejected unless they are 'localhost' + * - Localhost is always valid when both rpId and origin are localhost + * + * @param rpId - The Relying Party identifier to validate + * @param origin - The origin URL to validate against (must start with https://) + * @param fetchFn - Optional fetch function for testing, defaults to global fetch + * @returns `true` if the rpId is valid for the given origin, `false` otherwise + * + */ +export async function isValidRpId( + rpId: string, + origin: string, + relatedOriginChecksEnabled: boolean, + fetchFn?: typeof fetch, +): Promise { + // Classic WebAuthn validation: rpId must be a registrable domain suffix of the origin + const classicMatch = isValidRpIdInternal(rpId, origin); + + if (classicMatch) { + return true; + } + + if (!relatedOriginChecksEnabled) { + return false; + } + + // Fall back to Related Origin Requests (ROR) validation + return await isAllowedByRor(rpId, origin, fetchFn); } 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/fido2/fido2-client.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts index 4fd91fb19e6..7b298110040 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts @@ -71,6 +71,8 @@ describe("FidoAuthenticatorService", () => { isValidRpId = jest.spyOn(DomainUtils, "isValidRpId"); + configService.getFeatureFlag$.mockReturnValue(of(false)); + client = new Fido2ClientService( authenticator, configService, @@ -186,7 +188,7 @@ describe("FidoAuthenticatorService", () => { const params = createParams(); authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); // `params` actually has a valid rp.id, but we're mocking the function to return false - isValidRpId.mockReturnValue(false); + isValidRpId.mockResolvedValue(false); const result = async () => await client.createCredential(params, windowReference); @@ -459,7 +461,7 @@ describe("FidoAuthenticatorService", () => { const params = createParams(); authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); // `params` actually has a valid rp.id, but we're mocking the function to return false - isValidRpId.mockReturnValue(false); + isValidRpId.mockResolvedValue(false); const result = async () => await client.assertCredential(params, windowReference); diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.ts b/libs/common/src/platform/services/fido2/fido2-client.service.ts index 503ffef8241..8fabed450f8 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.ts @@ -3,6 +3,8 @@ import { firstValueFrom, Subscription } from "rxjs"; import { parse } from "tldts"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; + import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; @@ -30,7 +32,6 @@ import { Fido2ClientService as Fido2ClientServiceAbstraction, PublicKeyCredentialParam, UserRequestedFallbackAbortReason, - UserVerification, } from "../../abstractions/fido2/fido2-client.service.abstraction"; import { LogService } from "../../abstractions/log.service"; import { Utils } from "../../misc/utils"; @@ -63,6 +64,9 @@ export class Fido2ClientService< MAX: 600000, }, }; + protected readonly relatedOriginChecksEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.WebAuthnRelatedOrigins, + ); constructor( private authenticator: Fido2AuthenticatorService, @@ -143,7 +147,13 @@ export class Fido2ClientService< throw new DOMException("'origin' is not a valid https origin", "SecurityError"); } - if (!isValidRpId(params.rp.id, params.origin)) { + if ( + !(await isValidRpId( + params.rp.id, + params.origin, + await firstValueFrom(this.relatedOriginChecksEnabled$), + )) + ) { this.logService?.warning( `[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rp.id}; origin = ${params.origin}`, ); @@ -195,7 +205,7 @@ export class Fido2ClientService< } const timeoutSubscription = this.setAbortTimeout( abortController, - params.authenticatorSelection?.userVerification, + makeCredentialParams.requireUserVerification, params.timeout, ); @@ -282,7 +292,13 @@ export class Fido2ClientService< throw new DOMException("'origin' is not a valid https origin", "SecurityError"); } - if (!isValidRpId(params.rpId, params.origin)) { + if ( + !(await isValidRpId( + params.rpId, + params.origin, + await firstValueFrom(this.relatedOriginChecksEnabled$), + )) + ) { this.logService?.warning( `[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rpId}; origin = ${params.origin}`, ); @@ -318,7 +334,7 @@ export class Fido2ClientService< const timeoutSubscription = this.setAbortTimeout( abortController, - params.userVerification, + getAssertionParams.requireUserVerification, params.timeout, ); @@ -441,13 +457,13 @@ export class Fido2ClientService< private setAbortTimeout = ( abortController: AbortController, - userVerification?: UserVerification, + requireUserVerification: boolean, timeout?: number, ): Subscription => { let clampedTimeout: number; const { WITH_VERIFICATION, NO_VERIFICATION } = this.TIMEOUTS; - if (userVerification === "required") { + if (requireUserVerification) { timeout = timeout ?? WITH_VERIFICATION.DEFAULT; clampedTimeout = Math.max(WITH_VERIFICATION.MIN, Math.min(timeout, WITH_VERIFICATION.MAX)); } else { diff --git a/libs/common/src/platform/services/key-state/user-key.state.spec.ts b/libs/common/src/platform/services/key-state/user-key.state.spec.ts index 2ea3c31bc1b..7fe6618ff72 100644 --- a/libs/common/src/platform/services/key-state/user-key.state.spec.ts +++ b/libs/common/src/platform/services/key-state/user-key.state.spec.ts @@ -1,13 +1,4 @@ -import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string"; -import { EncryptionType } from "../../enums"; -import { Utils } from "../../misc/utils"; - -import { USER_ENCRYPTED_PRIVATE_KEY, USER_EVER_HAD_USER_KEY } from "./user-key.state"; - -function makeEncString(data?: string) { - data ??= Utils.newGuid(); - return new EncString(EncryptionType.AesCbc256_HmacSha256_B64, data, "test", "test"); -} +import { USER_EVER_HAD_USER_KEY } from "./user-key.state"; describe("Ever had user key", () => { const sut = USER_EVER_HAD_USER_KEY; @@ -20,17 +11,3 @@ describe("Ever had user key", () => { expect(result).toEqual(everHadUserKey); }); }); - -describe("Encrypted private key", () => { - const sut = USER_ENCRYPTED_PRIVATE_KEY; - - it("should deserialize encrypted private key", () => { - const encryptedPrivateKey = makeEncString().encryptedString; - - const result = sut.deserializer( - JSON.parse(JSON.stringify(encryptedPrivateKey as unknown)) as unknown as EncryptedString, - ); - - expect(result).toEqual(encryptedPrivateKey); - }); -}); diff --git a/libs/common/src/platform/services/key-state/user-key.state.ts b/libs/common/src/platform/services/key-state/user-key.state.ts index 64577768c8d..58560a8bf0b 100644 --- a/libs/common/src/platform/services/key-state/user-key.state.ts +++ b/libs/common/src/platform/services/key-state/user-key.state.ts @@ -1,5 +1,3 @@ -import { EncryptedString } from "../../../key-management/crypto/models/enc-string"; -import { SignedPublicKey, WrappedSigningKey } from "../../../key-management/types"; import { UserKey } from "../../../types/key"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; import { CRYPTO_DISK, CRYPTO_MEMORY, UserKeyDefinition } from "../../state"; @@ -13,34 +11,7 @@ export const USER_EVER_HAD_USER_KEY = new UserKeyDefinition( }, ); -export const USER_ENCRYPTED_PRIVATE_KEY = new UserKeyDefinition( - CRYPTO_DISK, - "privateKey", - { - deserializer: (obj) => obj, - clearOn: ["logout"], - }, -); - export const USER_KEY = new UserKeyDefinition(CRYPTO_MEMORY, "userKey", { deserializer: (obj) => SymmetricCryptoKey.fromJSON(obj) as UserKey, clearOn: ["logout", "lock"], }); - -export const USER_KEY_ENCRYPTED_SIGNING_KEY = new UserKeyDefinition( - CRYPTO_DISK, - "userSigningKey", - { - deserializer: (obj) => obj, - clearOn: ["logout"], - }, -); - -export const USER_SIGNED_PUBLIC_KEY = new UserKeyDefinition( - CRYPTO_DISK, - "userSignedPublicKey", - { - deserializer: (obj) => obj, - clearOn: ["logout"], - }, -); diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts index fb9c1fae77e..2a1a3497887 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts @@ -143,17 +143,44 @@ describe("DefaultSdkService", () => { }); it("destroys the internal SDK client when all subscriptions are closed", async () => { + jest.useFakeTimers(); const subject_1 = new BehaviorSubject | undefined>(undefined); const subject_2 = new BehaviorSubject | undefined>(undefined); const subscription_1 = service.userClient$(userId).subscribe(subject_1); const subscription_2 = service.userClient$(userId).subscribe(subject_2); - await new Promise(process.nextTick); + await jest.advanceTimersByTimeAsync(0); subscription_1.unsubscribe(); subscription_2.unsubscribe(); - await new Promise(process.nextTick); + await jest.advanceTimersByTimeAsync(0); + expect(mockClient.free).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(1000); expect(mockClient.free).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + it("does not destroy the internal SDK client if resubscribed within 1 second", async () => { + jest.useFakeTimers(); + const subject_1 = new BehaviorSubject | undefined>(undefined); + const subscription_1 = service.userClient$(userId).subscribe(subject_1); + await jest.advanceTimersByTimeAsync(0); + + subscription_1.unsubscribe(); + await jest.advanceTimersByTimeAsync(500); + expect(mockClient.free).not.toHaveBeenCalled(); + + // Resubscribe before the 1 second delay + const subject_2 = new BehaviorSubject | undefined>(undefined); + const subscription_2 = service.userClient$(userId).subscribe(subject_2); + await jest.advanceTimersByTimeAsync(1000); + + // Client should not be freed since we resubscribed + expect(mockClient.free).not.toHaveBeenCalled(); + expect(sdkClientFactory.createSdkClient).toHaveBeenCalledTimes(1); + subscription_2.unsubscribe(); + jest.useRealTimers(); }); it("destroys the internal SDK client when the userKey is unset (i.e. lock or logout)", async () => { @@ -218,6 +245,7 @@ describe("DefaultSdkService", () => { }); it("destroys the internal client when an override is set", async () => { + jest.useFakeTimers(); const mockInternalClient = createMockClient(); const mockOverrideClient = createMockClient(); sdkClientFactory.createSdkClient.mockResolvedValue(mockInternalClient); @@ -227,7 +255,10 @@ describe("DefaultSdkService", () => { service.setClient(userId, mockOverrideClient); await userClientTracker.pauseUntilReceived(2); + expect(mockInternalClient.free).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(1000); expect(mockInternalClient.free).toHaveBeenCalled(); + jest.useRealTimers(); }); it("destroys the override client when explicitly setting the client to undefined", async () => { 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..e5104f3c68d 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -2,7 +2,10 @@ import { combineLatest, concatMap, Observable, + share, shareReplay, + ReplaySubject, + timer, map, distinctUntilChanged, tap, @@ -80,7 +83,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 +213,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, @@ -263,7 +266,10 @@ export class DefaultSdkService implements SdkService { }, ), tap({ finalize: () => this.sdkClientCache.delete(userId) }), - shareReplay({ refCount: true, bufferSize: 1 }), + share({ + connector: () => new ReplaySubject(1), + resetOnRefCountZero: () => timer(1000), + }), ); this.sdkClientCache.set(userId, client$); @@ -322,11 +328,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..3d9ebc49718 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; @@ -199,7 +199,10 @@ describe("DefaultSyncService", () => { new EncString("encryptedUserKey"), user1, ); - expect(keyService.setPrivateKey).toHaveBeenCalledWith("privateKey", user1); + expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith( + { V1: { private_key: "privateKey" } }, + user1, + ); expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1); expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1); }); @@ -242,7 +245,10 @@ describe("DefaultSyncService", () => { new EncString("encryptedUserKey"), user1, ); - expect(keyService.setPrivateKey).toHaveBeenCalledWith("wrappedPrivateKey", user1); + expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith( + { V1: { private_key: "wrappedPrivateKey" } }, + user1, + ); expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1); expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1); }); @@ -293,12 +299,7 @@ describe("DefaultSyncService", () => { new EncString("encryptedUserKey"), user1, ); - expect(keyService.setPrivateKey).toHaveBeenCalledWith("wrappedPrivateKey", user1); - expect(keyService.setUserSigningKey).toHaveBeenCalledWith("wrappedSigningKey", user1); - expect(securityStateService.setAccountSecurityState).toHaveBeenCalledWith( - "securityState", - user1, - ); + expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalled(); expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1); expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1); }); diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index fdd05927b50..68c03503e8d 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -4,20 +4,25 @@ import { firstValueFrom, map } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports +import { CollectionService } from "@bitwarden/admin-console/common"; import { CollectionData, CollectionDetailsResponse, - CollectionService, -} from "@bitwarden/admin-console/common"; +} 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"; // 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 { KdfConfigService, KeyService } from "@bitwarden/key-management"; +import { EncString as SdkEncString } from "@bitwarden/sdk-internal"; -// 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 +98,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, @@ -245,29 +250,15 @@ export class DefaultSyncService extends CoreSyncService { response.accountKeys.toWrappedAccountCryptographicState(), response.id, ); - - // V1 and V2 users - await this.keyService.setPrivateKey( - response.accountKeys.publicKeyEncryptionKeyPair.wrappedPrivateKey, + } else { + await this.accountCryptographicStateService.setAccountCryptographicState( + { + V1: { + private_key: response.privateKey as SdkEncString, + }, + }, response.id, ); - // V2 users only - if (response.accountKeys.isV2Encryption()) { - await this.keyService.setUserSigningKey( - response.accountKeys.signatureKeyPair.wrappedSigningKey, - response.id, - ); - await this.securityStateService.setAccountSecurityState( - response.accountKeys.securityState.securityState, - response.id, - ); - await this.keyService.setSignedPublicKey( - response.accountKeys.publicKeyEncryptionKeyPair.signedPublicKey, - response.id, - ); - } - } else { - await this.keyService.setPrivateKey(response.privateKey, response.id); } await this.keyService.setProviderKeys(response.providers, response.id); await this.keyService.setOrgKeys( @@ -450,5 +441,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/platform/sync/sync.response.ts b/libs/common/src/platform/sync/sync.response.ts index 27b1145752b..bf378fe9aaf 100644 --- a/libs/common/src/platform/sync/sync.response.ts +++ b/libs/common/src/platform/sync/sync.response.ts @@ -1,6 +1,4 @@ -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { CollectionDetailsResponse } from "@bitwarden/admin-console/common"; +import { CollectionDetailsResponse } from "@bitwarden/common/admin-console/models/collections"; import { PolicyResponse } from "../../admin-console/models/response/policy.response"; import { UserDecryptionResponse } from "../../key-management/models/response/user-decryption.response"; diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 10f349fbec7..33e251f6411 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -4,16 +4,15 @@ import { firstValueFrom, map } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports +import { CreateCollectionRequest, UpdateCollectionRequest } from "@bitwarden/admin-console/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 +import { LogoutReason } from "@bitwarden/auth/common"; import { CollectionAccessDetailsResponse, CollectionDetailsResponse, CollectionResponse, - CreateCollectionRequest, - UpdateCollectionRequest, -} from "@bitwarden/admin-console/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 -import { LogoutReason } from "@bitwarden/auth/common"; +} from "@bitwarden/common/admin-console/models/collections"; import { ApiService as ApiServiceAbstraction } from "../abstractions/api.service"; import { OrganizationConnectionType } from "../admin-console/enums"; @@ -330,6 +329,7 @@ export class ApiService implements ApiServiceAbstraction { return new PaymentResponse(r); } + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page postReinstatePremium(): Promise { return this.send("POST", "/accounts/reinstate-premium", null, true, false); } diff --git a/libs/common/src/tools/password-strength/password-strength.service.ts b/libs/common/src/tools/password-strength/password-strength.service.ts index 77854943acd..3d9df6dd1ab 100644 --- a/libs/common/src/tools/password-strength/password-strength.service.ts +++ b/libs/common/src/tools/password-strength/password-strength.service.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import * as zxcvbn from "zxcvbn"; +import zxcvbn from "zxcvbn"; import { PasswordStrengthServiceAbstraction } from "./password-strength.service.abstraction"; diff --git a/libs/common/src/tools/send/models/data/send.data.ts b/libs/common/src/tools/send/models/data/send.data.ts index 2c6377de0c9..b4317c48959 100644 --- a/libs/common/src/tools/send/models/data/send.data.ts +++ b/libs/common/src/tools/send/models/data/send.data.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { SendType } from "../../enums/send-type"; +import { AuthType } from "../../types/auth-type"; +import { SendType } from "../../types/send-type"; import { SendResponse } from "../response/send.response"; import { SendFileData } from "./send-file.data"; @@ -24,6 +25,7 @@ export class SendData { emails: string; disabled: boolean; hideEmail: boolean; + authType: AuthType; constructor(response?: SendResponse) { if (response == null) { @@ -33,6 +35,7 @@ export class SendData { this.id = response.id; this.accessId = response.accessId; this.type = response.type; + this.authType = response.authType; this.name = response.name; this.notes = response.notes; this.key = response.key; @@ -45,6 +48,7 @@ export class SendData { this.emails = response.emails; 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-access.spec.ts b/libs/common/src/tools/send/models/domain/send-access.spec.ts index 686236bff8e..58083d8a4bb 100644 --- a/libs/common/src/tools/send/models/domain/send-access.spec.ts +++ b/libs/common/src/tools/send/models/domain/send-access.spec.ts @@ -1,7 +1,7 @@ import { mock } from "jest-mock-extended"; import { mockContainerService, mockEnc } from "../../../../../spec"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendAccessResponse } from "../response/send-access.response"; import { SendAccess } from "./send-access"; diff --git a/libs/common/src/tools/send/models/domain/send-access.ts b/libs/common/src/tools/send/models/domain/send-access.ts index 68d1af7b57e..1877a5c1148 100644 --- a/libs/common/src/tools/send/models/domain/send-access.ts +++ b/libs/common/src/tools/send/models/domain/send-access.ts @@ -3,7 +3,7 @@ import { EncString } from "../../../../key-management/crypto/models/enc-string"; import Domain from "../../../../platform/models/domain/domain-base"; import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendAccessResponse } from "../response/send-access.response"; import { SendAccessView } from "../view/send-access.view"; diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index dc9ca7d3444..b3fc3c4c3ef 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 @@ -11,10 +12,10 @@ import { EncryptService } from "../../../../key-management/crypto/abstractions/e import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "../../../../platform/services/container.service"; import { UserKey } from "../../../../types/key"; -import { SendType } from "../../enums/send-type"; +import { 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", () => { @@ -39,9 +40,10 @@ describe("Send", () => { expirationDate: "2022-01-31T12:00:00.000Z", deletionDate: "2022-01-31T12:00:00.000Z", password: "password", - emails: null!, + emails: "", disabled: false, hideEmail: true, + authType: AuthType.None, }; mockContainerService(); @@ -55,6 +57,7 @@ describe("Send", () => { id: null, accessId: null, type: undefined, + authType: undefined, name: null, notes: null, text: undefined, @@ -66,6 +69,7 @@ describe("Send", () => { expirationDate: null, deletionDate: null, password: undefined, + emails: undefined, disabled: undefined, hideEmail: undefined, }); @@ -91,9 +95,10 @@ 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: "", disabled: false, hideEmail: true, + authType: AuthType.None, }); }); @@ -107,6 +112,7 @@ describe("Send", () => { send.id = "id"; send.accessId = "accessId"; send.type = SendType.Text; + send.authType = AuthType.None; send.name = mockEnc("name"); send.notes = mockEnc("notes"); send.text = text; @@ -116,14 +122,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)); @@ -132,12 +146,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", @@ -155,8 +163,175 @@ 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 parsing", () => { + 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 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 = "test@example.com"; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + const view = await send.decrypt(userId); + expect(view.emails).toEqual(["test@example.com"]); + }); + + it("should 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 = "test@example.com,user@test.com,admin@domain.com"; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + const view = await send.decrypt(userId); + expect(view.emails).toEqual(["test@example.com", "user@test.com", "admin@domain.com"]); + }); + + it("should trim whitespace from 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 = " test@example.com , user@test.com "; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + 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 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 = ""; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + 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 2bf16de8a44..6e35bde8bfc 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -8,7 +8,8 @@ import { UserId } from "@bitwarden/common/types/guid"; import { EncString } from "../../../../key-management/crypto/models/enc-string"; import { Utils } from "../../../../platform/misc/utils"; import Domain from "../../../../platform/models/domain/domain-base"; -import { SendType } from "../../enums/send-type"; +import { AuthType } from "../../types/auth-type"; +import { SendType } from "../../types/send-type"; import { SendData } from "../data/send.data"; import { SendView } from "../view/send.view"; @@ -33,6 +34,7 @@ export class Send extends Domain { emails: string; disabled: boolean; hideEmail: boolean; + authType: AuthType; constructor(obj?: SendData) { super(); @@ -54,15 +56,17 @@ export class Send extends Domain { ); this.type = obj.type; + this.authType = obj.authType; this.maxAccessCount = obj.maxAccessCount; this.accessCount = obj.accessCount; this.password = obj.password; - this.emails = obj.emails; 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; + this.emails = obj.emails; switch (this.type) { case SendType.Text: @@ -88,8 +92,16 @@ 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) { + model.emails = this.emails ? this.emails.split(",").map((e) => e.trim()) : []; + } else { + model.emails = []; + } switch (this.type) { case SendType.File: @@ -118,6 +130,7 @@ export class Send extends Domain { key: EncString.fromJSON(obj.key), name: EncString.fromJSON(obj.name), notes: EncString.fromJSON(obj.notes), + emails: 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..48119e372c1 --- /dev/null +++ b/libs/common/src/tools/send/models/request/send.request.spec.ts @@ -0,0 +1,82 @@ +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 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.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(); + }); + + 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.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.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.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); + }); + }); +}); diff --git a/libs/common/src/tools/send/models/request/send.request.ts b/libs/common/src/tools/send/models/request/send.request.ts index f7e3ff26d7f..902ca0a2c54 100644 --- a/libs/common/src/tools/send/models/request/send.request.ts +++ b/libs/common/src/tools/send/models/request/send.request.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; import { Send } from "../domain/send"; diff --git a/libs/common/src/tools/send/models/response/send-access.response.ts b/libs/common/src/tools/send/models/response/send-access.response.ts index 65a98e527a4..54107017fcf 100644 --- a/libs/common/src/tools/send/models/response/send-access.response.ts +++ b/libs/common/src/tools/send/models/response/send-access.response.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { BaseResponse } from "../../../../models/response/base.response"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; diff --git a/libs/common/src/tools/send/models/response/send.response.ts b/libs/common/src/tools/send/models/response/send.response.ts index 5c6bd4dc1a6..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,7 +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 { SendType } from "../../enums/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; @@ -23,12 +25,14 @@ export class SendResponse extends BaseResponse { emails: string; disable: boolean; hideEmail: boolean; + authType: AuthType; constructor(response: any) { super(response); this.id = this.getResponseProperty("Id"); this.accessId = this.getResponseProperty("AccessId"); this.type = this.getResponseProperty("Type"); + this.authType = this.getResponseProperty("AuthType"); this.name = this.getResponseProperty("Name"); this.notes = this.getResponseProperty("Notes"); this.key = this.getResponseProperty("Key"); @@ -41,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-access.view.ts b/libs/common/src/tools/send/models/view/send-access.view.ts index cb8b29796af..9d1b56d88ec 100644 --- a/libs/common/src/tools/send/models/view/send-access.view.ts +++ b/libs/common/src/tools/send/models/view/send-access.view.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { View } from "../../../../models/view/view"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendAccess } from "../domain/send-access"; import { SendFileView } from "./send-file.view"; diff --git a/libs/common/src/tools/send/models/view/send.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index 54657b12438..150a649671b 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -4,7 +4,8 @@ import { View } from "../../../../models/view/view"; import { Utils } from "../../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { DeepJsonify } from "../../../../types/deep-jsonify"; -import { SendType } from "../../enums/send-type"; +import { AuthType } from "../../types/auth-type"; +import { SendType } from "../../types/send-type"; import { Send } from "../domain/send"; import { SendFileView } from "./send-file.view"; @@ -29,6 +30,7 @@ export class SendView implements View { emails: string[] = []; disabled = false; hideEmail = false; + authType: AuthType = null; constructor(s?: Send) { if (!s) { @@ -38,6 +40,7 @@ export class SendView implements View { this.id = s.id; this.accessId = s.accessId; this.type = s.type; + this.authType = s.authType; this.maxAccessCount = s.maxAccessCount; this.accessCount = s.accessCount; this.revisionDate = s.revisionDate; @@ -46,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 f709553646f..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"; @@ -6,7 +8,6 @@ import { FileUploadService, } from "../../../platform/abstractions/file-upload/file-upload.service"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; -import { SendType } from "../enums/send-type"; import { SendData } from "../models/data/send.data"; import { Send } from "../models/domain/send"; import { SendAccessRequest } from "../models/request/send-access.request"; @@ -16,6 +17,7 @@ import { SendFileDownloadDataResponse } from "../models/response/send-file-downl import { SendFileUploadDataResponse } from "../models/response/send-file-upload-data.response"; import { SendResponse } from "../models/response/send.response"; import { SendAccessView } from "../models/view/send-access.view"; +import { SendType } from "../types/send-type"; import { SendApiService as SendApiServiceAbstraction } from "./send-api.service.abstraction"; import { InternalSendService } from "./send.service.abstraction"; @@ -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 397ae905e31..d29dc81389f 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -16,6 +16,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"; @@ -24,13 +25,14 @@ import { ContainerService } from "../../../platform/services/container.service"; import { SelfHostedEnvironment } from "../../../platform/services/default-environment.service"; import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; -import { SendType } from "../enums/send-type"; import { SendFileApi } from "../models/api/send-file.api"; import { SendTextApi } from "../models/api/send-text.api"; import { SendFileData } from "../models/data/send-file.data"; import { SendTextData } from "../models/data/send-text.data"; import { SendData } from "../models/data/send.data"; +import { SendTextView } from "../models/view/send-text.view"; import { SendView } from "../models/view/send.view"; +import { SendType } from "../types/send-type"; import { SEND_USER_DECRYPTED, SEND_USER_ENCRYPTED } from "./key-definitions"; import { SendStateProvider } from "./send-state.provider"; @@ -48,7 +50,7 @@ describe("SendService", () => { const keyGenerationService = mock(); const encryptService = mock(); const environmentService = mock(); - + const configService = mock(); let sendStateProvider: SendStateProvider; let sendService: SendService; @@ -94,6 +96,7 @@ describe("SendService", () => { keyGenerationService, sendStateProvider, encryptService, + configService, ); }); @@ -573,4 +576,188 @@ 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); + }); + + describe("email processing", () => { + it("should create a comma separated string when an email list is provided", async () => { + sendView.emails = ["test@example.com", "user@test.com"]; + const [send] = await sendService.encrypt(sendView, null, null); + expect(send.emails).toEqual("test@example.com,user@test.com"); + 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(); + }); + + 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(); + }); + + 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(); + }); + + it("should process multiple emails and return comma-separated string", async () => { + sendView.emails = ["test@example.com", "user@test.com"]; + const [send] = await sendService.encrypt(sendView, null, null); + expect(send.emails).toBe("test@example.com,user@test.com"); + }); + + it("should trim and lowercase emails", async () => { + sendView.emails = [" Test@Example.COM ", "USER@test.com"]; + const [send] = await sendService.encrypt(sendView, null, null); + expect(send.emails).toBe("test@example.com,user@test.com"); + }); + + it("should handle single email correctly", async () => { + sendView.emails = ["single@test.com"]; + const [send] = await sendService.encrypt(sendView, null, null); + expect(send.emails).toBe("single@test.com"); + }); + }); + + 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); + }); + + 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(); + }); + + 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.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.password).toBe("hashedPassword"); + }); + + 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.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 810dbc05a2f..93031b61c9f 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -7,16 +7,17 @@ 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 { 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"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; -import { SendType } from "../enums/send-type"; import { SendData } from "../models/data/send.data"; import { Send } from "../models/domain/send"; import { SendFile } from "../models/domain/send-file"; @@ -24,6 +25,7 @@ import { SendText } from "../models/domain/send-text"; import { SendWithIdRequest } from "../models/request/send-with-id.request"; import { SendView } from "../models/view/send.view"; import { SEND_KDF_ITERATIONS } from "../send-kdf"; +import { SendType } from "../types/send-type"; import { SendStateProvider } from "./send-state.provider.abstraction"; import { InternalSendService as InternalSendServiceAbstraction } from "./send.service.abstraction"; @@ -51,6 +53,7 @@ export class SendService implements InternalSendServiceAbstraction { private keyGenerationService: KeyGenerationService, private stateProvider: SendStateProvider, private encryptService: EncryptService, + private configService: ConfigService, ) {} async encrypt( @@ -80,19 +83,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) { + send.emails = model.emails + .map((e) => e.trim()) + .join(",") + .toLocaleLowerCase(); 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; + + 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 +114,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 +145,8 @@ export class SendService implements InternalSendServiceAbstraction { } } + send.authType = model.authType; + return [send, fileData]; } diff --git a/libs/common/src/tools/send/services/test-data/send-tests.data.ts b/libs/common/src/tools/send/services/test-data/send-tests.data.ts index 784d54bd71f..6c901be8fd4 100644 --- a/libs/common/src/tools/send/services/test-data/send-tests.data.ts +++ b/libs/common/src/tools/send/services/test-data/send-tests.data.ts @@ -1,12 +1,12 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { EncString } from "../../../../key-management/crypto/models/enc-string"; -import { SendType } from "../../enums/send-type"; import { SendTextApi } from "../../models/api/send-text.api"; import { SendTextData } from "../../models/data/send-text.data"; import { SendData } from "../../models/data/send.data"; import { Send } from "../../models/domain/send"; import { SendView } from "../../models/view/send.view"; +import { SendType } from "../../types/send-type"; export function testSendViewData(id: string, name: string) { const data = new SendView({} as any); @@ -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,7 @@ export function createSendData(value: Partial = {}) { expirationDate: "2024-09-04", deletionDate: "2024-09-04", password: "password", + emails: "", disabled: false, hideEmail: false, }; @@ -62,6 +64,7 @@ export function testSendData(id: string, name: string) { data.deletionDate = null; data.notes = "Notes!!"; data.key = null; + data.emails = ""; return data; } @@ -77,5 +80,6 @@ export function testSend(id: string, name: string) { data.deletionDate = null; data.notes = new EncString("Notes!!"); data.key = null; + data.emails = ""; return data; } diff --git a/libs/common/src/tools/send/types/auth-type.ts b/libs/common/src/tools/send/types/auth-type.ts new file mode 100644 index 00000000000..5d0243249fd --- /dev/null +++ b/libs/common/src/tools/send/types/auth-type.ts @@ -0,0 +1,12 @@ +/** An type of auth necessary to access a Send */ +export const AuthType = Object.freeze({ + /** Send requires email OTP verification */ + Email: 0, + /** Send requires a password */ + Password: 1, + /** Send requires no auth */ + None: 2, +} as const); + +/** An type of auth necessary to access a Send */ +export type AuthType = (typeof AuthType)[keyof typeof AuthType]; diff --git a/libs/common/src/tools/send/types/send-filter-type.ts b/libs/common/src/tools/send/types/send-filter-type.ts new file mode 100644 index 00000000000..dd26536076a --- /dev/null +++ b/libs/common/src/tools/send/types/send-filter-type.ts @@ -0,0 +1,7 @@ +export const SendFilterType = Object.freeze({ + All: "all", + Text: "text", + File: "file", +} as const); + +export type SendFilterType = (typeof SendFilterType)[keyof typeof SendFilterType]; diff --git a/libs/common/src/tools/send/enums/send-type.ts b/libs/common/src/tools/send/types/send-type.ts similarity index 100% rename from libs/common/src/tools/send/enums/send-type.ts rename to libs/common/src/tools/send/types/send-type.ts diff --git a/libs/common/src/vault/abstractions/cipher-archive.service.ts b/libs/common/src/vault/abstractions/cipher-archive.service.ts index 0969b7de1ac..3a5071dc51a 100644 --- a/libs/common/src/vault/abstractions/cipher-archive.service.ts +++ b/libs/common/src/vault/abstractions/cipher-archive.service.ts @@ -3,12 +3,14 @@ import { Observable } from "rxjs"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { CipherData } from "../models/data/cipher.data"; + export abstract class CipherArchiveService { abstract hasArchiveFlagEnabled$: Observable; abstract archivedCiphers$(userId: UserId): Observable; abstract userCanArchive$(userId: UserId): Observable; abstract userHasPremium$(userId: UserId): Observable; - abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; - abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; + abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; + abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; abstract showSubscriptionEndedMessaging$(userId: UserId): Observable; } diff --git a/libs/common/src/vault/abstractions/cipher-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/data/folder.data.ts b/libs/common/src/vault/models/data/folder.data.ts index c2eb585a6f4..5358cd713b3 100644 --- a/libs/common/src/vault/models/data/folder.data.ts +++ b/libs/common/src/vault/models/data/folder.data.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { FolderResponse } from "../response/folder.response"; @@ -10,12 +8,19 @@ export class FolderData { revisionDate: string; constructor(response: Partial) { - this.name = response?.name; - this.id = response?.id; - this.revisionDate = response?.revisionDate; + this.name = response.name ?? ""; + this.id = response.id ?? ""; + this.revisionDate = response.revisionDate ?? new Date().toISOString(); } - static fromJSON(obj: Jsonify) { - return Object.assign(new FolderData({}), obj); + static fromJSON(obj: Jsonify) { + if (obj == null) { + return null; + } + return new FolderData({ + id: obj.id, + name: obj.name, + revisionDate: obj.revisionDate, + }); } } diff --git a/libs/common/src/vault/models/domain/attachment.ts b/libs/common/src/vault/models/domain/attachment.ts index d9dfa128028..b9bcaad8cea 100644 --- a/libs/common/src/vault/models/domain/attachment.ts +++ b/libs/common/src/vault/models/domain/attachment.ts @@ -47,6 +47,12 @@ export class Attachment extends Domain { if (this.key != null) { view.key = await this.decryptAttachmentKey(decryptionKey); view.encryptedKey = this.key; // Keep the encrypted key for the view + + // When the attachment key couldn't be decrypted, mark a decryption error + // The file won't be able to be downloaded in these cases + if (!view.key) { + view.hasDecryptionError = true; + } } return view; diff --git a/libs/common/src/vault/models/domain/folder.spec.ts b/libs/common/src/vault/models/domain/folder.spec.ts index d9e9e265d91..fd1455dbb66 100644 --- a/libs/common/src/vault/models/domain/folder.spec.ts +++ b/libs/common/src/vault/models/domain/folder.spec.ts @@ -8,7 +8,7 @@ import { mockFromJson, } from "../../../../spec"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; -import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string"; +import { EncString } from "../../../key-management/crypto/models/enc-string"; import { FolderData } from "../../models/data/folder.data"; import { Folder } from "../../models/domain/folder"; @@ -49,6 +49,30 @@ describe("Folder", () => { }); }); + describe("constructor", () => { + it("initializes properties from FolderData", () => { + const revisionDate = new Date("2022-08-04T01:06:40.441Z"); + const folder = new Folder({ + id: "id", + name: "name", + revisionDate: revisionDate.toISOString(), + }); + + expect(folder.id).toBe("id"); + expect(folder.revisionDate).toEqual(revisionDate); + expect(folder.name).toBeInstanceOf(EncString); + expect((folder.name as EncString).encryptedString).toBe("name"); + }); + + it("initializes empty properties when no FolderData is provided", () => { + const folder = new Folder(); + + expect(folder.id).toBe(""); + expect(folder.name).toBeInstanceOf(EncString); + expect(folder.revisionDate).toBeInstanceOf(Date); + }); + }); + describe("fromJSON", () => { jest.mock("../../../key-management/crypto/models/enc-string"); jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson); @@ -57,17 +81,13 @@ describe("Folder", () => { const revisionDate = new Date("2022-08-04T01:06:40.441Z"); const actual = Folder.fromJSON({ revisionDate: revisionDate.toISOString(), - name: "name" as EncryptedString, + name: "name", id: "id", }); - const expected = { - revisionDate: revisionDate, - name: "name_fromJSON", - id: "id", - }; - - expect(actual).toMatchObject(expected); + expect(actual?.id).toBe("id"); + expect(actual?.revisionDate).toEqual(revisionDate); + expect(actual?.name).toBe("name_fromJSON"); }); }); @@ -89,9 +109,7 @@ describe("Folder", () => { const view = await folder.decryptWithKey(key, encryptService); - expect(view).toEqual({ - name: "encName", - }); + expect(view.name).toBe("encName"); }); it("assigns the folder id and revision date", async () => { diff --git a/libs/common/src/vault/models/domain/folder.ts b/libs/common/src/vault/models/domain/folder.ts index c336095f15d..5f7f17ee751 100644 --- a/libs/common/src/vault/models/domain/folder.ts +++ b/libs/common/src/vault/models/domain/folder.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; @@ -9,16 +7,10 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr import { FolderData } from "../data/folder.data"; import { FolderView } from "../view/folder.view"; -export class Test extends Domain { - id: string; - name: EncString; - revisionDate: Date; -} - export class Folder extends Domain { - id: string; - name: EncString; - revisionDate: Date; + id: string = ""; + name: EncString = new EncString(""); + revisionDate: Date = new Date(); constructor(obj?: FolderData) { super(); @@ -26,17 +18,9 @@ export class Folder extends Domain { return; } - this.buildDomainModel( - this, - obj, - { - id: null, - name: null, - }, - ["id"], - ); - - this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null; + this.id = obj.id; + this.name = new EncString(obj.name); + this.revisionDate = new Date(obj.revisionDate); } decrypt(key: SymmetricCryptoKey): Promise { @@ -62,7 +46,14 @@ export class Folder extends Domain { } static fromJSON(obj: Jsonify) { - const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate); - return Object.assign(new Folder(), obj, { name: EncString.fromJSON(obj.name), revisionDate }); + if (obj == null) { + return null; + } + + const folder = new Folder(); + folder.id = obj.id; + folder.name = EncString.fromJSON(obj.name); + folder.revisionDate = new Date(obj.revisionDate); + return folder; } } diff --git a/libs/common/src/vault/models/request/folder-with-id.request.ts b/libs/common/src/vault/models/request/folder-with-id.request.ts index 9d8078c12c1..8af890048ba 100644 --- a/libs/common/src/vault/models/request/folder-with-id.request.ts +++ b/libs/common/src/vault/models/request/folder-with-id.request.ts @@ -7,6 +7,6 @@ export class FolderWithIdRequest extends FolderRequest { constructor(folder: Folder) { super(folder); - this.id = folder.id; + this.id = folder.id ?? ""; } } diff --git a/libs/common/src/vault/models/view/attachment.view.ts b/libs/common/src/vault/models/view/attachment.view.ts index ef4a9ed8b27..6eaa943fba0 100644 --- a/libs/common/src/vault/models/view/attachment.view.ts +++ b/libs/common/src/vault/models/view/attachment.view.ts @@ -2,7 +2,7 @@ import { Jsonify } from "type-fest"; import { AttachmentView as SdkAttachmentView } from "@bitwarden/sdk-internal"; -import { EncString } from "../../../key-management/crypto/models/enc-string"; +import { DECRYPT_ERROR, EncString } from "../../../key-management/crypto/models/enc-string"; import { View } from "../../../models/view/view"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { Attachment } from "../domain/attachment"; @@ -18,6 +18,7 @@ export class AttachmentView implements View { * The SDK returns an encrypted key for the attachment. */ encryptedKey: EncString | undefined; + private _hasDecryptionError?: boolean; constructor(a?: Attachment) { if (!a) { @@ -41,6 +42,14 @@ export class AttachmentView implements View { return 0; } + get hasDecryptionError(): boolean { + return this._hasDecryptionError || this.fileName === DECRYPT_ERROR; + } + + set hasDecryptionError(value: boolean) { + this._hasDecryptionError = value; + } + static fromJSON(obj: Partial>): AttachmentView { const key = obj.key == null ? null : SymmetricCryptoKey.fromJSON(obj.key); @@ -76,7 +85,10 @@ export class AttachmentView implements View { /** * Converts the SDK AttachmentView to a AttachmentView. */ - static fromSdkAttachmentView(obj: SdkAttachmentView): AttachmentView | undefined { + static fromSdkAttachmentView( + obj: SdkAttachmentView, + failure = false, + ): AttachmentView | undefined { if (!obj) { return undefined; } @@ -90,6 +102,7 @@ export class AttachmentView implements View { // TODO: PM-23005 - Temporary field, should be removed when encrypted migration is complete view.key = obj.decryptedKey ? SymmetricCryptoKey.fromString(obj.decryptedKey) : undefined; view.encryptedKey = obj.key ? new EncString(obj.key) : undefined; + view._hasDecryptionError = failure; return 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 db360f7f991..1e0cce8d72e 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"; @@ -109,6 +114,10 @@ export class CipherView implements View, InitializerMetadata { return this.item?.subTitle; } + get canBeArchived(): boolean { + return !this.isDeleted && !this.isArchived; + } + get hasPasswordHistory(): boolean { return this.passwordHistory && this.passwordHistory.length > 0; } @@ -271,6 +280,17 @@ export class CipherView implements View, InitializerMetadata { return undefined; } + const attachments = obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)!) ?? []; + + if (obj.attachmentDecryptionFailures?.length) { + obj.attachmentDecryptionFailures.forEach((attachment) => { + const attachmentView = AttachmentView.fromSdkAttachmentView(attachment, true); + if (attachmentView) { + attachments.push(attachmentView); + } + }); + } + const cipherView = new CipherView(); cipherView.id = uuidAsString(obj.id); cipherView.organizationId = uuidAsString(obj.organizationId); @@ -286,8 +306,7 @@ export class CipherView implements View, InitializerMetadata { cipherView.edit = obj.edit; cipherView.viewPassword = obj.viewPassword; cipherView.localData = fromSdkLocalData(obj.localData); - cipherView.attachments = - obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)!) ?? []; + cipherView.attachments = attachments; cipherView.fields = obj.fields?.map((f) => FieldView.fromSdkFieldView(f)!) ?? []; cipherView.passwordHistory = obj.passwordHistory?.map((ph) => PasswordHistoryView.fromSdkPasswordHistoryView(ph)!) ?? []; @@ -328,6 +347,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/models/view/folder.view.ts b/libs/common/src/vault/models/view/folder.view.ts index bc908e98eb8..6052ae9df37 100644 --- a/libs/common/src/vault/models/view/folder.view.ts +++ b/libs/common/src/vault/models/view/folder.view.ts @@ -1,19 +1,17 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { View } from "../../../models/view/view"; -import { DecryptedObject } from "../../../platform/models/domain/domain-base"; import { Folder } from "../domain/folder"; import { ITreeNodeObject } from "../domain/tree-node"; export class FolderView implements View, ITreeNodeObject { - id: string = null; - name: string = null; - revisionDate: Date = null; + id: string = ""; + name: string = ""; + revisionDate: Date; - constructor(f?: Folder | DecryptedObject) { + constructor(f?: Folder) { if (!f) { + this.revisionDate = new Date(); return; } @@ -22,7 +20,12 @@ export class FolderView implements View, ITreeNodeObject { } static fromJSON(obj: Jsonify) { - const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate); - return Object.assign(new FolderView(), obj, { revisionDate }); + const folderView = new FolderView(); + folderView.id = obj.id ?? ""; + folderView.name = obj.name ?? ""; + if (obj.revisionDate != null) { + folderView.revisionDate = new Date(obj.revisionDate); + } + return folderView; } } diff --git a/libs/common/src/vault/services/cipher-authorization.service.spec.ts b/libs/common/src/vault/services/cipher-authorization.service.spec.ts index 78fe6f18913..0490fba3d90 100644 --- a/libs/common/src/vault/services/cipher-authorization.service.spec.ts +++ b/libs/common/src/vault/services/cipher-authorization.service.spec.ts @@ -3,8 +3,9 @@ import { Observable, firstValueFrom, of } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { UserId } from "@bitwarden/common/types/guid"; @@ -204,6 +205,70 @@ describe("CipherAuthorizationService", () => { }); }); + describe("canEditCipher$", () => { + it("should return true if isAdminConsoleAction is true and cipher is unassigned", (done) => { + const cipher = createMockCipher("org1", []) as CipherView; + const organization = createMockOrganization({ canEditUnassignedCiphers: true }); + mockOrganizationService.organizations$.mockReturnValue( + of([organization]) as Observable, + ); + + cipherAuthorizationService.canEditCipher$(cipher, true).subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it("should return true if isAdminConsoleAction is true and user can edit all ciphers in the org", (done) => { + const cipher = createMockCipher("org1", ["col1"]) as CipherView; + const organization = createMockOrganization({ canEditAllCiphers: true }); + mockOrganizationService.organizations$.mockReturnValue( + of([organization]) as Observable, + ); + + cipherAuthorizationService.canEditCipher$(cipher, true).subscribe((result) => { + expect(result).toBe(true); + expect(mockOrganizationService.organizations$).toHaveBeenCalledWith(mockUserId); + done(); + }); + }); + + it("should return false if isAdminConsoleAction is true but user does not have permission to edit unassigned ciphers", (done) => { + const cipher = createMockCipher("org1", []) as CipherView; + const organization = createMockOrganization({ canEditUnassignedCiphers: false }); + mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[])); + + cipherAuthorizationService.canEditCipher$(cipher, true).subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + + it("should return true if cipher.edit is true and is not an admin action", (done) => { + const cipher = createMockCipher("org1", [], true) as CipherView; + const organization = createMockOrganization(); + mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[])); + + cipherAuthorizationService.canEditCipher$(cipher, false).subscribe((result) => { + expect(result).toBe(true); + expect(mockCollectionService.decryptedCollections$).not.toHaveBeenCalled(); + done(); + }); + }); + + it("should return false if cipher.edit is false and is not an admin action", (done) => { + const cipher = createMockCipher("org1", [], false) as CipherView; + const organization = createMockOrganization(); + mockOrganizationService.organizations$.mockReturnValue(of([organization] as Organization[])); + + cipherAuthorizationService.canEditCipher$(cipher, false).subscribe((result) => { + expect(result).toBe(false); + expect(mockCollectionService.decryptedCollections$).not.toHaveBeenCalled(); + done(); + }); + }); + }); + describe("canCloneCipher$", () => { it("should return true if cipher has no organizationId", async () => { const cipher = createMockCipher(null, []) as CipherView; diff --git a/libs/common/src/vault/services/cipher-authorization.service.ts b/libs/common/src/vault/services/cipher-authorization.service.ts index 7f7e2c3f531..eb89819a05e 100644 --- a/libs/common/src/vault/services/cipher-authorization.service.ts +++ b/libs/common/src/vault/services/cipher-authorization.service.ts @@ -53,6 +53,19 @@ export abstract class CipherAuthorizationService { cipher: CipherLike, isAdminConsoleAction?: boolean, ) => Observable; + + /** + * Determines if the user can edit the specified cipher. + * + * @param {CipherLike} cipher - The cipher object to evaluate for edit permissions. + * @param {boolean} isAdminConsoleAction - Optional. A flag indicating if the action is being performed from the admin console. + * + * @returns {Observable} - An observable that emits a boolean value indicating if the user can edit the cipher. + */ + abstract canEditCipher$: ( + cipher: CipherLike, + isAdminConsoleAction?: boolean, + ) => Observable; } /** @@ -118,6 +131,29 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer ); } + /** + * + * {@link CipherAuthorizationService.canEditCipher$} + */ + canEditCipher$(cipher: CipherLike, isAdminConsoleAction?: boolean): Observable { + return this.organization$(cipher).pipe( + map((organization) => { + if (isAdminConsoleAction) { + // If the user is an admin, they can edit an unassigned cipher + if (!cipher.collectionIds || cipher.collectionIds.length === 0) { + return organization?.canEditUnassignedCiphers === true; + } + + if (organization?.canEditAllCiphers) { + return true; + } + } + + return !!cipher.edit; + }), + ); + } + /** * {@link CipherAuthorizationService.canCloneCipher$} */ 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..9e858a6a9d7 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).toHaveBeenCalledWith({ ids: testCipherIds, organizationId: orgId }); + }); + + 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..06c6628f158 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,43 @@ 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 { + // Clear the cache before creating the cipher. The SDK internally updates the encrypted storage + // but the timing of the storage emitting the new values differs across platforms. Clearing the cache after + // `createWithServer` can cause race conditions where the cache is cleared after the + // encrypted storage has already been updated and thus downstream consumers not getting updated data. + await this.clearCache(userId); + + const resultCipherView = await this.cipherSdkService.createWithServer( + cipherView, + userId, + orgAdmin, + ); + return resultCipherView; + } + + private async createWithServer_legacy( { cipher, encryptedFor }: EncryptionContext, orgAdmin?: boolean, ): Promise { @@ -929,6 +975,45 @@ 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 { + // Clear the cache before updating the cipher. The SDK internally updates the encrypted storage + // but the timing of the storage emitting the new values differs across platforms. Clearing the cache after + // `updateWithServer` can cause race conditions where the cache is cleared after the + // encrypted storage has already been updated and thus downstream consumers not getting updated data. + await this.clearCache(userId); + + const resultCipherView = await this.cipherSdkService.updateWithServer( + cipher, + userId, + originalCipherView, + orgAdmin, + ); + return resultCipherView; + } + + async updateWithServer_legacy( { cipher, encryptedFor }: EncryptionContext, orgAdmin?: boolean, ): Promise { @@ -1106,34 +1191,28 @@ export class CipherService implements CipherServiceAbstraction { userId: UserId, admin = false, ): Promise { - const encKey = await this.getKeyForCipherKeyDecryption(cipher, userId); - const cipherKeyEncryptionEnabled = await this.getCipherKeyEncryptionEnabled(); + // The organization's symmetric key or the user's user key + const vaultKey = await this.getKeyForCipherKeyDecryption(cipher, userId); - const cipherEncKey = - cipherKeyEncryptionEnabled && cipher.key != null - ? ((await this.encryptService.unwrapSymmetricKey(cipher.key, encKey)) as UserKey) - : encKey; + const cipherKeyOrVaultKey = + cipher.key != null + ? ((await this.encryptService.unwrapSymmetricKey(cipher.key, vaultKey)) as UserKey) + : vaultKey; - //if cipher key encryption is disabled but the item has an individual key, - //then we rollback to using the user key as the main key of encryption of the item - //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); - } + const encFileName = await this.encryptService.encryptString(filename, cipherKeyOrVaultKey); - const encFileName = await this.encryptService.encryptString(filename, cipherEncKey); - - const dataEncKey = await this.keyService.makeDataEncKey(cipherEncKey); - const encData = await this.encryptService.encryptFileData(new Uint8Array(data), dataEncKey[0]); + const attachmentKey = await this.keyService.makeDataEncKey(cipherKeyOrVaultKey); + const encData = await this.encryptService.encryptFileData( + new Uint8Array(data), + attachmentKey[0], + ); const response = await this.cipherFileUploadService.upload( cipher, encFileName, encData, admin, - dataEncKey, + attachmentKey, ); const cData = new CipherData(response, cipher.collectionIds); @@ -1318,7 +1397,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,8 +1414,20 @@ export class CipherService implements CipherServiceAbstraction { await this.delete(id, userId); } - async deleteManyWithServer(ids: string[], userId: UserId, asAdmin = false): Promise { - const request = new CipherBulkDeleteRequest(ids); + 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, orgId); if (asAdmin) { await this.apiService.deleteManyCiphersAdmin(request); } else { @@ -1468,7 +1566,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 +1594,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 +1611,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 +1667,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 +1690,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/common/src/vault/services/default-cipher-archive.service.spec.ts b/libs/common/src/vault/services/default-cipher-archive.service.spec.ts index 2078d1f29ea..60589ed58db 100644 --- a/libs/common/src/vault/services/default-cipher-archive.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-archive.service.spec.ts @@ -1,3 +1,7 @@ +/** + * include structuredClone in test environment. + * @jest-environment ../../../../shared/test.environment.ts + */ import { mock } from "jest-mock-extended"; import { of, firstValueFrom, BehaviorSubject } from "rxjs"; diff --git a/libs/common/src/vault/services/default-cipher-archive.service.ts b/libs/common/src/vault/services/default-cipher-archive.service.ts index 12d67ab07f9..7382317fd9f 100644 --- a/libs/common/src/vault/services/default-cipher-archive.service.ts +++ b/libs/common/src/vault/services/default-cipher-archive.service.ts @@ -18,6 +18,7 @@ import { } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { CipherArchiveService } from "../abstractions/cipher-archive.service"; +import { CipherData } from "../models/data/cipher.data"; export class DefaultCipherArchiveService implements CipherArchiveService { constructor( @@ -84,45 +85,31 @@ export class DefaultCipherArchiveService implements CipherArchiveService { ); } - async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise { + async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise { const request = new CipherBulkArchiveRequest(Array.isArray(ids) ? ids : [ids]); const r = await this.apiService.send("PUT", "/ciphers/archive", request, true, true); const response = new ListResponse(r, CipherResponse); const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId)); + const responseDataArray = response.data.map( + (cipher) => new CipherData(cipher, currentCiphers[cipher.id as CipherId]?.collectionIds), + ); - for (const cipher of response.data) { - const localCipher = currentCiphers[cipher.id as CipherId]; - - if (localCipher == null) { - continue; - } - - localCipher.archivedDate = cipher.archivedDate; - localCipher.revisionDate = cipher.revisionDate; - } - - await this.cipherService.upsert(Object.values(currentCiphers), userId); + await this.cipherService.upsert(responseDataArray, userId); + return response.data[0]; } - async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise { + async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise { const request = new CipherBulkUnarchiveRequest(Array.isArray(ids) ? ids : [ids]); const r = await this.apiService.send("PUT", "/ciphers/unarchive", request, true, true); const response = new ListResponse(r, CipherResponse); const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId)); + const responseDataArray = response.data.map( + (cipher) => new CipherData(cipher, currentCiphers[cipher.id as CipherId]?.collectionIds), + ); - for (const cipher of response.data) { - const localCipher = currentCiphers[cipher.id as CipherId]; - - if (localCipher == null) { - continue; - } - - localCipher.archivedDate = cipher.archivedDate; - localCipher.revisionDate = cipher.revisionDate; - } - - await this.cipherService.upsert(Object.values(currentCiphers), userId); + await this.cipherService.upsert(responseDataArray, userId); + return response.data[0]; } } diff --git a/libs/common/src/vault/services/folder/folder-api.service.ts b/libs/common/src/vault/services/folder/folder-api.service.ts index fe9c3218a84..d5bd7fe9847 100644 --- a/libs/common/src/vault/services/folder/folder-api.service.ts +++ b/libs/common/src/vault/services/folder/folder-api.service.ts @@ -17,11 +17,11 @@ export class FolderApiService implements FolderApiServiceAbstraction { const request = new FolderRequest(folder); let response: FolderResponse; - if (folder.id == null) { + if (folder.id) { + response = await this.putFolder(folder.id, request); + } else { response = await this.postFolder(request); folder.id = response.id; - } else { - response = await this.putFolder(folder.id, request); } const data = new FolderData(response); diff --git a/libs/common/src/vault/services/folder/folder.service.spec.ts b/libs/common/src/vault/services/folder/folder.service.spec.ts index a520fd4852d..412e67e77d7 100644 --- a/libs/common/src/vault/services/folder/folder.service.spec.ts +++ b/libs/common/src/vault/services/folder/folder.service.spec.ts @@ -122,6 +122,7 @@ describe("Folder Service", () => { encryptedString: "ENC", encryptionType: 0, }, + revisionDate: expect.any(Date), }); }); @@ -132,7 +133,7 @@ describe("Folder Service", () => { expect(result).toEqual({ id: "1", name: makeEncString("ENC_STRING_" + 1), - revisionDate: null, + revisionDate: expect.any(Date), }); }); @@ -150,12 +151,12 @@ describe("Folder Service", () => { { id: "1", name: makeEncString("ENC_STRING_" + 1), - revisionDate: null, + revisionDate: expect.any(Date), }, { id: "2", name: makeEncString("ENC_STRING_" + 2), - revisionDate: null, + revisionDate: expect.any(Date), }, ]); }); @@ -167,7 +168,7 @@ describe("Folder Service", () => { { id: "4", name: makeEncString("ENC_STRING_" + 4), - revisionDate: null, + revisionDate: expect.any(Date), }, ]); }); @@ -203,7 +204,7 @@ describe("Folder Service", () => { const folderViews = await firstValueFrom(folderService.folderViews$(mockUserId)); expect(folderViews.length).toBe(1); - expect(folderViews[0].id).toBeNull(); // Should be the "No Folder" folder + expect(folderViews[0].id).toEqual(""); // Should be the "No Folder" folder }); describe("getRotatedData", () => { diff --git a/libs/common/tsconfig.spec.json b/libs/common/tsconfig.spec.json index d52d889aa78..5d4d5043ab5 100644 --- a/libs/common/tsconfig.spec.json +++ b/libs/common/tsconfig.spec.json @@ -2,7 +2,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "isolatedModules": true, - "emitDecoratorMetadata": false + "emitDecoratorMetadata": false, + "module": "nodenext", + "moduleResolution": "nodenext" }, "files": ["./test.setup.ts"] } 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 553da0c541b..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,14 +3,14 @@ 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"; +import { LandingContentMaxWidthType } from "../landing-layout"; import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service"; -import { AnonLayoutComponent, AnonLayoutMaxWidth } from "./anon-layout.component"; - +import { AnonLayoutComponent } from "./anon-layout.component"; export interface AnonLayoutWrapperData { /** * The optional title of the page. @@ -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). */ @@ -35,7 +35,7 @@ export interface AnonLayoutWrapperData { /** * Optional flag to set the max-width of the page. Defaults to 'md' if not provided. */ - maxWidth?: AnonLayoutMaxWidth; + maxWidth?: LandingContentMaxWidthType; /** * Hide the card that wraps the default content. Defaults to false. */ @@ -57,9 +57,9 @@ 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?: AnonLayoutMaxWidth | null; + protected maxWidth?: LandingContentMaxWidthType | null; protected hideCardWrapper?: boolean | null; protected hideBackgroundIllustration?: boolean | null; diff --git a/libs/components/src/anon-layout/anon-layout.component.html b/libs/components/src/anon-layout/anon-layout.component.html index 6bd72a25382..932ff10832c 100644 --- a/libs/components/src/anon-layout/anon-layout.component.html +++ b/libs/components/src/anon-layout/anon-layout.component.html @@ -1,76 +1,26 @@ -
-
- @if (!hideLogo()) { - - - - } -
- -
-
+ + + + -
- @let iconInput = icon(); - - - -
- -
- - @if (title()) { - -

- {{ title() }} -

- -

- {{ title() }} -

- } - - @if (subtitle()) { -
{{ subtitle() }}
- } -
- -
+ + @if (hideCardWrapper()) {
} @else { - + - + } - -
+
+ +
+ @if (!hideFooter()) { -
+ @if (showReadonlyHostname()) {
{{ "accessing" | i18n }} {{ hostname }}
} @else { @@ -81,22 +31,9 @@
© {{ year }} Bitwarden Inc.
{{ version }}
} -
+ } - - @if (!hideBackgroundIllustration()) { -
- -
-
- -
- } -
+ diff --git a/libs/components/src/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts index e6572a0c3c1..953a5e769cf 100644 --- a/libs/components/src/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -11,35 +11,29 @@ import { import { RouterModule } from "@angular/router"; import { firstValueFrom } from "rxjs"; -import { - BackgroundLeftIllustration, - BackgroundRightIllustration, - 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 { BaseCardComponent } from "../card"; -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"; -export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl"; - // 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: "auth-anon-layout", templateUrl: "./anon-layout.component.html", imports: [ - IconModule, + SvgModule, CommonModule, TypographyModule, SharedModule, RouterModule, - BaseCardComponent, + LandingLayoutModule, ], }) export class AnonLayoutComponent implements OnInit, OnChanges { @@ -49,12 +43,9 @@ export class AnonLayoutComponent implements OnInit, OnChanges { return ["tw-h-full"]; } - readonly leftIllustration = BackgroundLeftIllustration; - readonly rightIllustration = BackgroundRightIllustration; - 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); @@ -66,7 +57,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges { * * @default 'md' */ - readonly maxWidth = model("md"); + readonly maxWidth = model("md"); protected logo = BitwardenLogo; protected year: string; @@ -76,24 +67,6 @@ export class AnonLayoutComponent implements OnInit, OnChanges { protected hideYearAndVersion = false; - get maxWidthClass(): string { - const maxWidth = this.maxWidth(); - switch (maxWidth) { - case "md": - return "tw-max-w-md"; - case "lg": - return "tw-max-w-lg"; - case "xl": - return "tw-max-w-xl"; - case "2xl": - return "tw-max-w-2xl"; - case "3xl": - return "tw-max-w-3xl"; - case "4xl": - return "tw-max-w-4xl"; - } - } - constructor( private environmentService: EnvironmentService, private platformUtilsService: PlatformUtilsService, 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/berry/berry.component.html b/libs/components/src/berry/berry.component.html new file mode 100644 index 00000000000..2a05f534843 --- /dev/null +++ b/libs/components/src/berry/berry.component.html @@ -0,0 +1,3 @@ +@if (type() === "status" || content()) { + {{ content() }} +} diff --git a/libs/components/src/berry/berry.component.ts b/libs/components/src/berry/berry.component.ts new file mode 100644 index 00000000000..8e58b888f39 --- /dev/null +++ b/libs/components/src/berry/berry.component.ts @@ -0,0 +1,80 @@ +import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core"; + +export type BerryVariant = + | "primary" + | "subtle" + | "success" + | "warning" + | "danger" + | "accentPrimary" + | "contrast"; + +/** + * The berry component is a compact visual indicator used to display short, + * supplemental status information about another element, + * like a navigation item, button, or icon button. + * They draw users’ attention to status changes or new notifications. + * + * > `NOTE:` The maximum displayed value is 999. If the value is over 999, a “+” character is appended to indicate more. + */ +@Component({ + selector: "bit-berry", + templateUrl: "berry.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BerryComponent { + protected readonly variant = input("primary"); + protected readonly value = input(); + protected readonly type = input<"status" | "count">("count"); + + protected readonly content = computed(() => { + const value = this.value(); + const type = this.type(); + + if (type === "status" || !value || value < 0) { + return undefined; + } + return value > 999 ? "999+" : `${value}`; + }); + + protected readonly textColor = computed(() => { + return this.variant() === "contrast" ? "tw-text-fg-dark" : "tw-text-fg-white"; + }); + + protected readonly padding = computed(() => { + return (this.value()?.toString().length ?? 0) > 2 ? "tw-px-1.5 tw-py-0.5" : ""; + }); + + protected readonly containerClasses = computed(() => { + const baseClasses = [ + "tw-inline-flex", + "tw-items-center", + "tw-justify-center", + "tw-align-middle", + "tw-text-xxs", + "tw-rounded-full", + ]; + + const typeClasses = { + status: ["tw-h-2", "tw-w-2"], + count: ["tw-h-4", "tw-min-w-4", this.padding()], + }; + + const variantClass = { + primary: "tw-bg-bg-brand", + subtle: "tw-bg-bg-contrast", + success: "tw-bg-bg-success", + warning: "tw-bg-bg-warning", + danger: "tw-bg-bg-danger", + accentPrimary: "tw-bg-fg-accent-primary-strong", + contrast: "tw-bg-bg-white", + }; + + return [ + ...baseClasses, + ...typeClasses[this.type()], + variantClass[this.variant()], + this.textColor(), + ].join(" "); + }); +} diff --git a/libs/components/src/berry/berry.mdx b/libs/components/src/berry/berry.mdx new file mode 100644 index 00000000000..b79ed35cac8 --- /dev/null +++ b/libs/components/src/berry/berry.mdx @@ -0,0 +1,48 @@ +import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs/blocks"; + +import * as stories from "./berry.stories"; + + + +```ts +import { BerryComponent } from "@bitwarden/components"; +``` + + +<Description /> + +<Primary /> +<Controls /> + +## Usage + +### Status + +- Use a status berry to indicate a new notification of a status change that is not related to a + specific count. + +<Canvas of={stories.statusType} /> + +### Count + +- Use a count berry with text to indicate item count information for multiple new notifications. + +<Canvas of={stories.countType} /> + +### All Variants + +<Canvas of={stories.AllVariants} /> + +## Count Behavior + +- Counts of **1-99**: Display in a compact circular shape +- Counts of **100-999**: Display in a pill shape with padding +- Counts **over 999**: Display as "999+" to prevent overflow + +## Accessibility + +- Use berries as **supplemental visual indicators** alongside descriptive text +- Ensure sufficient color contrast with surrounding elements +- For screen readers, provide appropriate labels on parent elements that describe the berry's + meaning +- Berries are decorative; important information should not rely solely on the berry color diff --git a/libs/components/src/berry/berry.stories.ts b/libs/components/src/berry/berry.stories.ts new file mode 100644 index 00000000000..0b71e7259d8 --- /dev/null +++ b/libs/components/src/berry/berry.stories.ts @@ -0,0 +1,167 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { BerryComponent } from "./berry.component"; + +export default { + title: "Component Library/Berry", + component: BerryComponent, + decorators: [ + moduleMetadata({ + imports: [BerryComponent], + }), + ], + args: { + type: "count", + variant: "primary", + value: 5, + }, + argTypes: { + type: { + control: "select", + options: ["status", "count"], + description: "The type of the berry, which determines its size and content", + table: { + category: "Inputs", + type: { summary: '"status" | "count"' }, + defaultValue: { summary: '"count"' }, + }, + }, + variant: { + control: "select", + options: ["primary", "subtle", "success", "warning", "danger", "accentPrimary", "contrast"], + description: "The visual style variant of the berry", + table: { + category: "Inputs", + type: { summary: "BerryVariant" }, + defaultValue: { summary: "primary" }, + }, + }, + value: { + control: "number", + description: + "Optional value to display for berries with type 'count'. Maximum displayed is 999, values above show '999+'. If undefined, a small small berry is shown. If 0 or negative, the berry is hidden.", + table: { + category: "Inputs", + type: { summary: "number | undefined" }, + defaultValue: { summary: "undefined" }, + }, + }, + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/branch/rKUVGKb7Kw3d6YGoQl6Ho7/Tailwind-Component-Library?node-id=38367-199458&p=f&m=dev", + }, + }, +} as Meta<BerryComponent>; + +type Story = StoryObj<BerryComponent>; + +export const Primary: Story = { + render: (args) => ({ + props: args, + template: `<bit-berry [type]="type" [variant]="variant" [value]="value"></bit-berry>`, + }), +}; + +export const statusType: Story = { + render: (args) => ({ + props: args, + template: ` + <div class="tw-flex tw-items-center tw-gap-4"> + <bit-berry [type]="'status'" variant="primary"></bit-berry> + <bit-berry [type]="'status'" variant="subtle"></bit-berry> + <bit-berry [type]="'status'" variant="success"></bit-berry> + <bit-berry [type]="'status'" variant="warning"></bit-berry> + <bit-berry [type]="'status'" variant="danger"></bit-berry> + <bit-berry [type]="'status'" variant="accentPrimary"></bit-berry> + <bit-berry [type]="'status'" variant="contrast"></bit-berry> + </div> + `, + }), +}; + +export const countType: Story = { + render: (args) => ({ + props: args, + template: ` + <div class="tw-flex tw-items-center tw-gap-4"> + <bit-berry [value]="5"></bit-berry> + <bit-berry [value]="50"></bit-berry> + <bit-berry [value]="500"></bit-berry> + <bit-berry [value]="5000"></bit-berry> + </div> + `, + }), +}; + +export const AllVariants: Story = { + render: () => ({ + template: ` + <div class="tw-flex tw-flex-col tw-gap-4"> + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Primary:</span> + <bit-berry type="status" variant="primary"></bit-berry> + <bit-berry variant="primary" [value]="5"></bit-berry> + <bit-berry variant="primary" [value]="50"></bit-berry> + <bit-berry variant="primary" [value]="500"></bit-berry> + <bit-berry variant="primary" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Subtle:</span> + <bit-berry type="status"variant="subtle"></bit-berry> + <bit-berry variant="subtle" [value]="5"></bit-berry> + <bit-berry variant="subtle" [value]="50"></bit-berry> + <bit-berry variant="subtle" [value]="500"></bit-berry> + <bit-berry variant="subtle" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Success:</span> + <bit-berry type="status" variant="success"></bit-berry> + <bit-berry variant="success" [value]="5"></bit-berry> + <bit-berry variant="success" [value]="50"></bit-berry> + <bit-berry variant="success" [value]="500"></bit-berry> + <bit-berry variant="success" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Warning:</span> + <bit-berry type="status" variant="warning"></bit-berry> + <bit-berry variant="warning" [value]="5"></bit-berry> + <bit-berry variant="warning" [value]="50"></bit-berry> + <bit-berry variant="warning" [value]="500"></bit-berry> + <bit-berry variant="warning" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Danger:</span> + <bit-berry type="status" variant="danger"></bit-berry> + <bit-berry variant="danger" [value]="5"></bit-berry> + <bit-berry variant="danger" [value]="50"></bit-berry> + <bit-berry variant="danger" [value]="500"></bit-berry> + <bit-berry variant="danger" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Accent primary:</span> + <bit-berry type="status" variant="accentPrimary"></bit-berry> + <bit-berry variant="accentPrimary" [value]="5"></bit-berry> + <bit-berry variant="accentPrimary" [value]="50"></bit-berry> + <bit-berry variant="accentPrimary" [value]="500"></bit-berry> + <bit-berry variant="accentPrimary" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4 tw-bg-bg-dark"> + <span class="tw-w-20 tw-text-fg-white">Contrast:</span> + <bit-berry type="status" variant="contrast"></bit-berry> + <bit-berry variant="contrast" [value]="5"></bit-berry> + <bit-berry variant="contrast" [value]="50"></bit-berry> + <bit-berry variant="contrast" [value]="500"></bit-berry> + <bit-berry variant="contrast" [value]="5000"></bit-berry> + </div> + </div> + `, + }), +}; diff --git a/libs/components/src/berry/index.ts b/libs/components/src/berry/index.ts new file mode 100644 index 00000000000..8f85908653e --- /dev/null +++ b/libs/components/src/berry/index.ts @@ -0,0 +1 @@ +export * from "./berry.component"; diff --git a/libs/components/src/breadcrumbs/breadcrumbs.component.html b/libs/components/src/breadcrumbs/breadcrumbs.component.html index ee5ad79c739..d666c641572 100644 --- a/libs/components/src/breadcrumbs/breadcrumbs.component.html +++ b/libs/components/src/breadcrumbs/breadcrumbs.component.html @@ -2,7 +2,6 @@ @if (breadcrumb.route(); as route) { <a bitLink - linkType="primary" class="tw-my-2 tw-inline-block" [routerLink]="route" [queryParams]="breadcrumb.queryParams()" @@ -14,7 +13,6 @@ <button type="button" bitLink - linkType="primary" class="tw-my-2 tw-inline-block" (click)="breadcrumb.onClick($event)" > @@ -42,7 +40,6 @@ @if (breadcrumb.route(); as route) { <a bitMenuItem - linkType="primary" [routerLink]="route" [queryParams]="breadcrumb.queryParams()" [queryParamsHandling]="breadcrumb.queryParamsHandling()" @@ -50,7 +47,7 @@ <ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container> </a> } @else { - <button type="button" bitMenuItem linkType="primary" (click)="breadcrumb.onClick($event)"> + <button type="button" bitMenuItem (click)="breadcrumb.onClick($event)"> <ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container> </button> } @@ -61,7 +58,6 @@ @if (breadcrumb.route(); as route) { <a bitLink - linkType="primary" class="tw-my-2 tw-inline-block" [routerLink]="route" [queryParams]="breadcrumb.queryParams()" @@ -73,7 +69,6 @@ <button type="button" bitLink - linkType="primary" class="tw-my-2 tw-inline-block" (click)="breadcrumb.onClick($event)" > diff --git a/libs/components/src/button/button.component.html b/libs/components/src/button/button.component.html index 26e0c3b4d3d..d8718340217 100644 --- a/libs/components/src/button/button.component.html +++ b/libs/components/src/button/button.component.html @@ -1,6 +1,14 @@ -<span class="tw-relative"> - <span [ngClass]="{ 'tw-invisible': showLoadingStyle() }"> - <ng-content></ng-content> +<span class="tw-relative tw-flex tw-items-center tw-justify-center"> + <span [class.tw-invisible]="showLoadingStyle()" class="tw-flex tw-items-center tw-gap-2"> + @if (startIcon()) { + <i class="{{ startIconClasses() }}"></i> + } + <div> + <ng-content></ng-content> + </div> + @if (endIcon()) { + <i class="{{ endIconClasses() }}"></i> + } </span> @if (showLoadingStyle()) { <span class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center"> diff --git a/libs/components/src/button/button.component.ts b/libs/components/src/button/button.component.ts index 7cae8fe974d..1055d134e53 100644 --- a/libs/components/src/button/button.component.ts +++ b/libs/components/src/button/button.component.ts @@ -1,4 +1,4 @@ -import { NgClass } from "@angular/common"; +import { NgClass, NgTemplateOutlet } from "@angular/common"; import { input, HostBinding, @@ -14,6 +14,7 @@ import { debounce, interval } from "rxjs"; import { AriaDisableDirective } from "../a11y"; import { ButtonLikeAbstraction, ButtonType, ButtonSize } from "../shared/button-like.abstraction"; +import { BitwardenIcon } from "../shared/icon"; import { SpinnerComponent } from "../spinner"; import { ariaDisableElement } from "../utils"; @@ -71,7 +72,7 @@ const buttonStyles: Record<ButtonType, string[]> = { selector: "button[bitButton], a[bitButton]", templateUrl: "button.component.html", providers: [{ provide: ButtonLikeAbstraction, useExisting: ButtonComponent }], - imports: [NgClass, SpinnerComponent], + imports: [NgClass, NgTemplateOutlet, SpinnerComponent], hostDirectives: [AriaDisableDirective], }) export class ButtonComponent implements ButtonLikeAbstraction { @@ -125,12 +126,23 @@ export class ButtonComponent implements ButtonLikeAbstraction { readonly buttonType = input<ButtonType>("secondary"); + readonly startIcon = input<BitwardenIcon | undefined>(undefined); + + readonly endIcon = input<BitwardenIcon | undefined>(undefined); + readonly size = input<ButtonSize>("default"); readonly block = input(false, { transform: booleanAttribute }); readonly loading = model<boolean>(false); + readonly startIconClasses = computed(() => { + return ["bwi", this.startIcon()]; + }); + + readonly endIconClasses = computed(() => { + return ["bwi", this.endIcon()]; + }); /** * Determine whether it is appropriate to display a loading spinner. We only want to show * a spinner if it's been more than 75 ms since the `loading` state began. This prevents diff --git a/libs/components/src/button/button.stories.ts b/libs/components/src/button/button.stories.ts index 24c263f240a..9e8d23611ff 100644 --- a/libs/components/src/button/button.stories.ts +++ b/libs/components/src/button/button.stories.ts @@ -152,15 +152,13 @@ export const WithIcon: Story = { template: /*html*/ ` <span class="tw-flex tw-gap-8"> <div> - <button type="button" bitButton [buttonType]="buttonType" [block]="block"> - <i class="bwi bwi-plus tw-me-2"></i> + <button type="button" startIcon="bwi-plus" bitButton [buttonType]="buttonType" [block]="block"> Button label </button> </div> <div> - <button type="button" bitButton [buttonType]="buttonType" [block]="block"> + <button type="button" endIcon="bwi-plus" bitButton [buttonType]="buttonType" [block]="block"> Button label - <i class="bwi bwi-plus tw-ms-2"></i> </button> </div> </span> diff --git a/libs/components/src/callout/callout.stories.ts b/libs/components/src/callout/callout.stories.ts index c2185203034..fb1a2d67a40 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, @@ -113,7 +113,7 @@ export const WithTextButton: Story = { template: ` <bit-callout ${formatArgsForCodeSnippet<CalloutComponent>(args)}> <p class="tw-mb-2">The content of the callout</p> - <a bitLink> Visit the help center<i aria-hidden="true" class="bwi bwi-fw bwi-sm bwi-angle-right"></i> </a> + <a bitLink endIcon="bwi-angle-right">Visit the help center</a> </bit-callout> `, }), diff --git a/libs/components/src/card/base-card/base-card.stories.ts b/libs/components/src/card/base-card/base-card.stories.ts index bae07dd1468..98814c1f9f4 100644 --- a/libs/components/src/card/base-card/base-card.stories.ts +++ b/libs/components/src/card/base-card/base-card.stories.ts @@ -1,6 +1,6 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; -import { AnchorLinkDirective } from "../../link"; +import { LinkComponent } from "../../link"; import { TypographyModule } from "../../typography"; import { BaseCardComponent } from "./base-card.component"; @@ -10,7 +10,7 @@ export default { component: BaseCardComponent, decorators: [ moduleMetadata({ - imports: [AnchorLinkDirective, TypographyModule], + imports: [LinkComponent, TypographyModule], }), ], parameters: { diff --git a/libs/components/src/color-password/color-password.component.ts b/libs/components/src/color-password/color-password.component.ts index eaaefd29f1d..3bf4d3d9983 100644 --- a/libs/components/src/color-password/color-password.component.ts +++ b/libs/components/src/color-password/color-password.component.ts @@ -1,5 +1,14 @@ -import { Component, computed, HostBinding, input } from "@angular/core"; +import { + Component, + computed, + ElementRef, + HostBinding, + HostListener, + inject, + input, +} from "@angular/core"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; type CharacterType = "letter" | "emoji" | "special" | "number"; @@ -14,7 +23,7 @@ type CharacterType = "letter" | "emoji" | "special" | "number"; @Component({ selector: "bit-color-password", template: `@for (character of passwordCharArray(); track $index; let i = $index) { - <span [class]="getCharacterClass(character)" class="tw-font-mono"> + <span [class]="getCharacterClass(character)" class="tw-font-mono" data-password-character> <span>{{ character }}</span> @if (showCount()) { <span class="tw-whitespace-nowrap tw-text-xs tw-leading-5 tw-text-main">{{ i + 1 }}</span> @@ -31,6 +40,9 @@ export class ColorPasswordComponent { return Array.from(this.password() ?? ""); }); + private platformUtilsService = inject(PlatformUtilsService); + private elementRef = inject(ElementRef); + characterStyles: Record<CharacterType, string[]> = { emoji: [], letter: ["tw-text-main"], @@ -78,4 +90,28 @@ export class ColorPasswordComponent { return "letter"; } + + @HostListener("copy", ["$event"]) + onCopy(event: ClipboardEvent) { + event.preventDefault(); + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + + const spanElements = this.elementRef.nativeElement.querySelectorAll( + "span[data-password-character]", + ); + let copiedText = ""; + + spanElements.forEach((span: HTMLElement, index: number) => { + if (selection.containsNode(span, true)) { + copiedText += this.passwordCharArray()[index]; + } + }); + + if (copiedText) { + this.platformUtilsService.copyToClipboard(copiedText); + } + } } diff --git a/libs/components/src/color-password/color-password.stories.ts b/libs/components/src/color-password/color-password.stories.ts index 2ed5cdc4b8d..f00b3a4acf5 100644 --- a/libs/components/src/color-password/color-password.stories.ts +++ b/libs/components/src/color-password/color-password.stories.ts @@ -1,4 +1,6 @@ -import { Meta, StoryObj } from "@storybook/angular"; +import { applicationConfig, Meta, StoryObj } from "@storybook/angular"; + +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet"; @@ -9,6 +11,19 @@ const examplePassword = "Wq$Jk😀7jlI DX#rS5Sdi!z0O "; export default { title: "Component Library/Color Password", component: ColorPasswordComponent, + decorators: [ + applicationConfig({ + providers: [ + { + provide: PlatformUtilsService, + useValue: { + // eslint-disable-next-line + copyToClipboard: (text: string) => console.log(`${text} copied to clipboard`), + }, + }, + ], + }), + ], args: { password: examplePassword, showCount: false, diff --git a/libs/components/src/dialog/dialog.service.ts b/libs/components/src/dialog/dialog.service.ts index 88dee499e07..ed17cb27327 100644 --- a/libs/components/src/dialog/dialog.service.ts +++ b/libs/components/src/dialog/dialog.service.ts @@ -10,15 +10,15 @@ import { ComponentPortal, Portal } from "@angular/cdk/portal"; import { Injectable, Injector, TemplateRef, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; -import { filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs"; +import { filter, firstValueFrom, map, Observable, Subject, switchMap, take } from "rxjs"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; 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"; @@ -62,7 +62,7 @@ export abstract class DialogRef<R = unknown, C = unknown> implements Pick< export type DialogConfig<D = unknown, R = unknown> = Pick< CdkDialogConfig<D, R>, - "data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width" + "data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width" | "restoreFocus" >; /** @@ -242,6 +242,11 @@ export class DialogService { }; ref.cdkDialogRefBase = this.dialog.open<R, D, C>(componentOrTemplateRef, _config); + + if (config?.restoreFocus === undefined) { + this.setRestoreFocusEl<R, C>(ref); + } + return ref; } @@ -305,6 +310,48 @@ export class DialogService { return this.activeDrawer?.close(); } + /** + * Configure the dialog to return focus to the previous active element upon closing. + * @param ref CdkDialogRef + * + * The cdk dialog already has the optional directive `cdkTrapFocusAutoCapture` to capture the + * current active element and return focus to it upon close. However, it does not have a way to + * delay the capture of the element. We need this delay in some situations, where the active + * element may be changing as the dialog is opening, and we want to wait for that to settle. + * + * For example -- the menu component often contains menu items that open dialogs. When the dialog + * opens, the menu is closing and is setting focus back to the menu trigger since the menu item no + * longer exists. We want to capture the menu trigger as the active element, not the about-to-be- + * nonexistent menu item. If we wait a tick, we can let the menu finish that focus move. + */ + private setRestoreFocusEl<R = unknown, C = unknown>(ref: CdkDialogRef<R, C>) { + /** + * First, capture the current active el with no delay so that we can support normal use cases + * where we are not doing manual focus management + */ + const activeEl = document.activeElement; + + const restoreFocusTimeout = setTimeout(() => { + let restoreFocusEl = activeEl; + + /** + * If the original active element is no longer connected, it's because we purposely removed it + * from the DOM and have moved focus. Select the new active element instead. + */ + if (!restoreFocusEl?.isConnected) { + restoreFocusEl = document.activeElement; + } + + if (restoreFocusEl instanceof HTMLElement) { + ref.cdkDialogRefBase.config.restoreFocus = restoreFocusEl; + } + }, 0); + + ref.closed.pipe(take(1)).subscribe(() => { + clearTimeout(restoreFocusTimeout); + }); + } + /** The injector that is passed to the opened dialog */ private createInjector(opts: { data: unknown; dialogRef: DialogRef }): Injector { return Injector.create({ diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index 58364dfd045..f81f0594218 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -6,7 +6,6 @@ isDrawer ? 'tw-h-full tw-border-t-0' : 'tw-rounded-t-xl md:tw-rounded-xl tw-shadow-lg', ]" cdkTrapFocus - cdkTrapFocusAutoCapture > @let showHeaderBorder = bodyHasScrolledFrom().top; <header @@ -23,8 +22,8 @@ bitTypography="h3" noMargin class="tw-text-main tw-mb-0 tw-line-clamp-2 tw-text-ellipsis tw-break-words focus-visible:tw-outline-none" - cdkFocusInitial tabindex="-1" + #dialogHeader > {{ title() }} @if (subtitle(); as subtitleText) { diff --git a/libs/components/src/dialog/dialog/dialog.component.ts b/libs/components/src/dialog/dialog/dialog.component.ts index 0f9f341763a..c32ce176d27 100644 --- a/libs/components/src/dialog/dialog/dialog.component.ts +++ b/libs/components/src/dialog/dialog/dialog.component.ts @@ -11,9 +11,11 @@ import { DestroyRef, computed, signal, + AfterViewInit, + NgZone, } from "@angular/core"; import { toObservable } from "@angular/core/rxjs-interop"; -import { combineLatest, switchMap } from "rxjs"; +import { combineLatest, firstValueFrom, switchMap } from "rxjs"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -43,7 +45,7 @@ const drawerSizeToWidth = { // 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-dialog", + selector: "bit-dialog, [bit-dialog]", templateUrl: "./dialog.component.html", host: { "[class]": "classes()", @@ -62,8 +64,13 @@ const drawerSizeToWidth = { SpinnerComponent, ], }) -export class DialogComponent { +export class DialogComponent implements AfterViewInit { private readonly destroyRef = inject(DestroyRef); + private readonly ngZone = inject(NgZone); + private readonly el = inject(ElementRef); + + private readonly dialogHeader = + viewChild.required<ElementRef<HTMLHeadingElement>>("dialogHeader"); private readonly scrollableBody = viewChild.required(CdkScrollable); private readonly scrollBottom = viewChild.required<ElementRef<HTMLDivElement>>("scrollBottom"); @@ -151,4 +158,55 @@ export class DialogComponent { onAnimationEnd() { this.animationCompleted.set(true); } + + async ngAfterViewInit() { + /** + * Wait for the zone to stabilize before performing any focus behaviors. This ensures that all + * child elements are rendered and stable. + */ + if (this.ngZone.isStable) { + this.handleAutofocus(); + } else { + await firstValueFrom(this.ngZone.onStable); + this.handleAutofocus(); + } + } + + /** + * Ensure that the user's focus is in the dialog by autofocusing the appropriate element. + * + * If there is a descendant of the dialog with the AutofocusDirective applied, we defer to that. + * If not, we want to fallback to a default behavior of focusing the dialog's header element. We + * choose the dialog header as the default fallback for dialog focus because it is always present, + * unlike possible interactive elements. + */ + handleAutofocus() { + /** + * Angular's contentChildren query cannot see into the internal templates of child components. + * We need to use a regular DOM query instead to see if there are descendants using the + * AutofocusDirective. + */ + const dialogRef = this.el.nativeElement; + // Must match selectors of AutofocusDirective + const autofocusDescendants = dialogRef.querySelectorAll("[appAutofocus], [bitAutofocus]"); + const hasAutofocusDescendants = autofocusDescendants.length > 0; + + if (!hasAutofocusDescendants) { + /** + * Wait a tick for any focus management to occur on the trigger element before moving focus + * to the dialog header. + * + * We are doing this manually instead of using Angular's built-in focus management + * directives (`cdkTrapFocusAutoCapture` and `cdkFocusInitial`) because we need this delay + * behavior. + * + * And yes, we need the timeout even though we are already waiting for ngZone to stabilize. + */ + const headerFocusTimeout = setTimeout(() => { + this.dialogHeader().nativeElement.focus(); + }, 0); + + this.destroyRef.onDestroy(() => clearTimeout(headerFocusTimeout)); + } + } } diff --git a/libs/components/src/dialog/dialog/dialog.mdx b/libs/components/src/dialog/dialog/dialog.mdx index 056e4ac79bc..33dce6a53e0 100644 --- a/libs/components/src/dialog/dialog/dialog.mdx +++ b/libs/components/src/dialog/dialog/dialog.mdx @@ -82,3 +82,12 @@ The `background` input can be set to `alt` to change the background color. This dialogs that contain multiple card sections. <Canvas of={stories.WithCards} /> + +## Using Forms with Dialogs + +When using forms with dialogs, apply the `bit-dialog` attribute directly to the `<form>` element +instead of wrapping the dialog in a form. This ensures proper styling. + +```html +<form bit-dialog>...</form> +``` diff --git a/libs/components/src/dialog/dialog/dialog.stories.ts b/libs/components/src/dialog/dialog/dialog.stories.ts index 012bb77f2ac..9b96e529789 100644 --- a/libs/components/src/dialog/dialog/dialog.stories.ts +++ b/libs/components/src/dialog/dialog/dialog.stories.ts @@ -225,8 +225,7 @@ export const WithCards: Story = { ...args, }, template: /*html*/ ` - <form [formGroup]="formObj"> - <bit-dialog [dialogSize]="dialogSize" [background]="background" [title]="title" [subtitle]="subtitle" [loading]="loading" [disablePadding]="disablePadding" [disableAnimations]="disableAnimations"> + <form [formGroup]="formObj" bit-dialog [dialogSize]="dialogSize" [background]="background" [title]="title" [subtitle]="subtitle" [loading]="loading" [disablePadding]="disablePadding" [disableAnimations]="disableAnimations"> <ng-container bitDialogContent> <bit-section> <bit-section-header> @@ -270,7 +269,7 @@ export const WithCards: Story = { </bit-section> </ng-container> <ng-container bitDialogFooter> - <button type="button" bitButton buttonType="primary" [disabled]="loading">Save</button> + <button type="submit" bitButton buttonType="primary" [disabled]="loading">Save</button> <button type="button" bitButton buttonType="secondary" [disabled]="loading">Cancel</button> <button type="button" @@ -281,8 +280,7 @@ export const WithCards: Story = { size="default" label="Delete"></button> </ng-container> - </bit-dialog> - </form> + </form> `, }), args: { diff --git a/libs/components/src/dialog/dialogs.mdx b/libs/components/src/dialog/dialogs.mdx index ee03dda6f57..2b8afb06783 100644 --- a/libs/components/src/dialog/dialogs.mdx +++ b/libs/components/src/dialog/dialogs.mdx @@ -25,6 +25,35 @@ interruptive if overused. For non-blocking, supplementary content, open dialogs as a [Drawer](?path=/story/component-library-dialogs-service--drawer) (requires `bit-layout`). +### Closing Drawers on Navigation + +When using drawers, you may want to close them automatically when the user navigates to another page +to prevent the drawer from persisting across route changes. To implement this functionality: + +1. Store a reference to the dialog when opening it +2. Implement `OnDestroy` and close the dialog in `ngOnDestroy` + +```ts +import { Component, OnDestroy } from "@angular/core"; +import { DialogRef } from "@bitwarden/components"; + +export class MyComponent implements OnDestroy { + private myDialogRef: DialogRef; + + ngOnDestroy() { + this.myDialogRef?.close(); + } + + openDrawer() { + this.myDialogRef = this.dialogService.open(MyDialogComponent, { + // dialog options + }); + } +} +``` + +This ensures drawers are closed when the component is destroyed during navigation. + ## Placement Dialogs should be centered vertically and horizontally on screen. Dialogs height should expand to @@ -63,3 +92,12 @@ Once closed, focus should remain on the element which triggered the Dialog. **Note:** If a Simple Dialog is triggered from a main Dialog, be sure to make sure focus is moved to the Simple Dialog. + +## Using Forms with Dialogs + +When using forms with dialogs, apply the `bit-dialog` attribute directly to the `<form>` element +instead of wrapping the dialog in a form. This ensures proper styling. + +```html +<form bit-dialog>...</form> +``` 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/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.html b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.html index 470f4846785..2e285495934 100644 --- a/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.html +++ b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.html @@ -1,27 +1,25 @@ -<form [formGroup]="formGroup" [bitSubmit]="accept"> - <bit-simple-dialog> - <i bitDialogIcon class="bwi tw-text-3xl" [class]="iconClasses" aria-hidden="true"></i> +<form [formGroup]="formGroup" [bitSubmit]="accept" bit-simple-dialog> + <i bitDialogIcon class="bwi tw-text-3xl" [class]="iconClasses" aria-hidden="true"></i> - <span bitDialogTitle>{{ title }}</span> + <span bitDialogTitle>{{ title }}</span> - <div bitDialogContent>{{ content }}</div> + <div bitDialogContent>{{ content }}</div> - <ng-container bitDialogFooter> - <button type="submit" bitButton bitFormButton buttonType="primary"> - {{ acceptButtonText }} + <ng-container bitDialogFooter> + <button type="submit" bitButton bitFormButton buttonType="primary"> + {{ acceptButtonText }} + </button> + + @if (showCancelButton) { + <button + type="button" + bitButton + bitFormButton + buttonType="secondary" + (click)="dialogRef.close(false)" + > + {{ cancelButtonText }} </button> - - @if (showCancelButton) { - <button - type="button" - bitButton - bitFormButton - buttonType="secondary" - (click)="dialogRef.close(false)" - > - {{ cancelButtonText }} - </button> - } - </ng-container> - </bit-simple-dialog> + } + </ng-container> </form> diff --git a/libs/components/src/dialog/simple-dialog/simple-dialog.component.ts b/libs/components/src/dialog/simple-dialog/simple-dialog.component.ts index cd44a79c271..804c654186c 100644 --- a/libs/components/src/dialog/simple-dialog/simple-dialog.component.ts +++ b/libs/components/src/dialog/simple-dialog/simple-dialog.component.ts @@ -12,7 +12,7 @@ export class IconDirective {} // 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-simple-dialog", + selector: "bit-simple-dialog, [bit-simple-dialog]", templateUrl: "./simple-dialog.component.html", animations: [fadeIn], imports: [DialogTitleContainerDirective, TypographyDirective], diff --git a/libs/components/src/dialog/simple-dialog/simple-dialog.mdx b/libs/components/src/dialog/simple-dialog/simple-dialog.mdx index 1d7a3668719..0720715478b 100644 --- a/libs/components/src/dialog/simple-dialog/simple-dialog.mdx +++ b/libs/components/src/dialog/simple-dialog/simple-dialog.mdx @@ -49,3 +49,12 @@ Simple dialogs can support scrolling content if necessary, but typically with la content a [Dialog component](?path=/docs/component-library-dialogs-dialog--docs). <Canvas of={stories.ScrollingContent} /> + +## Using Forms with Dialogs + +When using forms with dialogs, apply the `bit-simple-dialog` attribute directly to the `<form>` +element instead of wrapping the dialog in a form. This ensures proper styling. + +```html +<form bit-simple-dialog>...</form> +``` diff --git a/libs/components/src/dialog/simple-dialog/simple-dialog.stories.ts b/libs/components/src/dialog/simple-dialog/simple-dialog.stories.ts index 3a178892908..c67d52280b0 100644 --- a/libs/components/src/dialog/simple-dialog/simple-dialog.stories.ts +++ b/libs/components/src/dialog/simple-dialog/simple-dialog.stories.ts @@ -126,3 +126,21 @@ export const TextOverflow: Story = { `, }), }; + +export const WithForm: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + <form bit-simple-dialog> + <span bitDialogTitle>Confirm Action</span> + <span bitDialogContent> + Are you sure you want to proceed with this action? This cannot be undone. + </span> + <ng-container bitDialogFooter> + <button type="submit" bitButton buttonType="primary">Confirm</button> + <button type="button" bitButton buttonType="secondary">Cancel</button> + </ng-container> + </form> + `, + }), +}; 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: ` <ng-content></ng-content> `, -}) -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 - * <bit-drawer> - * <button type="button" bitButton bitDrawerClose>Close</button> - * </bit-drawer> - * ``` - **/ -@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 @@ -<header class="tw-flex tw-justify-between tw-items-center tw-gap-4"> - <div class="tw-flex tw-items-center tw-gap-4 tw-overflow-auto"> - <ng-content select="[slot=start]"></ng-content> - <h2 bitTypography="h3" noMargin class="tw-text-main tw-mb-0 tw-truncate" [attr.title]="title()"> - {{ title() }} - </h2> - </div> - <button bitIconButton="bwi-close" type="button" bitDrawerClose [label]="'close' | i18n"></button> -</header> 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<string>(); - - /** 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<Portal<unknown> | undefined>(undefined); - - /** The portal to display */ - portal = this._portal.asReadonly(); - - open(portal: Portal<unknown>) { - this._portal.set(portal); - } - - close(portal: Portal<unknown>) { - 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 @@ -<ng-container *cdkPortal> - <section - [attr.role]="role()" - class="tw-w-[23rem] tw-sticky tw-top-0 tw-h-full tw-flex tw-flex-col tw-overflow-auto tw-border-0 tw-border-l tw-border-solid tw-border-secondary-300 tw-bg-background" - > - <ng-content></ng-content> - </section> -</ng-container> 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<boolean>(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"; - -<Meta of={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. - -<Primary /> - -<Controls /> - -## Usage - -A `bit-drawer` in a template will not render inline, but rather will render adjacent to the main -page content. - -```html -<bit-drawer [open]="true"> - <bit-drawer-header title="Hello Bitwaaaaaaaaaaaaaaaaaaaaaaaaarden!"></bit-drawer-header> - <bit-drawer-body> - <p>Lorem ipsum dolor...</p> - </bit-drawer-body> -</bit-drawer> -``` - -`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: -`<bit-drawer-header title="Foobar"></bit-drawer-header>` - -Custom content can be rendered before the title with the header's `start` slot: - -```html -<bit-drawer-header title="Foobar"> - <i slot="start" class="bwi bwi-key" aria-hidden="true"></i> -</bit-drawer-header> -``` - -## Opening and closing - -`bit-drawer` opens when its `open` input is `true`: - -```html -<bit-drawer [open]="true">...</bit-drawer> -``` - -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 `<bit-drawer [open]="true">` instead of just `<bit-drawer open>`. - -Buttons can be made to open/toggle drawers by referencing a template variable, or by manipulating -state that is bound to `open`: - -```html -<button (click)="myDrawer.toggle()"></button> <bit-drawer #myDrawer>...</bit-drawer> -``` - -For convenience, close buttons can be created _inside_ the drawer with the `bitDrawerClose` -directive: - -```html -<bit-drawer> - <button type="button" bitDrawerClose>Close</button> -</bit-drawer> -``` - -## 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. - -<Canvas of={stories.MultipleDrawers} /> - -## Headless - -Omitting `bit-drawer-header` and `bit-drawer-body` allows for fully customizable content. - -<Canvas of={stories.Headless} /> - -## 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 -<bit-drawer> - <h2 bitTypography="h2">Hello world!</h2> -</bit-drawer> - -<!-- or --> - -<bit-drawer> - <bit-drawer-header title="Hello world!"></bit-drawer-header> -</bit-drawer> -``` - -- 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 - -<Canvas of={KitchenSink} autoplay /> 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<DrawerComponent>; - -type Story = StoryObj<DrawerComponent>; - -export const Default: Story = { - render: (args) => ({ - props: args, - template: /*html*/ ` - <bit-layout class="tw-text-main"> - <p>The drawer is {{ open ? "open" : "closed" }}.<p> - <button type="button" bitButton (click)="drawer.toggle()">Toggle</button> - - <!-- Note: bit-drawer does *not* need to be a direct descendant of bit-layout. --> - <bit-drawer [(open)]="open" #drawer> - <bit-drawer-header title="Hello Bitwaaaaaaaaaaaaaaaaaaaaaaaaarden!"> - <i slot="start" class="bwi bwi-key" aria-hidden="true"></i> - </bit-drawer-header> - <bit-drawer-body> - <p bitTypography="body1"> - 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. - </p> - </bit-drawer-body> - </bit-drawer> - </bit-layout> - `, - }), - args: { - open: true, - }, -}; - -export const Headless: Story = { - render: (args) => ({ - props: args, - template: /*html*/ ` - <bit-layout class="tw-text-main"> - <p>The drawer is {{ open ? "open" : "closed" }}.<p> - <button type="button" bitButton (click)="drawer.toggle()">Toggle</button> - <bit-drawer [(open)]="open" #drawer> - <h2 bitTypography="h2"></h2> - Hello world! - </bit-drawer> - </bit-layout> - `, - }), - args: { - open: true, - }, -}; - -export const MultipleDrawers: Story = { - render: (args) => ({ - props: args, - template: /*html*/ ` - <bit-layout class="tw-text-main"> - <button type="button" bitButton (click)="foo.toggle()">{{ !foo.open() ? "Open" : "Close" }} Foo</button> - <button type="button" bitButton (click)="bar.toggle()">{{ !bar.open() ? "Open" : "Close" }} Bar</button> - - <bit-drawer #foo> - Foo - </bit-drawer> - - <bit-drawer #bar [open]="true"> - Bar - </bit-drawer> - </bit-layout> - `, - }), -}; 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-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index c7eb28fc086..3b5e01132a2 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -71,9 +71,9 @@ const styles: Record<IconButtonType, string[]> = { primary: ["!tw-text-primary-600", "focus-visible:before:tw-ring-primary-600", ...focusRing], danger: ["!tw-text-danger-600", "focus-visible:before:tw-ring-primary-600", ...focusRing], "nav-contrast": [ - "!tw-text-alt2", + "!tw-text-fg-sidenav-text", "hover:!tw-bg-hover-contrast", - "focus-visible:before:tw-ring-text-alt2", + "focus-visible:before:tw-ring-border-focus", ...focusRing, ], }; 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<Icon>(); +export class IconComponent { + /** + * The Bitwarden icon name (e.g., "bwi-lock", "bwi-user") + */ + readonly name = input.required<BitwardenIcon>(); + /** + * Accessible label for the icon + */ readonly ariaLabel = input<string>(); - 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 +<bit-icon name="bwi-lock"></bit-icon> +``` -2. **Rename the file** as a `<name>.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`<svg … </svg>`; - ``` +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 +<bit-icon name="bwi-lock" [ariaLabel]="'Secure lock'"></bit-icon> +``` - - 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` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#020F66"}}></span> | `tw-stroke-illustration-outline` | `tw-fill-illustration-outline` | `--color-illustration-outline` | - | `#DBE5F6` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#DBE5F6"}}></span> | `tw-stroke-illustration-bg-primary` | `tw-fill-illustration-bg-primary` | `--color-illustration-bg-primary` | - | `#AAC3EF` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#AAC3EF"}}></span> | `tw-stroke-illustration-bg-secondary` | `tw-fill-illustration-bg-secondary` | `--color-illustration-bg-secondary` | - | `#FFFFFF` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#FFFFFF"}}></span> | `tw-stroke-illustration-bg-tertiary` | `tw-fill-illustration-bg-tertiary` | `--color-illustration-bg-tertiary` | - | `#FFBF00` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#FFBF00"}}></span> | `tw-stroke-illustration-tertiary` | `tw-fill-illustration-tertiary` | `--color-illustration-tertiary` | - | `#175DDC` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#175DDC"}}></span> | `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 +<bit-icon name="bwi-lock" class="tw-text-primary-500 tw-text-2xl"></bit-icon> +``` -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 `<bit-icon>` component - - ```html - <bit-icon [icon]="Icons.ExampleIcon"></bit-icon> - ``` - - With `ariaLabel` - - ```html - <bit-icon [icon]="Icons.ExampleIcon" [ariaLabel]="Your custom label text here"></bit-icon> - ``` - -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<IconComponent>; -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<IconComponent>; -export const Default = { - render: (args: { icons: [string, any][] }) => ({ - props: args, - template: /*html*/ ` - <div class="tw-bg-secondary-100 tw-p-2 tw-grid tw-grid-cols-[repeat(auto-fit,minmax(224px,1fr))] tw-gap-2"> - @for (icon of icons; track icon[0]) { - <div class="tw-size-56 tw-border tw-border-secondary-300 tw-rounded-md"> - <div class="tw-text-xs tw-text-center">{{icon[0]}}</div> - <div class="tw-size-52 tw-w-full tw-content-center"> - <bit-icon [icon]="icon[1]"></bit-icon> - </div> - </div> - } - </div> - `, - }), +export const Default: Story = { args: { - icons: Object.entries(Icons), + name: "bwi-lock", }, }; + +export const AllIcons: Story = { + render: () => ({ + template: ` + <div class="tw-grid tw-grid-cols-[repeat(auto-fit,minmax(150px,1fr))] tw-gap-4 tw-p-4"> + @for (icon of icons; track icon) { + <div class="tw-flex tw-flex-col tw-items-center tw-p-2 tw-border tw-border-secondary-300 tw-rounded"> + <bit-icon [name]="icon" class="tw-text-2xl tw-mb-2"></bit-icon> + <span class="tw-text-xs tw-text-center">{{ icon }}</span> + </div> + } + </div> + `, + props: { + icons: BITWARDEN_ICONS, + }, + }), +}; + +export const WithAriaLabel: Story = { + args: { + name: "bwi-lock", + ariaLabel: "Secure lock icon", + }, +}; + +export const CompareWithLegacy: Story = { + render: () => ({ + template: `<bit-icon name="bwi-lock"></bit-icon> <i class="bwi bwi-lock"></i>`, + }), +}; 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 23fb5beb456..d0bb8576095 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"; @@ -6,6 +7,7 @@ export * from "./avatar"; export * from "./badge-list"; export * from "./badge"; export * from "./banner"; +export * from "./berry"; export * from "./breadcrumbs"; export * from "./button"; export * from "./callout"; @@ -17,14 +19,15 @@ 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"; +export * from "./landing-layout"; export * from "./layout"; export * from "./link"; export * from "./menu"; diff --git a/libs/components/src/input/autofocus.directive.ts b/libs/components/src/input/autofocus.directive.ts index a4791a51f01..bffac8eb757 100644 --- a/libs/components/src/input/autofocus.directive.ts +++ b/libs/components/src/input/autofocus.directive.ts @@ -22,6 +22,8 @@ import { FocusableElement } from "../shared/focusable-element"; * * If the component provides the `FocusableElement` interface, the `focus` * method will be called. Otherwise, the native element will be focused. + * + * If selector changes, `dialog.component.ts` must also be updated */ @Directive({ selector: "[appAutofocus], [bitAutofocus]", diff --git a/libs/components/src/landing-layout/index.ts b/libs/components/src/landing-layout/index.ts new file mode 100644 index 00000000000..49b3d24631d --- /dev/null +++ b/libs/components/src/landing-layout/index.ts @@ -0,0 +1,7 @@ +export * from "./landing-layout.component"; +export * from "./landing-layout.module"; +export * from "./landing-card.component"; +export * from "./landing-content.component"; +export * from "./landing-footer.component"; +export * from "./landing-header.component"; +export * from "./landing-hero.component"; diff --git a/libs/components/src/landing-layout/landing-card.component.html b/libs/components/src/landing-layout/landing-card.component.html new file mode 100644 index 00000000000..bea783489bf --- /dev/null +++ b/libs/components/src/landing-layout/landing-card.component.html @@ -0,0 +1,5 @@ +<bit-base-card + class="tw-z-[2] tw-relative !tw-rounded-2xl tw-mb-6 sm:tw-mb-10 tw-mx-auto tw-w-full tw-bg-transparent tw-border-none tw-shadow-none sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-100 sm:tw-shadow sm:tw-p-8" +> + <ng-content></ng-content> +</bit-base-card> diff --git a/libs/components/src/landing-layout/landing-card.component.ts b/libs/components/src/landing-layout/landing-card.component.ts new file mode 100644 index 00000000000..cea04f6f784 --- /dev/null +++ b/libs/components/src/landing-layout/landing-card.component.ts @@ -0,0 +1,33 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +import { BaseCardComponent } from "../card"; + +/** + * Card component for landing pages that wraps content in a styled container. + * + * @remarks + * This component provides: + * - Card-based layout with consistent styling + * - Content projection for forms, text, or other content + * - Proper elevation and border styling + * + * Use this component inside `bit-landing-content` to wrap forms, content sections, + * or any content that should appear in a contained, elevated card. + * + * @example + * ```html + * <bit-landing-card> + * <form> + * <!-- Your form fields here --> + * </form> + * </bit-landing-card> + * ``` + */ +@Component({ + selector: "bit-landing-card", + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [BaseCardComponent], + templateUrl: "./landing-card.component.html", +}) +export class LandingCardComponent {} diff --git a/libs/components/src/landing-layout/landing-content.component.html b/libs/components/src/landing-layout/landing-content.component.html new file mode 100644 index 00000000000..a09db26e4e4 --- /dev/null +++ b/libs/components/src/landing-layout/landing-content.component.html @@ -0,0 +1,8 @@ +<div + class="tw-flex tw-flex-col tw-flex-1 tw-items-center tw-bg-background-alt tw-p-5 tw-pt-12 tw-text-main" +> + <div [class]="maxWidthClasses()"> + <ng-content select="bit-landing-hero"></ng-content> + <ng-content></ng-content> + </div> +</div> diff --git a/libs/components/src/landing-layout/landing-content.component.ts b/libs/components/src/landing-layout/landing-content.component.ts new file mode 100644 index 00000000000..940e4b01f53 --- /dev/null +++ b/libs/components/src/landing-layout/landing-content.component.ts @@ -0,0 +1,63 @@ +import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core"; + +export const LandingContentMaxWidth = ["md", "lg", "xl", "2xl", "3xl", "4xl"] as const; + +export type LandingContentMaxWidthType = (typeof LandingContentMaxWidth)[number]; + +/** + * Main content container for landing pages with configurable max-width constraints. + * + * @remarks + * This component provides: + * - Centered content area with alternative background color + * - Configurable maximum width to control content readability + * - Content projection slots for hero section and main content + * - Responsive padding and layout + * + * Use this component inside `bit-landing-layout` to wrap your main page content. + * Optionally include a `bit-landing-hero` as the first child for consistent hero section styling. + * + * @example + * ```html + * <bit-landing-content [maxWidth]="'xl'"> + * <bit-landing-hero + * [icon]="lockIcon" + * [title]="'Welcome'" + * [subtitle]="'Get started with your account'" + * ></bit-landing-hero> + * <bit-landing-card> + * <!-- Your form or content here --> + * </bit-landing-card> + * </bit-landing-content> + * ``` + */ +@Component({ + selector: "bit-landing-content", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-content.component.html", + host: { + class: "tw-grow tw-flex tw-flex-col", + }, +}) +export class LandingContentComponent { + /** + * Max width of the landing layout container. + * + * @default "md" + */ + readonly maxWidth = input<LandingContentMaxWidthType>("md"); + + private readonly maxWidthClassMap: Record<LandingContentMaxWidthType, string> = { + md: "tw-max-w-md", + lg: "tw-max-w-lg", + xl: "tw-max-w-xl", + "2xl": "tw-max-w-2xl", + "3xl": "tw-max-w-3xl", + "4xl": "tw-max-w-4xl", + }; + + readonly maxWidthClasses = computed(() => { + const maxWidthClass = this.maxWidthClassMap[this.maxWidth()]; + return `tw-flex tw-flex-col tw-w-full ${maxWidthClass}`; + }); +} diff --git a/libs/components/src/landing-layout/landing-footer.component.html b/libs/components/src/landing-layout/landing-footer.component.html new file mode 100644 index 00000000000..c0230a93171 --- /dev/null +++ b/libs/components/src/landing-layout/landing-footer.component.html @@ -0,0 +1,3 @@ +<footer class="tw-bg-background-alt tw-text-center tw-p-5 tw-pt-4 sm:tw-pt-6"> + <ng-content></ng-content> +</footer> diff --git a/libs/components/src/landing-layout/landing-footer.component.ts b/libs/components/src/landing-layout/landing-footer.component.ts new file mode 100644 index 00000000000..f18199bd280 --- /dev/null +++ b/libs/components/src/landing-layout/landing-footer.component.ts @@ -0,0 +1,29 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +/** + * Footer component for landing pages. + * + * @remarks + * This component provides: + * - Content projection for custom footer content (e.g., links, copyright, legal) + * - Consistent footer positioning at the bottom of the page + * - Proper z-index to appear above background illustrations + * + * Use this component inside `bit-landing-layout` as the last child to position it at the bottom. + * + * @example + * ```html + * <bit-landing-footer> + * <div class="tw-text-center tw-text-sm"> + * <a routerLink="/privacy">Privacy</a> + * <span>© 2024 Bitwarden</span> + * </div> + * </bit-landing-footer> + * ``` + */ +@Component({ + selector: "bit-landing-footer", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-footer.component.html", +}) +export class LandingFooterComponent {} diff --git a/libs/components/src/landing-layout/landing-header.component.html b/libs/components/src/landing-layout/landing-header.component.html new file mode 100644 index 00000000000..882f1b96c99 --- /dev/null +++ b/libs/components/src/landing-layout/landing-header.component.html @@ -0,0 +1,13 @@ +<header class="tw-flex tw-w-full tw-bg-background-alt tw-px-5"> + @if (!hideLogo()) { + <a + [routerLink]="['/']" + class="tw-w-32 tw-py-5 sm:tw-w-[200px] tw-self-center sm:tw-self-start tw-block [&>*]:tw-align-top" + > + <bit-svg [content]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-svg> + </a> + } + <div class="[&:has(*)]:tw-ms-auto [&:has(*)]:tw-py-5"> + <ng-content></ng-content> + </div> +</header> diff --git a/libs/components/src/landing-layout/landing-header.component.ts b/libs/components/src/landing-layout/landing-header.component.ts new file mode 100644 index 00000000000..c0fb3cd67f1 --- /dev/null +++ b/libs/components/src/landing-layout/landing-header.component.ts @@ -0,0 +1,42 @@ +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { RouterModule } from "@angular/router"; + +import { BitwardenLogo } from "@bitwarden/assets/svg"; + +import { SharedModule } from "../shared"; +import { SvgModule } from "../svg"; + +/** + * Header component for landing pages with optional Bitwarden logo and header actions slot. + * + * @remarks + * This component provides: + * - Optional Bitwarden logo with link to home page (left-aligned) + * - Default content projection slot for header actions (right-aligned, auto-margin left) + * - Consistent header styling across landing pages + * - Responsive layout that adapts logo size + * + * Use this component inside `bit-landing-layout` as the first child to position it at the top. + * Content projected into this component will automatically align to the right side of the header. + * + * @example + * ```html + * <bit-landing-header [hideLogo]="false"> + * <!-- Content here appears in the right-aligned actions slot --> + * <nav> + * <a routerLink="/login">Log in</a> + * <button type="button">Sign up</button> + * </nav> + * </bit-landing-header> + * ``` + */ +@Component({ + selector: "bit-landing-header", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-header.component.html", + imports: [RouterModule, SvgModule, SharedModule], +}) +export class LandingHeaderComponent { + readonly hideLogo = input<boolean>(false); + protected readonly logo = BitwardenLogo; +} diff --git a/libs/components/src/landing-layout/landing-hero.component.html b/libs/components/src/landing-layout/landing-hero.component.html new file mode 100644 index 00000000000..9394bb03c63 --- /dev/null +++ b/libs/components/src/landing-layout/landing-hero.component.html @@ -0,0 +1,28 @@ +@if (icon() || title() || subtitle()) { + <div class="tw-text-center tw-mb-4 sm:tw-mb-6 tw-mx-auto"> + @if (icon()) { + <!-- In some scenarios this icon's size is not limited by container width correctly --> + <!-- Targeting the SVG here to try and ensure it never grows too large in even the media queries are not working as expected --> + <div + class="tw-size-20 sm:tw-size-24 [&_svg]:tw-w-full [&_svg]:tw-max-w-24 tw-mx-auto tw-content-center" + > + <bit-svg [content]="icon()"></bit-svg> + </div> + } + + @if (title()) { + <!-- Small screens --> + <h1 bitTypography="h2" class="tw-mt-2 sm:tw-hidden"> + {{ title() }} + </h1> + <!-- Medium to Larger screens --> + <h1 bitTypography="h1" class="tw-mt-2 tw-hidden sm:tw-block"> + {{ title() }} + </h1> + } + + @if (subtitle()) { + <div class="tw-text-sm sm:tw-text-base">{{ subtitle() }}</div> + } + </div> +} diff --git a/libs/components/src/landing-layout/landing-hero.component.ts b/libs/components/src/landing-layout/landing-hero.component.ts new file mode 100644 index 00000000000..d3b9ffd0ee9 --- /dev/null +++ b/libs/components/src/landing-layout/landing-hero.component.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; + +import { BitSvg } from "@bitwarden/assets/svg"; + +import { SvgModule } from "../svg"; +import { TypographyModule } from "../typography"; + +/** + * Hero section component for landing pages featuring an optional icon, title, and subtitle. + * + * @remarks + * This component provides: + * - Optional icon display (e.g., feature icons, status icons) + * - Large title text with consistent typography + * - Subtitle text for additional context + * - Centered layout with proper spacing + * + * Use this component as the first child inside `bit-landing-content` to create a prominent + * hero section that introduces the page's purpose. + * + * @example + * ```html + * <bit-landing-hero + * [icon]="lockIcon" + * [title]="'Secure Your Passwords'" + * [subtitle]="'Create your account to get started'" + * ></bit-landing-hero> + * ``` + */ +@Component({ + selector: "bit-landing-hero", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-hero.component.html", + imports: [SvgModule, TypographyModule], +}) +export class LandingHeroComponent { + readonly icon = input<BitSvg | null>(null); + readonly title = input<string | undefined>(); + readonly subtitle = input<string | undefined>(); +} diff --git a/libs/components/src/landing-layout/landing-layout.component.html b/libs/components/src/landing-layout/landing-layout.component.html new file mode 100644 index 00000000000..a33054e8e64 --- /dev/null +++ b/libs/components/src/landing-layout/landing-layout.component.html @@ -0,0 +1,25 @@ +<div + class="tw-relative tw-flex tw-size-full tw-mx-auto tw-flex-col" + [class]="{ + 'tw-min-h-screen': clientType === 'web', + 'tw-min-h-full': clientType === 'browser' || clientType === 'desktop', + }" +> + <ng-content select="bit-landing-header"></ng-content> + <main class="tw-relative tw-flex tw-flex-1 tw-size-full tw-mx-auto tw-flex-col"> + <ng-content></ng-content> + </main> + @if (!hideBackgroundIllustration()) { + <div + class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-start-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]" + > + <bit-svg [content]="leftIllustration"></bit-svg> + </div> + <div + class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-end-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]" + > + <bit-svg [content]="rightIllustration"></bit-svg> + </div> + } + <ng-content select="bit-landing-footer"></ng-content> +</div> diff --git a/libs/components/src/landing-layout/landing-layout.component.ts b/libs/components/src/landing-layout/landing-layout.component.ts new file mode 100644 index 00000000000..65c7302e828 --- /dev/null +++ b/libs/components/src/landing-layout/landing-layout.component.ts @@ -0,0 +1,40 @@ +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 { SvgModule } from "../svg"; + +/** + * Root layout component for landing pages providing a full-screen container with optional decorative background illustrations. + * + * @remarks + * This component serves as the outermost wrapper for landing pages and provides: + * - Full-screen layout that adapts to different client types (web, browser, desktop) + * - Optional decorative background illustrations in the bottom corners + * - Content projection slots for header, main content, and footer + * + * @example + * ```html + * <bit-landing-layout [hideBackgroundIllustration]="false"> + * <bit-landing-header>...</bit-landing-header> + * <bit-landing-content>...</bit-landing-content> + * <bit-landing-footer>...</bit-landing-footer> + * </bit-landing-layout> + * ``` + */ +@Component({ + selector: "bit-landing-layout", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-layout.component.html", + imports: [SvgModule], +}) +export class LandingLayoutComponent { + readonly hideBackgroundIllustration = input<boolean>(false); + + protected readonly leftIllustration = BackgroundLeftIllustration; + protected readonly rightIllustration = BackgroundRightIllustration; + + private readonly platformUtilsService: PlatformUtilsService = inject(PlatformUtilsService); + protected readonly clientType = this.platformUtilsService.getClientType(); +} diff --git a/libs/components/src/landing-layout/landing-layout.module.ts b/libs/components/src/landing-layout/landing-layout.module.ts new file mode 100644 index 00000000000..d225b8b35e1 --- /dev/null +++ b/libs/components/src/landing-layout/landing-layout.module.ts @@ -0,0 +1,28 @@ +import { NgModule } from "@angular/core"; + +import { LandingCardComponent } from "./landing-card.component"; +import { LandingContentComponent } from "./landing-content.component"; +import { LandingFooterComponent } from "./landing-footer.component"; +import { LandingHeaderComponent } from "./landing-header.component"; +import { LandingHeroComponent } from "./landing-hero.component"; +import { LandingLayoutComponent } from "./landing-layout.component"; + +@NgModule({ + imports: [ + LandingLayoutComponent, + LandingHeaderComponent, + LandingHeroComponent, + LandingFooterComponent, + LandingContentComponent, + LandingCardComponent, + ], + exports: [ + LandingLayoutComponent, + LandingHeaderComponent, + LandingHeroComponent, + LandingFooterComponent, + LandingContentComponent, + LandingCardComponent, + ], +}) +export class LandingLayoutModule {} diff --git a/libs/components/src/landing-layout/landing-layout.stories.ts b/libs/components/src/landing-layout/landing-layout.stories.ts new file mode 100644 index 00000000000..7ea9598a64a --- /dev/null +++ b/libs/components/src/landing-layout/landing-layout.stories.ts @@ -0,0 +1,162 @@ +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; + +import { ClientType } from "@bitwarden/common/enums"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { ButtonModule } from "../button"; + +import { LandingLayoutComponent } from "./landing-layout.component"; + +class MockPlatformUtilsService implements Partial<PlatformUtilsService> { + getClientType = () => ClientType.Web; +} + +type StoryArgs = LandingLayoutComponent & { + contentLength: "normal" | "long" | "thin"; + includeHeader: boolean; + includeFooter: boolean; +}; + +export default { + title: "Component Library/Landing Layout", + component: LandingLayoutComponent, + decorators: [ + moduleMetadata({ + imports: [ButtonModule], + providers: [ + { + provide: PlatformUtilsService, + useClass: MockPlatformUtilsService, + }, + ], + }), + ], + render: (args) => { + return { + props: args, + template: /*html*/ ` + <bit-landing-layout + [hideBackgroundIllustration]="hideBackgroundIllustration" + > + @if (includeHeader) { + <bit-landing-header> + <div class="tw-p-4"> + <div class="tw-flex tw-items-center tw-gap-4"> + <div class="tw-text-xl tw-font-semibold">Header Content</div> + </div> + </div> + </bit-landing-header> + } + + <div> + @switch (contentLength) { + @case ('thin') { + <div class="tw-text-center tw-p-8"> + <div class="tw-font-medium">Thin Content</div> + </div> + } + @case ('long') { + <div class="tw-p-8"> + <div class="tw-font-medium tw-mb-4">Long Content</div> + <div class="tw-mb-4">Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?</div> + <div class="tw-mb-4">Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?</div> + </div> + } + @default { + <div class="tw-p-8"> + <div class="tw-font-medium tw-mb-4">Normal Content</div> + <div>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.</div> + </div> + } + } + </div> + + @if (includeFooter) { + <bit-landing-footer> + <div class="tw-text-center tw-text-sm tw-text-muted"> + <div>Footer Content</div> + </div> + </bit-landing-footer> + } + </bit-landing-layout> + `, + }; + }, + + argTypes: { + hideBackgroundIllustration: { control: "boolean" }, + contentLength: { + control: "radio", + options: ["normal", "long", "thin"], + }, + includeHeader: { control: "boolean" }, + includeFooter: { control: "boolean" }, + }, + + args: { + hideBackgroundIllustration: false, + contentLength: "normal", + includeHeader: false, + includeFooter: false, + }, +} satisfies Meta<StoryArgs>; + +type Story = StoryObj<StoryArgs>; + +export const Default: Story = { + args: { + contentLength: "normal", + }, +}; + +export const WithHeader: Story = { + args: { + includeHeader: true, + }, +}; + +export const WithFooter: Story = { + args: { + includeFooter: true, + }, +}; + +export const WithHeaderAndFooter: Story = { + args: { + includeHeader: true, + includeFooter: true, + }, +}; + +export const LongContent: Story = { + args: { + contentLength: "long", + includeHeader: true, + includeFooter: true, + }, +}; + +export const ThinContent: Story = { + args: { + contentLength: "thin", + includeHeader: true, + includeFooter: true, + }, +}; + +export const NoBackgroundIllustration: Story = { + args: { + hideBackgroundIllustration: true, + includeHeader: true, + includeFooter: true, + }, +}; + +export const MinimalState: Story = { + args: { + contentLength: "thin", + hideBackgroundIllustration: true, + includeHeader: false, + includeFooter: false, + }, +}; diff --git a/libs/components/src/layout/layout.component.html b/libs/components/src/layout/layout.component.html index 255799b6690..f0e2b601e38 100644 --- a/libs/components/src/layout/layout.component.html +++ b/libs/components/src/layout/layout.component.html @@ -1,5 +1,5 @@ @let mainContentId = "main-content"; -<div class="tw-flex tw-size-full"> +<div class="tw-flex tw-size-full" [class.tw-bg-background-alt3]="rounded()"> <div class="tw-flex tw-size-full" cdkTrapFocus> <div class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto" @@ -24,26 +24,20 @@ tabindex="-1" bitScrollLayoutHost class="tw-overflow-auto tw-max-h-full tw-min-w-0 tw-flex-1 tw-bg-background tw-p-8 tw-pt-6 tw-@container" + [class.tw-rounded-tl-2xl]="rounded()" > <!-- ^ If updating this padding, also update the padding correction in bit-banner! ^ --> <ng-content></ng-content> </main> <!-- overlay backdrop for side-nav --> - @if ( - { - open: sideNavService.open$ | async, - }; - as data - ) { - <div - class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden" - [ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']" - > - @if (data.open) { - <div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div> - } - </div> - } + <div + class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden" + [class]="sideNavService.open() ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0'" + > + @if (sideNavService.open()) { + <div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div> + } + </div> </div> <div class="tw-absolute tw-z-50 tw-left-0 md:tw-sticky tw-top-0 tw-h-full md:tw-w-auto"> <ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template> diff --git a/libs/components/src/layout/layout.component.ts b/libs/components/src/layout/layout.component.ts index 7460099cf92..c71c4e73c6e 100644 --- a/libs/components/src/layout/layout.component.ts +++ b/libs/components/src/layout/layout.component.ts @@ -1,12 +1,11 @@ import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y"; import { PortalModule } from "@angular/cdk/portal"; import { CommonModule } from "@angular/common"; -import { Component, ElementRef, inject, viewChild } from "@angular/core"; +import { booleanAttribute, Component, ElementRef, inject, input, viewChild } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { DrawerHostDirective } from "../drawer/drawer-host.directive"; -import { DrawerService } from "../drawer/drawer.service"; -import { LinkModule } from "../link"; +import { DrawerService } from "../dialog/drawer.service"; +import { LinkComponent, LinkModule } from "../link"; import { SideNavService } from "../navigation/side-nav.service"; import { SharedModule } from "../shared"; @@ -31,13 +30,18 @@ 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); protected drawerPortal = inject(DrawerService).portal; private readonly mainContent = viewChild.required<ElementRef<HTMLElement>>("main"); + + /** + * Rounded top left corner for the main content area + */ + readonly rounded = input(false, { transform: booleanAttribute }); + protected focusMainContent() { this.mainContent().nativeElement.focus(); } @@ -48,11 +52,11 @@ export class LayoutComponent { * * @see https://github.com/angular/components/issues/10247#issuecomment-384060265 **/ - private readonly skipLink = viewChild.required<ElementRef<HTMLElement>>("skipLink"); + private readonly skipLink = viewChild.required<LinkComponent>("skipLink"); handleKeydown(ev: KeyboardEvent) { if (isNothingFocused()) { ev.preventDefault(); - this.skipLink().nativeElement.focus(); + this.skipLink().el.nativeElement.focus(); } } } diff --git a/libs/components/src/layout/layout.stories.ts b/libs/components/src/layout/layout.stories.ts index 59770c21d2e..75ae329a1b3 100644 --- a/libs/components/src/layout/layout.stories.ts +++ b/libs/components/src/layout/layout.stories.ts @@ -14,6 +14,8 @@ import { StorybookGlobalStateProvider } from "../utils/state-mock"; import { LayoutComponent } from "./layout.component"; import { mockLayoutI18n } from "./mocks"; +import { formatArgsForCodeSnippet } from ".storybook/format-args-for-code-snippet"; + export default { title: "Component Library/Layout", component: LayoutComponent, @@ -63,7 +65,7 @@ export const WithContent: Story = { render: (args) => ({ props: args, template: /* HTML */ ` - <bit-layout> + <bit-layout ${formatArgsForCodeSnippet<LayoutComponent>(args)}> <bit-side-nav> <bit-nav-group text="Hello World (Anchor)" [route]="['a']" icon="bwi-filter"> <bit-nav-item text="Child A" route="a" icon="bwi-filter"></bit-nav-item> @@ -111,3 +113,10 @@ export const Secondary: Story = { `, }), }; + +export const Rounded: Story = { + ...WithContent, + args: { + rounded: true, + }, +}; diff --git a/libs/components/src/link/index.ts b/libs/components/src/link/index.ts index 480f5396de7..08617e813f5 100644 --- a/libs/components/src/link/index.ts +++ b/libs/components/src/link/index.ts @@ -1,2 +1,2 @@ -export * from "./link.directive"; +export * from "./link.component"; export * from "./link.module"; diff --git a/libs/components/src/link/link.component.html b/libs/components/src/link/link.component.html new file mode 100644 index 00000000000..810b65db519 --- /dev/null +++ b/libs/components/src/link/link.component.html @@ -0,0 +1,11 @@ +<div class="tw-flex tw-gap-2 tw-items-center"> + @if (startIcon()) { + <i [class]="['bwi', startIcon()]" aria-hidden="true"></i> + } + <span> + <ng-content></ng-content> + </span> + @if (endIcon()) { + <i [class]="['bwi', endIcon()]" aria-hidden="true"></i> + } +</div> diff --git a/libs/components/src/link/link.component.ts b/libs/components/src/link/link.component.ts new file mode 100644 index 00000000000..79cf55da637 --- /dev/null +++ b/libs/components/src/link/link.component.ts @@ -0,0 +1,151 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + booleanAttribute, + inject, + ElementRef, +} from "@angular/core"; + +import { BitwardenIcon } from "../shared/icon"; +import { ariaDisableElement } from "../utils"; + +export const LinkTypes = [ + "primary", + "secondary", + "contrast", + "light", + "default", + "subtle", + "success", + "warning", + "danger", +] as const; + +export type LinkType = (typeof LinkTypes)[number]; + +const linkStyles: Record<LinkType, string[]> = { + primary: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"], + default: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"], + secondary: ["tw-text-fg-heading", "hover:tw-text-fg-heading"], + light: ["tw-text-fg-white", "hover:tw-text-fg-white", "focus-visible:before:tw-ring-fg-contrast"], + subtle: ["!tw-text-fg-heading", "hover:tw-text-fg-heading"], + success: ["tw-text-fg-success", "hover:tw-text-fg-success-strong"], + warning: ["tw-text-fg-warning", "hover:tw-text-fg-warning-strong"], + danger: ["tw-text-fg-danger", "hover:tw-text-fg-danger-strong"], + contrast: [ + "tw-text-fg-contrast", + "hover:tw-text-fg-contrast", + "focus-visible:before:tw-ring-fg-contrast", + ], +}; + +const commonStyles = [ + "tw-text-unset", + "tw-leading-none", + "tw-px-0", + "tw-py-0.5", + "tw-font-semibold", + "tw-bg-transparent", + "tw-border-0", + "tw-border-none", + "tw-rounded", + "tw-transition", + "tw-no-underline", + "tw-cursor-pointer", + "[&:hover_span]:tw-underline", + "[&.tw-test-hover_span]:tw-underline", + "[&:hover_span]:tw-decoration-[.125em]", + "[&.tw-test-hover_span]:tw-decoration-[.125em]", + "focus-visible:tw-outline-none", + "focus-visible:before:tw-ring-border-focus", + "[&:focus-visible_span]:tw-underline", + "[&:focus-visible_span]:tw-decoration-[.125em]", + "[&.tw-test-focus-visible_span]:tw-underline", + "[&.tw-test-focus-visible_span]:tw-decoration-[.125em]", + + // Workaround for html button tag not being able to be set to `display: inline` + // and at the same time not being able to use `tw-ring-offset` because of box-shadow issue. + // https://github.com/w3c/csswg-drafts/issues/3226 + // Add `tw-inline`, add `tw-py-0.5` and use regular `tw-ring` if issue is fixed. + // + // https://github.com/tailwindlabs/tailwindcss/issues/3595 + // Remove `before:` and use regular `tw-ring` when browser no longer has bug, or better: + // switch to `outline` with `outline-offset` when Safari supports border radius on outline. + // Using `box-shadow` to create outlines is a hack and as such `outline` should be preferred. + "tw-relative", + "before:tw-content-['']", + "before:tw-block", + "before:tw-absolute", + "before:-tw-inset-x-[0.1em]", + "before:-tw-inset-y-[0]", + "before:tw-rounded-md", + "before:tw-transition", + "before:tw-h-full", + "before:tw-w-[calc(100%_+_.25rem)]", + "before:tw-pointer-events-none", + "focus-visible:before:tw-ring-2", + "focus-visible:tw-z-10", + "aria-disabled:tw-no-underline", + "aria-disabled:tw-pointer-events-none", + "aria-disabled:!tw-text-fg-disabled", + "aria-disabled:hover:!tw-text-fg-disabled", + "aria-disabled:hover:tw-no-underline", + "[&[aria-disabled]:focus-visible_span]:!tw-no-underline", +]; + +@Component({ + selector: "a[bitLink], button[bitLink]", + templateUrl: "./link.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + "[class]": "classList()", + // This is for us to be able to correctly aria-disable the button and capture clicks. + // It's normally added via the AriaDisableDirective as a host directive. + // But, we're not able to conditionally apply the host directive based on if this is a button or not + "[attr.bit-aria-disable]": "isButton ? true : null", + }, +}) +export class LinkComponent { + readonly el = inject(ElementRef<HTMLElement>); + /** + * The variant of link you want to render + * @default "primary" + */ + readonly linkType = input<LinkType>("primary"); + /** + * The leading icon to display within the link + * @default undefined + */ + readonly startIcon = input<BitwardenIcon | undefined>(undefined); + /** + * The trailing icon to display within the link + * @default undefined + */ + readonly endIcon = input<BitwardenIcon | undefined>(undefined); + /** + * Whether the button is disabled + * @default false + * @note Only applicable if the link is rendered as a button + */ + readonly disabled = input(false, { transform: booleanAttribute }); + + protected readonly isButton = this.el.nativeElement.tagName === "BUTTON"; + + readonly classList = computed(() => { + return [!this.isButton && "tw-inline-flex"] + .concat(commonStyles) + .concat(linkStyles[this.linkType()] ?? []); + }); + + focus() { + this.el.nativeElement.focus(); + } + + constructor() { + if (this.isButton) { + ariaDisableElement(this.el.nativeElement, this.disabled); + } + } +} diff --git a/libs/components/src/link/link.directive.ts b/libs/components/src/link/link.directive.ts deleted file mode 100644 index e6de8ac8402..00000000000 --- a/libs/components/src/link/link.directive.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { input, HostBinding, Directive, inject, ElementRef, booleanAttribute } from "@angular/core"; - -import { AriaDisableDirective } from "../a11y"; -import { ariaDisableElement } from "../utils"; - -export type LinkType = "primary" | "secondary" | "contrast" | "light"; - -const linkStyles: Record<LinkType, string[]> = { - primary: [ - "!tw-text-primary-600", - "hover:!tw-text-primary-700", - "focus-visible:before:tw-ring-primary-600", - ], - secondary: ["!tw-text-main", "hover:!tw-text-main", "focus-visible:before:tw-ring-primary-600"], - contrast: [ - "!tw-text-contrast", - "hover:!tw-text-contrast", - "focus-visible:before:tw-ring-text-contrast", - ], - light: ["!tw-text-alt2", "hover:!tw-text-alt2", "focus-visible:before:tw-ring-text-alt2"], -}; - -const commonStyles = [ - "tw-text-unset", - "tw-leading-none", - "tw-px-0", - "tw-py-0.5", - "tw-font-semibold", - "tw-bg-transparent", - "tw-border-0", - "tw-border-none", - "tw-rounded", - "tw-transition", - "tw-no-underline", - "hover:tw-underline", - "hover:tw-decoration-1", - "disabled:tw-no-underline", - "disabled:tw-cursor-not-allowed", - "disabled:!tw-text-secondary-300", - "disabled:hover:!tw-text-secondary-300", - "disabled:hover:tw-no-underline", - "focus-visible:tw-outline-none", - "focus-visible:tw-underline", - "focus-visible:tw-decoration-1", - - // Workaround for html button tag not being able to be set to `display: inline` - // and at the same time not being able to use `tw-ring-offset` because of box-shadow issue. - // https://github.com/w3c/csswg-drafts/issues/3226 - // Add `tw-inline`, add `tw-py-0.5` and use regular `tw-ring` if issue is fixed. - // - // https://github.com/tailwindlabs/tailwindcss/issues/3595 - // Remove `before:` and use regular `tw-ring` when browser no longer has bug, or better: - // switch to `outline` with `outline-offset` when Safari supports border radius on outline. - // Using `box-shadow` to create outlines is a hack and as such `outline` should be preferred. - "tw-relative", - "before:tw-content-['']", - "before:tw-block", - "before:tw-absolute", - "before:-tw-inset-x-[0.1em]", - "before:tw-rounded-md", - "before:tw-transition", - "focus-visible:before:tw-ring-2", - "focus-visible:tw-z-10", - "aria-disabled:tw-no-underline", - "aria-disabled:tw-pointer-events-none", - "aria-disabled:!tw-text-secondary-300", - "aria-disabled:hover:!tw-text-secondary-300", - "aria-disabled:hover:tw-no-underline", -]; - -@Directive() -abstract class LinkDirective { - readonly linkType = input<LinkType>("primary"); -} - -/** - * Text Links and Buttons can use either the `<a>` or `<button>` tags. Choose which based on the action the button takes: - - * - if navigating to a new page, use a `<a>` - * - if taking an action on the current page, use a `<button>` - - * Text buttons or links are most commonly used in paragraphs of text or in forms to customize actions or show/hide additional form options. - */ -@Directive({ - selector: "a[bitLink]", -}) -export class AnchorLinkDirective extends LinkDirective { - @HostBinding("class") get classList() { - return ["before:-tw-inset-y-[0.125rem]"] - .concat(commonStyles) - .concat(linkStyles[this.linkType()] ?? []); - } -} - -@Directive({ - selector: "button[bitLink]", - hostDirectives: [AriaDisableDirective], -}) -export class ButtonLinkDirective extends LinkDirective { - private el = inject(ElementRef<HTMLButtonElement>); - - readonly disabled = input(false, { transform: booleanAttribute }); - - @HostBinding("class") get classList() { - return ["before:-tw-inset-y-[0.25rem]"] - .concat(commonStyles) - .concat(linkStyles[this.linkType()] ?? []); - } - - constructor() { - super(); - ariaDisableElement(this.el.nativeElement, this.disabled); - } -} diff --git a/libs/components/src/link/link.mdx b/libs/components/src/link/link.mdx index 072e0dd84d8..4954effb6c0 100644 --- a/libs/components/src/link/link.mdx +++ b/libs/components/src/link/link.mdx @@ -18,10 +18,15 @@ import { LinkModule } from "@bitwarden/components"; You can use one of the following variants by providing it as the `linkType` input: -- `primary` - most common, uses brand color -- `secondary` - matches the main text color +- @deprecated `primary` => use `default` instead +- @deprecated `secondary` => use `subtle` instead +- `default` - most common, uses brand color +- `subtle` - matches the main text color - `contrast` - for high contrast against a dark background (or a light background in dark mode) - `light` - always a light color, even in dark mode +- `warning` - used in association with warning callouts/banners +- `success` - used in association with success callouts/banners +- `danger` - used in association with danger callouts/banners ## Sizes diff --git a/libs/components/src/link/link.module.ts b/libs/components/src/link/link.module.ts index 52d2f29e53c..87ad8daa7e1 100644 --- a/libs/components/src/link/link.module.ts +++ b/libs/components/src/link/link.module.ts @@ -1,9 +1,9 @@ import { NgModule } from "@angular/core"; -import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive"; +import { LinkComponent } from "./link.component"; @NgModule({ - imports: [AnchorLinkDirective, ButtonLinkDirective], - exports: [AnchorLinkDirective, ButtonLinkDirective], + imports: [LinkComponent], + exports: [LinkComponent], }) export class LinkModule {} diff --git a/libs/components/src/link/link.stories.ts b/libs/components/src/link/link.stories.ts index ae91c9be108..6df3a11ce40 100644 --- a/libs/components/src/link/link.stories.ts +++ b/libs/components/src/link/link.stories.ts @@ -2,7 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet"; -import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive"; +import { LinkComponent, LinkTypes } from "./link.component"; import { LinkModule } from "./link.module"; export default { @@ -14,7 +14,7 @@ export default { ], argTypes: { linkType: { - options: ["primary", "secondary", "contrast"], + options: LinkTypes.map((type) => type), control: { type: "radio" }, }, }, @@ -26,64 +26,167 @@ export default { }, } as Meta; -type Story = StoryObj<ButtonLinkDirective>; +type Story = StoryObj<LinkComponent>; export const Default: Story = { render: (args) => ({ + props: { + linkType: args.linkType, + backgroundClass: + args.linkType === "contrast" + ? "tw-bg-bg-contrast" + : args.linkType === "light" + ? "tw-bg-bg-brand" + : "tw-bg-transparent", + }, template: /*html*/ ` - <a bitLink ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a> + <div class="tw-p-2" [class]="backgroundClass"> + <a bitLink href="#" ${formatArgsForCodeSnippet<LinkComponent>(args)}>Your text here</a> + </div> `, }), + args: { + linkType: "primary", + }, + parameters: { + chromatic: { disableSnapshot: true }, + }, +}; + +export const AllVariations: Story = { + render: () => ({ + template: /*html*/ ` + <div class="tw-flex tw-flex-col tw-gap-6"> + <div class="tw-flex tw-gap-4 tw-p-2"> + <a bitLink linkType="primary" href="#">Primary</a> + </div> + <div class="tw-flex tw-gap-4 tw-p-2"> + <a bitLink linkType="secondary" href="#">Secondary</a> + </div> + <div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-contrast"> + <a bitLink linkType="contrast" href="#">Contrast</a> + </div> + <div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-brand"> + <a bitLink linkType="light" href="#">Light</a> + </div> + <div class="tw-flex tw-gap-4 tw-p-2"> + <a bitLink linkType="default" href="#">Default</a> + </div> + <div class="tw-flex tw-gap-4 tw-p-2"> + <a bitLink linkType="subtle" href="#">Subtle</a> + </div> + <div class="tw-flex tw-gap-4 tw-p-2"> + <a bitLink linkType="success" href="#">Success</a> + </div> + <div class="tw-flex tw-gap-4 tw-p-2"> + <a bitLink linkType="warning" href="#">Warning</a> + </div> + <div class="tw-flex tw-gap-4 tw-p-2"> + <a bitLink linkType="danger" href="#">Danger</a> + </div> + </div> + `, + }), + parameters: { + controls: { + exclude: ["linkType"], + hideNoControlsWarning: true, + }, + }, }; export const InteractionStates: Story = { render: () => ({ template: /*html*/ ` - <div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6"> + <div class="tw-flex tw-flex-col tw-gap-6"> + <div class="tw-flex tw-gap-4 tw-p-2"> <a bitLink linkType="primary" href="#">Primary</a> <a bitLink linkType="primary" href="#" class="tw-test-hover">Primary</a> <a bitLink linkType="primary" href="#" class="tw-test-focus-visible">Primary</a> <a bitLink linkType="primary" href="#" class="tw-test-hover tw-test-focus-visible">Primary</a> </div> - <div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6"> + <div class="tw-flex tw-gap-4 tw-p-2"> <a bitLink linkType="secondary" href="#">Secondary</a> <a bitLink linkType="secondary" href="#" class="tw-test-hover">Secondary</a> <a bitLink linkType="secondary" href="#" class="tw-test-focus-visible">Secondary</a> <a bitLink linkType="secondary" href="#" class="tw-test-hover tw-test-focus-visible">Secondary</a> </div> - <div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6 tw-bg-primary-600"> + <div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-contrast"> <a bitLink linkType="contrast" href="#">Contrast</a> <a bitLink linkType="contrast" href="#" class="tw-test-hover">Contrast</a> <a bitLink linkType="contrast" href="#" class="tw-test-focus-visible">Contrast</a> <a bitLink linkType="contrast" href="#" class="tw-test-hover tw-test-focus-visible">Contrast</a> </div> - <div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6 tw-bg-primary-600"> + <div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-brand"> <a bitLink linkType="light" href="#">Light</a> <a bitLink linkType="light" href="#" class="tw-test-hover">Light</a> <a bitLink linkType="light" href="#" class="tw-test-focus-visible">Light</a> <a bitLink linkType="light" href="#" class="tw-test-hover tw-test-focus-visible">Light</a> </div> + <div class="tw-flex tw-gap-4 tw-p-2"> + <a bitLink linkType="default" href="#">Default</a> + <a bitLink linkType="default" href="#" class="tw-test-hover">Default</a> + <a bitLink linkType="default" href="#" class="tw-test-focus-visible">Default</a> + <a bitLink linkType="default" href="#" class="tw-test-hover tw-test-focus-visible">Default</a> + </div> + <div class="tw-flex tw-gap-4 tw-p-2"> + <a bitLink linkType="subtle" href="#">Subtle</a> + <a bitLink linkType="subtle" href="#" class="tw-test-hover">Subtle</a> + <a bitLink linkType="subtle" href="#" class="tw-test-focus-visible">Subtle</a> + <a bitLink linkType="subtle" href="#" class="tw-test-hover tw-test-focus-visible">Subtle</a> + </div> + <div class="tw-flex tw-gap-4 tw-p-2"> + <a bitLink linkType="success" href="#">Success</a> + <a bitLink linkType="success" href="#" class="tw-test-hover">Success</a> + <a bitLink linkType="success" href="#" class="tw-test-focus-visible">Success</a> + <a bitLink linkType="success" href="#" class="tw-test-hover tw-test-focus-visible">Success</a> + </div> + <div class="tw-flex tw-gap-4 tw-p-2"> + <a bitLink linkType="warning" href="#">Warning</a> + <a bitLink linkType="warning" href="#" class="tw-test-hover">Warning</a> + <a bitLink linkType="warning" href="#" class="tw-test-focus-visible">Warning</a> + <a bitLink linkType="warning" href="#" class="tw-test-hover tw-test-focus-visible">Warning</a> + </div> + <div class="tw-flex tw-gap-4 tw-p-2"> + <a bitLink linkType="danger" href="#">Danger</a> + <a bitLink linkType="danger" href="#" class="tw-test-hover">Danger</a> + <a bitLink linkType="danger" href="#" class="tw-test-focus-visible">Danger</a> + <a bitLink linkType="danger" href="#" class="tw-test-hover tw-test-focus-visible">Danger</a> + </div> + </div> `, }), + parameters: { + controls: { + exclude: ["linkType"], + hideNoControlsWarning: true, + }, + }, }; export const Buttons: Story = { render: (args) => ({ - props: args, + props: { + linkType: args.linkType, + backgroundClass: + args.linkType === "contrast" + ? "tw-bg-bg-contrast" + : args.linkType === "light" + ? "tw-bg-bg-brand" + : "tw-bg-transparent", + }, template: /*html*/ ` - <div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }"> + <div class="tw-p-2" [class]="backgroundClass"> <div class="tw-block tw-p-2"> <button type="button" bitLink [linkType]="linkType">Button</button> </div> <div class="tw-block tw-p-2"> - <button type="button" bitLink [linkType]="linkType"> - <i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i> + <button type="button" bitLink [linkType]="linkType" startIcon="bwi-plus-circle"> Add Icon Button </button> </div> <div class="tw-block tw-p-2"> - <button type="button" bitLink [linkType]="linkType"> - <i class="bwi bwi-fw bwi-sm bwi-angle-right" aria-hidden="true"></i> + <button type="button" bitLink [linkType]="linkType" endIcon="bwi-angle-right"> Chevron Icon Button </button> </div> @@ -98,23 +201,29 @@ export const Buttons: Story = { }, }; -export const Anchors: StoryObj<AnchorLinkDirective> = { +export const Anchors: StoryObj<LinkComponent> = { render: (args) => ({ - props: args, + props: { + linkType: args.linkType, + backgroundClass: + args.linkType === "contrast" + ? "tw-bg-bg-contrast" + : args.linkType === "light" + ? "tw-bg-bg-brand" + : "tw-bg-transparent", + }, template: /*html*/ ` - <div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }"> + <div class="tw-p-2" [class]="backgroundClass"> <div class="tw-block tw-p-2"> <a bitLink [linkType]="linkType" href="#">Anchor</a> </div> <div class="tw-block tw-p-2"> - <a bitLink [linkType]="linkType" href="#"> - <i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i> + <a bitLink [linkType]="linkType" href="#" startIcon="bwi-plus-circle"> Add Icon Anchor </a> </div> <div class="tw-block tw-p-2"> - <a bitLink [linkType]="linkType" href="#"> - <i class="bwi bwi-fw bwi-sm bwi-angle-right" aria-hidden="true"></i> + <a bitLink [linkType]="linkType" href="#" endIcon="bwi-angle-right"> Chevron Icon Anchor </a> </div> @@ -134,23 +243,57 @@ export const Inline: Story = { props: args, template: /*html*/ ` <span class="tw-text-main"> - On the internet paragraphs often contain <a bitLink href="#">inline links</a>, but few know that <button type="button" bitLink>buttons</button> can be used for similar purposes. + On the internet paragraphs often contain <a bitLink href="#">inline links with very long text that might break</a>, but few know that <button type="button" bitLink>buttons</button> can be used for similar purposes. </span> `, }), +}; + +export const WithIcons: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + <div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }"> + <div class="tw-block tw-p-2"> + <a bitLink [linkType]="linkType" href="#" startIcon="bwi-star">Start icon link</a> + </div> + <div class="tw-block tw-p-2"> + <a bitLink [linkType]="linkType" href="#" endIcon="bwi-external-link">External link</a> + </div> + <div class="tw-block tw-p-2"> + <a bitLink [linkType]="linkType" href="#" startIcon="bwi-angle-left" endIcon="bwi-angle-right">Both icons</a> + </div> + <div class="tw-block tw-p-2"> + <button type="button" bitLink [linkType]="linkType" startIcon="bwi-plus-circle">Add item</button> + </div> + <div class="tw-block tw-p-2"> + <button type="button" bitLink [linkType]="linkType" endIcon="bwi-angle-right">Next</button> + </div> + <div class="tw-block tw-p-2"> + <button type="button" bitLink [linkType]="linkType" startIcon="bwi-download" endIcon="bwi-check">Download complete</button> + </div> + </div> + `, + }), args: { linkType: "primary", }, }; -export const Disabled: Story = { +export const Inactive: Story = { render: (args) => ({ - props: args, + props: { + ...args, + onClick: () => { + alert("Button clicked! (This should not appear when disabled)"); + }, + }, template: /*html*/ ` - <button type="button" bitLink disabled linkType="primary" class="tw-me-2">Primary</button> - <button type="button" bitLink disabled linkType="secondary" class="tw-me-2">Secondary</button> + <button type="button" bitLink (click)="onClick()" disabled linkType="primary" class="tw-me-2">Primary button</button> + <a bitLink href="" disabled linkType="primary" class="tw-me-2">Links can not be inactive</a> + <button type="button" bitLink disabled linkType="secondary" class="tw-me-2">Secondary button</button> <div class="tw-bg-primary-600 tw-p-2 tw-inline-block"> - <button type="button" bitLink disabled linkType="contrast">Contrast</button> + <button type="button" bitLink disabled linkType="contrast">Contrast button</button> </div> `, }), diff --git a/libs/components/src/menu/menu-trigger-for.directive.ts b/libs/components/src/menu/menu-trigger-for.directive.ts index 1d79fbc9768..6306f3326d6 100644 --- a/libs/components/src/menu/menu-trigger-for.directive.ts +++ b/libs/components/src/menu/menu-trigger-for.directive.ts @@ -192,7 +192,7 @@ export class MenuTriggerForDirective implements OnDestroy { return; } - const escKey = this.overlayRef.keydownEvents().pipe( + const keyEvents = this.overlayRef.keydownEvents().pipe( filter((event: KeyboardEvent) => { const keys = this.menu().ariaRole() === "menu" ? ["Escape", "Tab"] : ["Escape"]; return keys.includes(event.key); @@ -202,8 +202,8 @@ export class MenuTriggerForDirective implements OnDestroy { const detachments = this.overlayRef.detachments(); const closeEvents = isContextMenu - ? merge(detachments, escKey, menuClosed) - : merge(detachments, escKey, this.overlayRef.backdropClick(), menuClosed); + ? merge(detachments, keyEvents, menuClosed) + : merge(detachments, keyEvents, this.overlayRef.backdropClick(), menuClosed); this.closedEventsSub = closeEvents .pipe(takeUntil(this.overlayRef.detachments())) @@ -215,9 +215,9 @@ export class MenuTriggerForDirective implements OnDestroy { event.preventDefault(); } - if (event instanceof KeyboardEvent && (event.key === "Tab" || event.key === "Escape")) { - this.elementRef.nativeElement.focus(); - } + // Move focus to the menu trigger, since any active menu items are about to be destroyed + this.elementRef.nativeElement.focus(); + this.destroyMenu(); }); } diff --git a/libs/components/src/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<RouterLink["routerLink"]>(); /** * Passed to internal `routerLink` * - * See {@link RouterLink.relativeTo} + * @see {@link RouterLink.relativeTo} */ readonly relativeTo = input<RouterLink["relativeTo"]>(); /** * Passed to internal `routerLink` * - * See {@link RouterLinkActive.routerLinkActiveOptions} + * @default { paths: "subset", queryParams: "ignored", fragment: "ignored", matrixParams: "ignored" } + * @see {@link RouterLinkActive.routerLinkActiveOptions} */ readonly routerLinkActiveOptions = input<RouterLinkActive["routerLinkActiveOptions"]>({ 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<MouseEvent> = new EventEmitter(); + readonly mainContentClicked = output<void>(); } 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()) { <div class="tw-h-px tw-w-full tw-my-2 tw-bg-secondary-300"></div> } 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 1790fea179a..26d1c68da43 100644 --- a/libs/components/src/navigation/nav-group.component.html +++ b/libs/components/src/navigation/nav-group.component.html @@ -19,10 +19,8 @@ <ng-template #button> <button type="button" - class="tw-ms-auto" - [ngClass]="{ - 'tw-transform tw-rotate-[90deg]': variantValue === 'tree' && !open(), - }" + class="tw-ms-auto tw-text-fg-sidenav-text" + [class]="variantValue === 'tree' && !open() ? 'tw-transform tw-rotate-[90deg]' : ''" [bitIconButton]="toggleButtonIcon()" buttonType="nav-contrast" (click)="toggle($event)" diff --git a/libs/components/src/navigation/nav-group.component.ts b/libs/components/src/navigation/nav-group.component.ts index 3e584c855ef..72a70128da9 100644 --- a/libs/components/src/navigation/nav-group.component.ts +++ b/libs/components/src/navigation/nav-group.component.ts @@ -1,17 +1,14 @@ -import { CommonModule } from "@angular/common"; +import { NgTemplateOutlet } from "@angular/common"; import { booleanAttribute, Component, - EventEmitter, - Optional, - Output, - SkipSelf, + inject, input, model, contentChildren, + ChangeDetectionStrategy, computed, } from "@angular/core"; -import { toSignal } from "@angular/core/rxjs-interop"; import { RouterLinkActive } from "@angular/router"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -22,8 +19,6 @@ import { NavBaseComponent } from "./nav-base.component"; import { NavGroupAbstraction, NavItemComponent } from "./nav-item.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-group", templateUrl: "./nav-group.component.html", @@ -31,20 +26,24 @@ import { SideNavService } from "./side-nav.service"; { provide: NavBaseComponent, useExisting: NavGroupComponent }, { provide: NavGroupAbstraction, useExisting: NavGroupComponent }, ], - imports: [CommonModule, NavItemComponent, IconButtonModule, I18nPipe], + imports: [NgTemplateOutlet, NavItemComponent, IconButtonModule, I18nPipe], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class NavGroupComponent extends NavBaseComponent { + protected readonly sideNavService = inject(SideNavService); + private readonly parentNavGroup = inject(NavGroupComponent, { optional: true, skipSelf: true }); + // Query direct children for hideIfEmpty functionality readonly nestedNavComponents = contentChildren(NavBaseComponent, { descendants: false }); - readonly sideNavOpen = toSignal(this.sideNavService.open$); + protected readonly sideNavOpen = this.sideNavService.open; readonly sideNavAndGroupOpen = computed(() => { return this.open() && this.sideNavOpen(); }); /** When the side nav is open, the parent nav item should not show active styles when open. */ - readonly parentHideActiveStyles = computed(() => { + protected readonly parentHideActiveStyles = computed(() => { return this.hideActiveStyles() || this.sideNavAndGroupOpen(); }); @@ -80,7 +79,7 @@ export class NavGroupComponent extends NavBaseComponent { /** * UID for `[attr.aria-controls]` */ - protected contentId = Math.random().toString(36).substring(2); + protected readonly contentId = Math.random().toString(36).substring(2); /** * Is `true` if the expanded content is visible @@ -98,15 +97,7 @@ export class NavGroupComponent extends NavBaseComponent { /** Does not toggle the expanded state on click */ readonly disableToggleOnClick = input(false, { transform: booleanAttribute }); - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref - @Output() - openChange = new EventEmitter<boolean>(); - - constructor( - protected sideNavService: SideNavService, - @Optional() @SkipSelf() private parentNavGroup: NavGroupComponent, - ) { + constructor() { super(); // Set tree depth based on parent's depth @@ -118,9 +109,8 @@ export class NavGroupComponent extends NavBaseComponent { setOpen(isOpen: boolean) { this.open.set(isOpen); - this.openChange.emit(this.open()); - if (this.open()) { - this.parentNavGroup?.setOpen(this.open()); + if (this.open() && this.parentNavGroup) { + this.parentNavGroup.setOpen(this.open()); } } @@ -130,9 +120,9 @@ export class NavGroupComponent extends NavBaseComponent { } protected handleMainContentClicked() { - if (!this.sideNavService.open) { + if (!this.sideNavService.open()) { if (!this.route()) { - this.sideNavService.setOpen(); + this.sideNavService.open.set(true); } this.open.set(true); } else if (!this.disableToggleOnClick()) { diff --git a/libs/components/src/navigation/nav-group.stories.ts b/libs/components/src/navigation/nav-group.stories.ts index 04b282c1bab..417630e00a1 100644 --- a/libs/components/src/navigation/nav-group.stories.ts +++ b/libs/components/src/navigation/nav-group.stories.ts @@ -1,4 +1,4 @@ -import { Component, importProvidersFrom } from "@angular/core"; +import { ChangeDetectionStrategy, Component, importProvidersFrom } from "@angular/core"; import { RouterModule } from "@angular/router"; import { StoryObj, Meta, moduleMetadata, applicationConfig } from "@storybook/angular"; @@ -14,10 +14,9 @@ import { StorybookGlobalStateProvider } from "../utils/state-mock"; import { NavGroupComponent } from "./nav-group.component"; import { NavigationModule } from "./navigation.module"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "", + changeDetection: ChangeDetectionStrategy.OnPush, }) class DummyContentComponent {} diff --git a/libs/components/src/navigation/nav-item.component.html b/libs/components/src/navigation/nav-item.component.html index 8a59d474d94..9dda3b3b6a7 100644 --- a/libs/components/src/navigation/nav-item.component.html +++ b/libs/components/src/navigation/nav-item.component.html @@ -1,53 +1,27 @@ <div class="tw-ps-2 tw-pe-2"> - @let open = sideNavService.open$ | async; + @let open = sideNavService.open(); @if (open || icon()) { <div [style.padding-inline-start]="navItemIndentationPadding()" class="tw-relative tw-rounded-md tw-h-10" - [class.tw-bg-background-alt4]="showActiveStyles" - [class.tw-bg-background-alt3]="!showActiveStyles" - [class.hover:tw-bg-hover-contrast]="!showActiveStyles" - [class]="fvwStyles$ | async" + [class]="fvwStyles()" + [class.tw-bg-bg-sidenav-active-item]="showActiveStyles" + [class.tw-bg-bg-sidenav-background]="!showActiveStyles" + [class.hover:tw-bg-bg-sidenav-item-hover]="!showActiveStyles" > <div class="tw-relative tw-flex tw-items-center tw-h-full"> @if (open) { <div - class="tw-absolute tw-left-[0px] tw-transform tw--translate-x-[calc(100%_+_4px)] [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2" + class="tw-absolute tw-left-[0px] tw-transform tw--translate-x-[calc(100%_+_4px)] [&>*:focus-visible::before]:!tw-ring-border-focus [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2" > <ng-content select="[slot=start]"></ng-content> </div> } - <ng-container *ngIf="route(); then isAnchor; else isButton"></ng-container> - - <!-- Main content of `NavItem` --> - <ng-template #anchorAndButtonContent> - <div - [title]="text()" - class="tw-gap-2 tw-flex tw-items-center tw-font-medium tw-h-full" - [class.tw-py-0]="variant() === 'tree' || treeDepth() > 0" - [class.tw-py-2]="variant() !== 'tree' && treeDepth() === 0" - [class.tw-text-center]="!open" - [class.tw-justify-center]="!open" - > - @if (icon()) { - <i - class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-alt2 {{ icon() }}" - [attr.aria-hidden]="open" - [attr.aria-label]="text()" - ></i> - } - @if (open) { - <span class="tw-truncate">{{ text() }}</span> - } - </div> - </ng-template> - - <!-- Show if a value was passed to `this.route` --> - <ng-template #isAnchor> - <!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin$` --> - <!-- The following `class` field should match the `#isButton` class field below --> + @if (route()) { + <!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin` --> + <!-- The following `class` field should match the button class field below --> <a - class="tw-size-full tw-px-4 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]" + class="tw-size-full tw-px-4 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-fg-sidenav-text hover:tw-text-fg-sidenav-text hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]" [class.!tw-ps-0]="variant() === 'tree' || treeDepth() > 0" data-fvw [routerLink]="route()" @@ -61,25 +35,22 @@ > <ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container> </a> - </ng-template> - - <!-- Show if `this.route` is falsy --> - <ng-template #isButton> - <!-- Class field should match `#isAnchor` class field above --> + } @else { + <!-- Class field should match anchor class field above --> <button type="button" - class="tw-size-full tw-px-4 tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]" + class="tw-size-full tw-px-4 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-fg-sidenav-text hover:tw-text-fg-sidenav-text hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]" [class.!tw-ps-0]="variant() === 'tree' || treeDepth() > 0" data-fvw (click)="mainContentClicked.emit()" > <ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container> </button> - </ng-template> + } @if (open) { <div - class="tw-flex tw-items-center tw-pe-1 tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2 empty:tw-hidden" + class="tw-flex tw-items-center tw-pe-1 tw-gap-1 [&>*:focus-visible::before]:!tw-ring-border-focus [&>*:hover]:!tw-border-border-focus [&>*]:tw-text-fg-sidenav-text empty:tw-hidden" > <ng-content select="[slot=end]"></ng-content> </div> @@ -88,3 +59,27 @@ </div> } </div> + +<!-- Main content of `NavItem` --> +<ng-template #anchorAndButtonContent> + <div + [title]="text()" + class="tw-gap-2 tw-flex tw-items-center tw-font-medium tw-h-full" + [class.tw-py-0]="variant() === 'tree' || treeDepth() > 0" + [class.tw-py-2]="variant() !== 'tree' && treeDepth() === 0" + [class.tw-text-center]="!open" + [class.tw-justify-center]="!open" + > + @if (icon()) { + <i + class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-fg-sidenav-text" + [class]="icon()" + [attr.aria-hidden]="open" + [attr.aria-label]="text()" + ></i> + } + @if (open) { + <span class="tw-truncate">{{ text() }}</span> + } + </div> +</ng-template> diff --git a/libs/components/src/navigation/nav-item.component.ts b/libs/components/src/navigation/nav-item.component.ts index b32ca0e3fde..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<typeof model<number>>; } -// 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<boolean>(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-text-alt2" : "", - ), + 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 5915f029357..8323a0f3479 100644 --- a/libs/components/src/navigation/nav-logo.component.html +++ b/libs/components/src/navigation/nav-logo.component.html @@ -1,22 +1,21 @@ <div - [ngClass]="{ - 'tw-sticky tw-top-0 tw-z-50 tw-pb-4': sideNavService.open, - 'tw-pb-[calc(theme(spacing.8)_+_2px)]': !sideNavService.open, - }" class="tw-px-2 tw-pt-2" + [class]=" + sideNavService.open() + ? 'tw-sticky tw-top-0 tw-z-50 tw-pb-4' + : 'tw-pb-[calc(theme(spacing.8)_+_2px)]' + " > <!-- absolutely position the link svg to avoid shifting layout when sidenav is closed --> <a [routerLink]="route()" - class="tw-relative tw-p-3 tw-block tw-rounded-md tw-bg-background-alt3 tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-text-alt2 hover:tw-bg-hover-contrast tw-h-[73px] [&_svg]:tw-absolute [&_svg]:tw-inset-[.6875rem] [&_svg]:tw-w-[200px]" - [ngClass]="{ - '!tw-h-[55px] [&_svg]:!tw-w-[26px]': !sideNavService.open, - }" + class="tw-relative tw-p-3 tw-block tw-rounded-md tw-bg-bg-sidenav tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-border-focus hover:tw-bg-bg-sidenav-item-hover tw-h-[73px] [&_svg]:tw-absolute [&_svg]:tw-inset-[.6875rem] [&_svg]:tw-w-[200px]" + [class]="!sideNavService.open() ? '!tw-h-[55px] [&_svg]:!tw-w-[26px]' : ''" [attr.aria-label]="label()" [title]="label()" routerLinkActive ariaCurrentWhenActive="page" > - <bit-icon [icon]="sideNavService.open ? openIcon() : closedIcon()"></bit-icon> + <bit-svg [content]="sideNavService.open() ? openIcon() : closedIcon()"></bit-svg> </a> </div> 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>(); + /** + * Icon that is displayed when the side nav is open + */ + readonly openIcon = input.required<BitSvg>(); /** * Route to be passed to internal `routerLink` */ readonly route = input.required<string | any[]>(); - /** Passed to `attr.aria-label` and `attr.title` */ + /** + * Passed to `attr.aria-label` and `attr.title` + */ readonly label = input.required<string>(); - - 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 38ec096fe49..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 -) { - <div class="tw-relative tw-h-full"> - <nav - id="bit-side-nav" - class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-background-alt3 tw-outline-none" - [style.width.rem]="data.open ? (sideNavService.width$ | async) : undefined" - [ngStyle]=" - variant() === 'secondary' && { - '--color-text-alt2': 'var(--color-text-main)', - '--color-background-alt3': 'var(--color-secondary-100)', - '--color-background-alt4': 'var(--color-secondary-300)', - '--color-hover-contrast': 'var(--color-hover-default)', - } - " - [cdkTrapFocus]="data.isOverlay" - [attr.role]="data.isOverlay ? 'dialog' : null" - [attr.aria-modal]="data.isOverlay ? 'true' : null" - (keydown)="handleKeyDown($event)" - > - <ng-content></ng-content> - <!-- 53rem = ~850px --> - <!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) --> - <div - class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-background-alt3" - > - <bit-nav-divider></bit-nav-divider> - @if (data.open) { - <ng-content select="[slot=footer]"></ng-content> - } - <div class="tw-mx-0.5 tw-my-4 tw-w-[3.75rem]"> - <button - #toggleButton - type="button" - class="tw-mx-auto tw-block tw-max-w-fit" - [bitIconButton]="data.open ? 'bwi-angle-left' : 'bwi-angle-right'" - buttonType="nav-contrast" - size="small" - (click)="sideNavService.toggle()" - [label]="'toggleSideNavigation' | i18n" - [attr.aria-expanded]="data.open" - aria-controls="bit-side-nav" - ></button> - </div> - </div> - </nav> +@let open = sideNavService.open(); +@let isOverlay = sideNavService.isOverlay(); + +<div class="tw-relative tw-h-full"> + <nav + id="bit-side-nav" + class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-bg-sidenav tw-text-fg-sidenav-text tw-outline-none" + [style.width.rem]="open ? (sideNavService.width$ | async) : undefined" + [style]=" + variant() === 'secondary' + ? '--color-sidenav-text: var(--color-admin-sidenav-text); --color-sidenav-background: var(--color-admin-sidenav-background); --color-sidenav-active-item: var(--color-admin-sidenav-active-item); --color-sidenav-item-hover: var(--color-admin-sidenav-item-hover);' + : '' + " + [cdkTrapFocus]="isOverlay" + [attr.role]="isOverlay ? 'dialog' : null" + [attr.aria-modal]="isOverlay ? 'true' : null" + (keydown)="handleKeyDown($event)" + > + <ng-content></ng-content> + <!-- 53rem = ~850px --> + <!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) --> <div - cdkDrag - (cdkDragMoved)="onDragMoved($event)" - class="tw-absolute tw-top-0 -tw-right-0.5 tw-z-30 tw-h-full tw-w-1 tw-cursor-col-resize tw-transition-colors tw-duration-[250ms] hover:tw-ease-in-out hover:tw-delay-[250ms] hover:tw-bg-primary-300 focus:tw-outline-none focus-visible:tw-bg-primary-300 before:tw-content-[''] before:tw-absolute before:tw-block before:tw-inset-y-0 before:-tw-left-0.5 before:-tw-right-1" - [class.tw-hidden]="!data.open" - tabindex="0" - (keydown)="onKeydown($event)" - role="separator" - [attr.aria-valuenow]="sideNavService.width$ | async" - [attr.aria-valuemax]="sideNavService.MAX_OPEN_WIDTH" - [attr.aria-valuemin]="sideNavService.MIN_OPEN_WIDTH" - aria-orientation="vertical" - aria-controls="bit-side-nav" - [attr.aria-label]="'resizeSideNavigation' | i18n" - ></div> - </div> -} + 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" + > + <bit-nav-divider></bit-nav-divider> + @if (open) { + <ng-content select="[slot=footer]"></ng-content> + } + <div class="tw-mx-0.5 tw-my-4 tw-w-[3.75rem]"> + <button + #toggleButton + type="button" + class="tw-mx-auto tw-block tw-max-w-fit" + [bitIconButton]="open ? 'bwi-angle-left' : 'bwi-angle-right'" + buttonType="nav-contrast" + size="small" + (click)="sideNavService.toggle()" + [label]="'toggleSideNavigation' | i18n" + [attr.aria-expanded]="open" + aria-controls="bit-side-nav" + ></button> + </div> + </div> + </nav> + <div + cdkDrag + (cdkDragMoved)="onDragMoved($event)" + class="tw-absolute tw-top-0 -tw-right-0.5 tw-z-30 tw-h-full tw-w-1 tw-cursor-col-resize tw-transition-colors tw-duration-[250ms] hover:tw-ease-in-out hover:tw-delay-[250ms] hover:tw-bg-primary-300 focus:tw-outline-none focus-visible:tw-bg-primary-300 before:tw-content-[''] before:tw-absolute before:tw-block before:tw-inset-y-0 before:-tw-left-0.5 before:-tw-right-1" + [class.tw-hidden]="!open" + tabindex="0" + (keydown)="onKeydown($event)" + role="separator" + [attr.aria-valuenow]="sideNavService.width$ | async" + [attr.aria-valuemax]="sideNavService.MAX_OPEN_WIDTH" + [attr.aria-valuemin]="sideNavService.MIN_OPEN_WIDTH" + aria-orientation="vertical" + aria-controls="bit-side-nav" + [attr.aria-label]="'resizeSideNavigation' | i18n" + ></div> +</div> 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<SideNavVariant>("primary"); private readonly toggleButton = viewChild("toggleButton", { read: ElementRef }); private elementRef = inject<ElementRef<HTMLElement>>(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<boolean>(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<CollapsePreference>(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<CollapsePreference>(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 @@ <div class="tw-mx-auto tw-flex tw-flex-col tw-items-center tw-justify-center tw-pt-6"> <div class="tw-max-w-sm tw-flex tw-flex-col tw-items-center"> <div class="tw-size-24 tw-content-center"> - <bit-icon [icon]="icon()" aria-hidden="true"></bit-icon> + <bit-svg [content]="icon()" aria-hidden="true"></bit-svg> </div> <h3 class="tw-font-medium tw-text-center tw-mt-4"> <ng-content select="[slot=title]"></ng-content> 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/popover/popover-trigger-for.directive.spec.ts b/libs/components/src/popover/popover-trigger-for.directive.spec.ts new file mode 100644 index 00000000000..8a3bdc2cb8c --- /dev/null +++ b/libs/components/src/popover/popover-trigger-for.directive.spec.ts @@ -0,0 +1,446 @@ +import { Overlay, OverlayRef } from "@angular/cdk/overlay"; +import { ChangeDetectionStrategy, Component, NgZone, TemplateRef, viewChild } from "@angular/core"; +import { ComponentFixture, TestBed, fakeAsync, flush, tick } from "@angular/core/testing"; +import { Subject } from "rxjs"; + +import { PopoverTriggerForDirective } from "./popover-trigger-for.directive"; +import { PopoverComponent } from "./popover.component"; + +/** + * Test component to host the directive. + * + * Note: When testing RAF (requestAnimationFrame) behavior in fakeAsync tests: + * - tick() without arguments advances virtual time but does NOT execute RAF callbacks + * - tick(16) advances time by 16ms (typical animation frame duration) and DOES execute RAF callbacks + * - tick(0) flushes microtasks, useful for Angular effects that run synchronously + */ +@Component({ + standalone: true, + template: ` + <button + type="button" + [bitPopoverTriggerFor]="popoverComponent" + [(popoverOpen)]="isOpen" + #trigger="popoverTrigger" + > + Trigger + </button> + <bit-popover #popoverComponent></bit-popover> + `, + imports: [PopoverTriggerForDirective, PopoverComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class TestPopoverTriggerComponent { + isOpen = false; + readonly directive = viewChild("trigger", { read: PopoverTriggerForDirective }); + readonly popoverComponent = viewChild("popoverComponent", { read: PopoverComponent }); + readonly templateRef = viewChild("trigger", { read: TemplateRef }); +} + +describe("PopoverTriggerForDirective", () => { + let fixture: ComponentFixture<TestPopoverTriggerComponent>; + let component: TestPopoverTriggerComponent; + let directive: PopoverTriggerForDirective; + let overlayRef: Partial<OverlayRef>; + let overlay: Partial<Overlay>; + let ngZone: NgZone; + + beforeEach(async () => { + // Create mock overlay ref + overlayRef = { + backdropElement: document.createElement("div"), + attach: jest.fn(), + detach: jest.fn(), + dispose: jest.fn(), + detachments: jest.fn().mockReturnValue(new Subject()), + keydownEvents: jest.fn().mockReturnValue(new Subject()), + backdropClick: jest.fn().mockReturnValue(new Subject()), + }; + + // Create mock overlay + const mockPositionStrategy = { + flexibleConnectedTo: jest.fn().mockReturnThis(), + withPositions: jest.fn().mockReturnThis(), + withLockedPosition: jest.fn().mockReturnThis(), + withFlexibleDimensions: jest.fn().mockReturnThis(), + withPush: jest.fn().mockReturnThis(), + }; + + overlay = { + create: jest.fn().mockReturnValue(overlayRef), + position: jest.fn().mockReturnValue(mockPositionStrategy), + scrollStrategies: { + reposition: jest.fn().mockReturnValue({}), + } as any, + }; + + await TestBed.configureTestingModule({ + imports: [TestPopoverTriggerComponent], + providers: [{ provide: Overlay, useValue: overlay }], + }).compileComponents(); + + fixture = TestBed.createComponent(TestPopoverTriggerComponent); + component = fixture.componentInstance; + ngZone = TestBed.inject(NgZone); + fixture.detectChanges(); + directive = component.directive()!; + }); + + afterEach(() => { + fixture.destroy(); + }); + + describe("Initial popover open with RAF delay", () => { + it("should use double RAF delay on first open", fakeAsync(() => { + // Spy on requestAnimationFrame to verify it's being called + const rafSpy = jest.spyOn(window, "requestAnimationFrame"); + + // Set popoverOpen signal directly on the directive inside NgZone + ngZone.run(() => { + directive.popoverOpen.set(true); + fixture.detectChanges(); + }); + + // After effect execution, RAF should be scheduled but not executed yet + expect(overlay.create).not.toHaveBeenCalled(); + + // Execute first RAF - tick(16) advances time by one animation frame (16ms) + // This executes the first requestAnimationFrame callback + tick(16); + expect(overlay.create).not.toHaveBeenCalled(); + + // Execute second RAF - the nested requestAnimationFrame callback + tick(16); + expect(overlay.create).toHaveBeenCalled(); + expect(overlayRef.attach).toHaveBeenCalled(); + + rafSpy.mockRestore(); + flush(); + })); + + it("should skip RAF delay on subsequent opens", fakeAsync(() => { + // First open with double RAF delay + ngZone.run(() => { + directive.popoverOpen.set(true); + fixture.detectChanges(); + }); + // Execute both RAF callbacks (16ms each = 32ms total for first open) + tick(16); // First RAF + tick(16); // Second RAF + expect(overlay.create).toHaveBeenCalledTimes(1); + jest.mocked(overlay.create).mockClear(); + + // Close by clicking + const button = fixture.nativeElement.querySelector("button"); + button.click(); + fixture.detectChanges(); + + // Second open should skip RAF delay (hasInitialized is now true) + ngZone.run(() => { + directive.popoverOpen.set(true); + fixture.detectChanges(); + }); + // Only need tick(0) to flush microtasks - NO RAF delay on subsequent opens + tick(0); + expect(overlay.create).toHaveBeenCalledTimes(1); + + flush(); + })); + }); + + describe("Race condition prevention", () => { + it("should prevent multiple RAF scheduling when toggled rapidly", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Try to toggle back to false before RAF completes + ngZone.run(() => { + directive.popoverOpen.set(false); + }); + fixture.detectChanges(); + + // Try to toggle back to true + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Execute RAFs + tick(16); + tick(16); + + // Should only create overlay once + expect(overlay.create).toHaveBeenCalledTimes(1); + + flush(); + })); + + it("should not schedule new RAF if one is already pending", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Try to open again while RAF is pending (shouldn't schedule another) + ngZone.run(() => { + directive.popoverOpen.set(false); + }); + fixture.detectChanges(); + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + tick(16); + tick(16); + + // Should only have created one overlay + expect(overlay.create).toHaveBeenCalledTimes(1); + + flush(); + })); + + it("should prevent duplicate overlays from click handler during RAF", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Click to close before RAF completes - this should cancel the RAF and prevent overlay creation + const button = fixture.nativeElement.querySelector("button"); + button.click(); + fixture.detectChanges(); + + // Verify popoverOpen was set to false + expect(directive.popoverOpen()).toBe(false); + + tick(16); + tick(16); + + // Should NOT have created any overlay because RAF was canceled + expect(overlay.create).not.toHaveBeenCalled(); + + flush(); + })); + }); + + describe("Component destruction during RAF", () => { + it("should cancel RAF callbacks when component is destroyed", fakeAsync(() => { + const cancelAnimationFrameSpy = jest.spyOn(window, "cancelAnimationFrame"); + + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Destroy component before RAF completes + fixture.destroy(); + + // Should have cancelled animation frames + expect(cancelAnimationFrameSpy).toHaveBeenCalled(); + + cancelAnimationFrameSpy.mockRestore(); + + flush(); + })); + + it("should not create overlay if destroyed during RAF delay", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Execute first RAF + tick(16); + + // Destroy before second RAF + fixture.destroy(); + + // Execute second RAF (should be no-op) + tick(16); + + expect(overlay.create).not.toHaveBeenCalled(); + + flush(); + })); + + it("should set isDestroyed flag and prevent further operations", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + // Destroy the component + fixture.destroy(); + + // Try to toggle (should be blocked by isDestroyed check) + const button = fixture.nativeElement.querySelector("button"); + button.click(); + + expect(overlay.create).toHaveBeenCalledTimes(1); // Only from initial open + + flush(); + })); + }); + + describe("Click handling", () => { + it("should open popover on click when closed", fakeAsync(() => { + const button = fixture.nativeElement.querySelector("button"); + button.click(); + fixture.detectChanges(); + + expect(component.isOpen).toBe(true); + expect(overlay.create).toHaveBeenCalled(); + + flush(); + })); + + it("should close popover on click when open", fakeAsync(() => { + // Open first + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + // Click to close + const button = fixture.nativeElement.querySelector("button"); + button.click(); + fixture.detectChanges(); + + expect(component.isOpen).toBe(false); + expect(overlayRef.dispose).toHaveBeenCalled(); + + flush(); + })); + + it("should not process clicks after component is destroyed", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + const initialCreateCount = jest.mocked(overlay.create).mock.calls.length; + + fixture.destroy(); + + const button = fixture.nativeElement.querySelector("button"); + button.click(); + + // Should not have created additional overlay + expect(overlay.create).toHaveBeenCalledTimes(initialCreateCount); + + flush(); + })); + }); + + describe("Resource cleanup", () => { + it("should cancel both RAF IDs in disposeAll", fakeAsync(() => { + const cancelAnimationFrameSpy = jest.spyOn(window, "cancelAnimationFrame"); + + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Trigger disposal while RAF is pending + directive.ngOnDestroy(); + + // Should cancel animation frames + expect(cancelAnimationFrameSpy).toHaveBeenCalled(); + + cancelAnimationFrameSpy.mockRestore(); + + flush(); + })); + + it("should dispose overlay on destroy", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + expect(overlayRef.attach).toHaveBeenCalled(); + + fixture.destroy(); + + expect(overlayRef.dispose).toHaveBeenCalled(); + + flush(); + })); + + it("should unsubscribe from closed events on destroy", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + // Get the subscription (it's private, so we'll verify via disposal) + fixture.destroy(); + + // Should have disposed overlay which triggers cleanup + expect(overlayRef.dispose).toHaveBeenCalled(); + + flush(); + })); + }); + + describe("Overlay guard in openPopover", () => { + it("should not create duplicate overlay if overlayRef already exists", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + expect(overlay.create).toHaveBeenCalledTimes(1); + + // Try to open again + ngZone.run(() => { + directive.popoverOpen.set(false); + }); + fixture.detectChanges(); + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + expect(overlay.create).toHaveBeenCalledTimes(1); + + flush(); + })); + }); + + describe("aria-expanded attribute", () => { + it("should set aria-expanded to false when closed", () => { + const button = fixture.nativeElement.querySelector("button"); + expect(button.getAttribute("aria-expanded")).toBe("false"); + }); + + it("should set aria-expanded to true when open", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + const button = fixture.nativeElement.querySelector("button"); + expect(button.getAttribute("aria-expanded")).toBe("true"); + + flush(); + })); + }); +}); diff --git a/libs/components/src/popover/popover-trigger-for.directive.ts b/libs/components/src/popover/popover-trigger-for.directive.ts index cb114f1fbc3..176a736fb39 100644 --- a/libs/components/src/popover/popover-trigger-for.directive.ts +++ b/libs/components/src/popover/popover-trigger-for.directive.ts @@ -1,12 +1,12 @@ import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay"; import { TemplatePortal } from "@angular/cdk/portal"; import { - AfterViewInit, Directive, ElementRef, HostListener, OnDestroy, ViewContainerRef, + effect, input, model, } from "@angular/core"; @@ -22,7 +22,7 @@ import { PopoverComponent } from "./popover.component"; "[attr.aria-expanded]": "this.popoverOpen()", }, }) -export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { +export class PopoverTriggerForDirective implements OnDestroy { readonly popoverOpen = model(false); readonly popover = input.required<PopoverComponent>({ alias: "bitPopoverTriggerFor" }); @@ -31,6 +31,10 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { private overlayRef: OverlayRef | null = null; private closedEventsSub: Subscription | null = null; + private hasInitialized = false; + private rafId1: number | null = null; + private rafId2: number | null = null; + private isDestroyed = false; get positions() { if (!this.position()) { @@ -65,10 +69,44 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { private elementRef: ElementRef<HTMLElement>, private viewContainerRef: ViewContainerRef, private overlay: Overlay, - ) {} + ) { + effect(() => { + if (this.isDestroyed || !this.popoverOpen() || this.overlayRef) { + return; + } + + if (this.hasInitialized) { + this.openPopover(); + return; + } + + if (this.rafId1 !== null || this.rafId2 !== null) { + return; + } + + // Initial open - wait for layout to stabilize + // First RAF: Waits for Angular's change detection to complete and queues the next paint + this.rafId1 = requestAnimationFrame(() => { + // Second RAF: Ensures the browser has actually painted that frame and all layout/position calculations are final + this.rafId2 = requestAnimationFrame(() => { + if (this.isDestroyed || !this.popoverOpen() || this.overlayRef) { + return; + } + this.openPopover(); + this.hasInitialized = true; + this.rafId2 = null; + }); + this.rafId1 = null; + }); + }); + } @HostListener("click") togglePopover() { + if (this.isDestroyed) { + return; + } + if (this.popoverOpen()) { this.closePopover(); } else { @@ -77,6 +115,10 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { } private openPopover() { + if (this.overlayRef) { + return; + } + this.popoverOpen.set(true); this.overlayRef = this.overlay.create(this.defaultPopoverConfig); @@ -104,7 +146,7 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { } private destroyPopover() { - if (!this.overlayRef || !this.popoverOpen()) { + if (!this.popoverOpen()) { return; } @@ -117,15 +159,19 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { this.closedEventsSub = null; this.overlayRef?.dispose(); this.overlayRef = null; - } - ngAfterViewInit() { - if (this.popoverOpen()) { - this.openPopover(); + if (this.rafId1 !== null) { + cancelAnimationFrame(this.rafId1); + this.rafId1 = null; + } + if (this.rafId2 !== null) { + cancelAnimationFrame(this.rafId2); + this.rafId2 = null; } } ngOnDestroy() { + this.isDestroyed = true; this.disposeAll(); } diff --git a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts index 23c95cafb8a..e54064f0c9d 100644 --- a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts +++ b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts @@ -73,7 +73,6 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module"; A random password <button bitLink - linkType="primary" [bitPopoverTriggerFor]="myPopover" #triggerRef="popoverTrigger" type="button" diff --git a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts index 81b5e1b49c5..d935061d5e3 100644 --- a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts +++ b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts @@ -112,13 +112,12 @@ class KitchenSinkDialogComponent { <div class="tw-my-6"> <h1 bitTypography="h1">Bitwarden Kitchen Sink<bit-avatar text="Bit Warden"></bit-avatar></h1> - <a bitLink linkType="primary" href="#">This is a link</a> + <a bitLink href="#">This is a link</a> <p bitTypography="body1" class="tw-inline">  and this is a link button popover trigger:  </p> <button bitLink - linkType="primary" [bitPopoverTriggerFor]="myPopover" #triggerRef="popoverTrigger" type="button" 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/stories/kitchen-sink/kitchen-sink.stories.ts b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts index 08f4d875962..4c602995cd1 100644 --- a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts +++ b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts @@ -13,6 +13,7 @@ import { import { PasswordManagerLogo } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { GlobalStateProvider } from "@bitwarden/state"; import { LayoutComponent } from "../../layout"; @@ -71,6 +72,13 @@ export default { }); }, }, + { + provide: PlatformUtilsService, + useValue: { + // eslint-disable-next-line + copyToClipboard: (text: string) => console.log(`${text} copied to clipboard`), + }, + }, { provide: GlobalStateProvider, useClass: StorybookGlobalStateProvider, diff --git a/libs/components/src/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<BitSvg>(); + readonly ariaLabel = input<string>(); + + protected readonly innerHtml = computed<SafeHtml | null>(() => { + 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<BitIconComponent>; +describe("SvgComponent", () => { + let fixture: ComponentFixture<SvgComponent>; 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`<svg><text x="0" y="15">safe icon</text></svg>`; + const icon = svg`<svg><text x="0" y="15">safe icon</text></svg>`; - 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"; + +<Meta of={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 `<name>.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`<svg … </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` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#020F66"}}></span> | `tw-stroke-illustration-outline` | `tw-fill-illustration-outline` | `--color-illustration-outline` | + | `#DBE5F6` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#DBE5F6"}}></span> | `tw-stroke-illustration-bg-primary` | `tw-fill-illustration-bg-primary` | `--color-illustration-bg-primary` | + | `#AAC3EF` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#AAC3EF"}}></span> | `tw-stroke-illustration-bg-secondary` | `tw-fill-illustration-bg-secondary` | `--color-illustration-bg-secondary` | + | `#FFFFFF` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#FFFFFF"}}></span> | `tw-stroke-illustration-bg-tertiary` | `tw-fill-illustration-bg-tertiary` | `--color-illustration-bg-tertiary` | + | `#FFBF00` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#FFBF00"}}></span> | `tw-stroke-illustration-tertiary` | `tw-fill-illustration-tertiary` | `--color-illustration-tertiary` | + | `#175DDC` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#175DDC"}}></span> | `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 `<bit-svg>` component + + ```html + <bit-svg [content]="Icons.ExampleIcon"></bit-svg> + ``` + + With `ariaLabel` + + ```html + <bit-svg [content]="Icons.ExampleIcon" [ariaLabel]="Your custom label text here"></bit-svg> + ``` + +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*/ ` + <div class="tw-bg-secondary-100 tw-p-2 tw-grid tw-grid-cols-[repeat(auto-fit,minmax(224px,1fr))] tw-gap-2"> + @for (icon of icons; track icon[0]) { + <div class="tw-size-56 tw-border tw-border-secondary-300 tw-rounded-md"> + <div class="tw-text-xs tw-text-center">{{icon[0]}}</div> + <div class="tw-size-52 tw-w-full tw-content-center"> + <bit-svg [content]="icon[1]"></bit-svg> + </div> + </div> + } + </div> + `, + }), + args: { + icons: Object.entries(Icons), + }, +}; diff --git a/libs/components/src/tooltip/tooltip.directive.ts b/libs/components/src/tooltip/tooltip.directive.ts index cca52526c7d..a50a4d07e26 100644 --- a/libs/components/src/tooltip/tooltip.directive.ts +++ b/libs/components/src/tooltip/tooltip.directive.ts @@ -11,6 +11,7 @@ import { signal, model, computed, + OnDestroy, } from "@angular/core"; import { TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions"; @@ -27,12 +28,12 @@ export const TOOLTIP_DELAY_MS = 800; host: { "(mouseenter)": "showTooltip()", "(mouseleave)": "hideTooltip()", - "(focus)": "showTooltip()", - "(blur)": "hideTooltip()", + "(focusin)": "onFocusIn($event)", + "(focusout)": "onFocusOut()", "[attr.aria-describedby]": "resolvedDescribedByIds()", }, }) -export class TooltipDirective implements OnInit { +export class TooltipDirective implements OnInit, OnDestroy { private static nextId = 0; /** * The value of this input is forwarded to the tooltip.component to render @@ -51,6 +52,7 @@ export class TooltipDirective implements OnInit { private readonly isVisible = signal(false); private overlayRef: OverlayRef | undefined; + private showTimeoutId: ReturnType<typeof setTimeout> | undefined; private elementRef = inject<ElementRef<HTMLElement>>(ElementRef); private overlay = inject(Overlay); private viewContainerRef = inject(ViewContainerRef); @@ -81,13 +83,29 @@ export class TooltipDirective implements OnInit { }), ); + /** + * Clear any pending show timeout + * + * Use cases: prevent tooltip from appearing after hide; clear existing timeout before showing a + * new tooltip + */ + private clearTimeout() { + if (this.showTimeoutId !== undefined) { + clearTimeout(this.showTimeoutId); + this.showTimeoutId = undefined; + } + } + private destroyTooltip = () => { + this.clearTimeout(); this.overlayRef?.dispose(); this.overlayRef = undefined; this.isVisible.set(false); }; protected showTooltip = () => { + this.clearTimeout(); + if (!this.overlayRef) { this.overlayRef = this.overlay.create({ ...this.defaultPopoverConfig, @@ -97,8 +115,9 @@ export class TooltipDirective implements OnInit { this.overlayRef.attach(this.tooltipPortal); } - setTimeout(() => { + this.showTimeoutId = setTimeout(() => { this.isVisible.set(true); + this.showTimeoutId = undefined; }, TOOLTIP_DELAY_MS); }; @@ -106,6 +125,20 @@ export class TooltipDirective implements OnInit { this.destroyTooltip(); }; + /** + * Show tooltip on focus-visible (keyboard navigation) but not on regular focus (mouse click). + */ + protected onFocusIn(event: FocusEvent) { + const target = event.target as HTMLElement; + if (target.matches(":focus-visible")) { + this.showTooltip(); + } + } + + protected onFocusOut() { + this.hideTooltip(); + } + protected readonly resolvedDescribedByIds = computed(() => { if (this.addTooltipToDescribedby()) { if (this.currentDescribedByIds) { @@ -134,4 +167,8 @@ export class TooltipDirective implements OnInit { ngOnInit() { this.positionStrategy.withPositions(this.computePositions(this.tooltipPosition())); } + + ngOnDestroy(): void { + this.destroyTooltip(); + } } diff --git a/libs/components/src/tooltip/tooltip.spec.ts b/libs/components/src/tooltip/tooltip.spec.ts index ff73911d29d..0d73db2d015 100644 --- a/libs/components/src/tooltip/tooltip.spec.ts +++ b/libs/components/src/tooltip/tooltip.spec.ts @@ -41,6 +41,7 @@ interface OverlayLike { interface OverlayRefStub { attach: (portal: ComponentPortal<unknown>) => unknown; updatePosition: () => void; + dispose: () => void; } describe("TooltipDirective (visibility only)", () => { @@ -68,6 +69,7 @@ describe("TooltipDirective (visibility only)", () => { }, })), updatePosition: jest.fn(), + dispose: jest.fn(), }; const overlayMock: OverlayLike = { @@ -101,13 +103,22 @@ describe("TooltipDirective (visibility only)", () => { expect(isVisible()).toBe(true); })); - it("sets isVisible to true on focus", fakeAsync(() => { + it("sets isVisible to true on focus-visible", fakeAsync(() => { const button: HTMLButtonElement = fixture.debugElement.query(By.css("button")).nativeElement; const directive = getDirective(); const isVisible = (directive as unknown as { isVisible: () => boolean }).isVisible; - button.dispatchEvent(new Event("focus")); + // Mock matches to return true for :focus-visible (simulates keyboard navigation) + const originalMatches = button.matches.bind(button); + button.matches = jest.fn((selector: string) => { + if (selector === ":focus-visible") { + return true; + } + return originalMatches(selector); + }); + + button.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); tick(TOOLTIP_DELAY_MS); expect(isVisible()).toBe(true); })); diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css index 757859985d6..5aab0d8bec9 100644 --- a/libs/components/src/tw-theme.css +++ b/libs/components/src/tw-theme.css @@ -353,6 +353,19 @@ /* Focus Border */ --color-border-focus: var(--color-black); + + /**======================================== + * SIDENAV BACKGROUND COLORS (Light mode) + * ======================================== */ + --color-sidenav-background: var(--color-brand-800); + --color-sidenav-text: var(--color-white); + --color-sidenav-active-item: var(--color-brand-900); + --color-sidenav-item-hover: var(--color-brand-900); + + --color-admin-sidenav-background: var(--color-gray-100); + --color-admin-sidenav-text: var(--color-gray-900); + --color-admin-sidenav-active-item: var(--color-gray-300); + --color-admin-sidenav-item-hover: var(--color-gray-300); } .theme_light { @@ -542,6 +555,19 @@ /* Focus Border */ --color-border-focus: var(--color-white); + + /**======================================== + * SIDENAV BACKGROUND COLORS (Dark mode) + * ======================================== */ + --color-sidenav-background: var(--color-gray-800); + --color-sidenav-text: var(--color-white); + --color-sidenav-active-item: var(--color-gray-900); + --color-sidenav-item-hover: var(--color-gray-900); + + --color-admin-sidenav-background: var(--color-gray-800); + --color-admin-sidenav-text: var(--color-white); + --color-admin-sidenav-active-item: var(--color-gray-900); + --color-admin-sidenav-item-hover: var(--color-gray-900); } @layer components { diff --git a/libs/components/tailwind.config.base.js b/libs/components/tailwind.config.base.js index bd88f5471ff..5de00fac34f 100644 --- a/libs/components/tailwind.config.base.js +++ b/libs/components/tailwind.config.base.js @@ -72,11 +72,11 @@ module.exports = { code: rgba("--color-text-code"), }, background: { - DEFAULT: rgba("--color-background"), - alt: rgba("--color-background-alt"), - alt2: rgba("--color-background-alt2"), - alt3: rgba("--color-background-alt3"), - alt4: rgba("--color-background-alt4"), + DEFAULT: "var(--color-bg-primary)", + alt: "var(--color-bg-tertiary)", + alt2: "var(--color-bg-brand)", + alt3: "var(--color-bg-brand-strong)", + alt4: "var(--color-brand-950)", }, bg: { white: "var(--color-bg-white)", @@ -117,6 +117,9 @@ module.exports = { "accent-tertiary": "var(--color-bg-accent-tertiary)", hover: "var(--color-bg-hover)", overlay: "var(--color-bg-overlay)", + sidenav: "var(--color-sidenav-background)", + "sidenav-active-item": "var(--color-sidenav-active-item)", + "sidenav-item-hover": "var(--color-sidenav-item-hover)", }, hover: { default: "var(--color-hover-default)", @@ -159,6 +162,7 @@ module.exports = { "accent-tertiary-soft": "var(--color-fg-accent-tertiary-soft)", "accent-tertiary": "var(--color-fg-accent-tertiary)", "accent-tertiary-strong": "var(--color-fg-accent-tertiary-strong)", + "sidenav-text": "var(--color-sidenav-text)", }, border: { muted: "var(--color-border-muted)", @@ -253,6 +257,7 @@ module.exports = { "fg-accent-tertiary-soft": "var(--color-fg-accent-tertiary-soft)", "fg-accent-tertiary": "var(--color-fg-accent-tertiary)", "fg-accent-tertiary-strong": "var(--color-fg-accent-tertiary-strong)", + "fg-sidenav-text": "var(--color-sidenav-text)", }), borderColor: ({ theme }) => ({ ...theme("colors"), @@ -312,6 +317,7 @@ module.exports = { base: ["1rem", "150%"], sm: ["0.875rem", "150%"], xs: [".75rem", "150%"], + xxs: [".5rem", "150%"], }, container: { "@5xl": "1100px", 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..6f856646a07 --- /dev/null +++ b/libs/eslint/components/no-bwi-class-usage.mjs @@ -0,0 +1,71 @@ +export const errorMessage = + "Use <bit-icon> component instead of applying 'bwi' classes directly. Example: <bit-icon name=\"bwi-lock\"></bit-icon>"; + +// Helper classes from libs/angular/src/scss/bwicons/styles/style.scss +// These are utility classes that can be used independently +const ALLOWED_BWI_HELPER_CLASSES = new Set([ + "bwi-fw", // Fixed width + "bwi-sm", // Small + "bwi-lg", // Large + "bwi-2x", // 2x size + "bwi-3x", // 3x size + "bwi-4x", // 4x size + "bwi-spin", // Spin animation + "bwi-ul", // List + "bwi-li", // List item + "bwi-rotate-270", // Rotation +]); + +export default { + meta: { + type: "suggestion", + docs: { + description: + "Discourage using 'bwi' font icon classes directly in favor of the <bit-icon> 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 || ""; + + if (typeof classValue !== "string") { + continue; + } + + // Extract all bwi classes from the class string + const bwiClassMatches = classValue.match(/\bbwi(?:-[\w-]+)?\b/g); + + if (!bwiClassMatches) { + continue; + } + + // Check if any bwi class is NOT in the allowed helper classes list + const hasDisallowedBwiClass = bwiClassMatches.some( + (cls) => !ALLOWED_BWI_HELPER_CLASSES.has(cls), + ); + + if (hasDisallowedBwiClass) { + 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..768081ac966 --- /dev/null +++ b/libs/eslint/components/no-bwi-class-usage.spec.mjs @@ -0,0 +1,81 @@ +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: `<bit-icon icon="bwi-lock"></bit-icon>`, + }, + { + name: "should allow bit-icon with bwi-fw helper class", + code: `<bit-icon icon="bwi-lock" class="bwi-fw"></bit-icon>`, + }, + { + name: "should allow bit-icon with name attribute and bwi-fw helper class", + code: `<bit-icon name="bwi-angle-down" class="bwi-fw"/>`, + }, + { + name: "should allow elements without bwi classes", + code: `<div class="tw-flex tw-p-4"></div>`, + }, + { + name: "should allow bwi-fw helper class alone", + code: `<i class="bwi-fw"></i>`, + }, + { + name: "should allow bwi-sm helper class", + code: `<i class="bwi-sm"></i>`, + }, + { + name: "should allow multiple helper classes together", + code: `<i class="bwi-fw bwi-sm"></i>`, + }, + { + name: "should allow helper classes with other non-bwi classes", + code: `<i class="tw-flex bwi-fw bwi-lg tw-p-2"></i>`, + }, + { + name: "should allow bwi-spin helper class", + code: `<i class="bwi-spin"></i>`, + }, + { + name: "should allow bwi-rotate-270 helper class", + code: `<i class="bwi-rotate-270"></i>`, + }, + ], + invalid: [ + { + name: "should error on direct bwi class usage", + code: `<i class="bwi bwi-lock"></i>`, + errors: [{ message: errorMessage }], + }, + { + name: "should error on bwi class with other classes", + code: `<i class="tw-flex bwi bwi-lock tw-p-2"></i>`, + errors: [{ message: errorMessage }], + }, + { + name: "should error on single bwi-* icon class", + code: `<i class="bwi-lock"></i>`, + errors: [{ message: errorMessage }], + }, + { + name: "should error on icon classes even with helper classes", + code: `<i class="bwi bwi-lock bwi-fw"></i>`, + errors: [{ message: errorMessage }], + }, + { + name: "should error on base bwi class alone", + code: `<i class="bwi"></i>`, + 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`<svg><path fill="none"/></svg>`;', + code: 'const icon = svg`<svg><path fill="none"/></svg>`;', }, { name: "Allows CSS variable", - code: 'const icon = svgIcon`<svg><path fill="var(--my-color)"/></svg>`;', + code: 'const icon = svg`<svg><path fill="var(--my-color)"/></svg>`;', }, { name: "Allows class-based coloring", - code: 'const icon = svgIcon`<svg><path class="tw-fill-art-primary"/></svg>`;', + code: 'const icon = svg`<svg><path class="tw-fill-art-primary"/></svg>`;', }, ], invalid: [ { name: "Errors on fill with hex color", - code: 'const icon = svgIcon`<svg><path fill="#000000"/></svg>`;', + code: 'const icon = svg`<svg><path fill="#000000"/></svg>`;', errors: [{ messageId: "hardcodedColor", data: { color: "#000000" } }], }, { name: "Errors on stroke with named color", - code: 'const icon = svgIcon`<svg><path stroke="red"/></svg>`;', + code: 'const icon = svg`<svg><path stroke="red"/></svg>`;', errors: [{ messageId: "hardcodedColor", data: { color: "red" } }], }, { name: "Errors on fill with rgb()", - code: 'const icon = svgIcon`<svg><path fill="rgb(255,0,0)"/></svg>`;', + code: 'const icon = svg`<svg><path fill="rgb(255,0,0)"/></svg>`;', errors: [{ messageId: "hardcodedColor", data: { color: "rgb(255,0,0)" } }], }, { name: "Errors on fill with named color", - code: 'const icon = svgIcon`<svg><path fill="blue"/></svg>`;', + code: 'const icon = svg`<svg><path fill="blue"/></svg>`;', errors: [{ messageId: "hardcodedColor", data: { color: "blue" } }], }, ], diff --git a/libs/importer/src/components/chrome/import-chrome.component.ts b/libs/importer/src/components/chrome/import-chrome.component.ts index 5467b08ee61..420a95bca5e 100644 --- a/libs/importer/src/components/chrome/import-chrome.component.ts +++ b/libs/importer/src/components/chrome/import-chrome.component.ts @@ -195,6 +195,8 @@ export class ImportChromeComponent implements OnInit, OnDestroy { return "Brave"; } else if (format === "vivaldicsv") { return "Vivaldi"; + } else if (format === "arccsv") { + return "Arc"; } return "Chrome"; } diff --git a/libs/importer/src/components/import.component.ts b/libs/importer/src/components/import.component.ts index 0ff62b00e78..86f5d765d31 100644 --- a/libs/importer/src/components/import.component.ts +++ b/libs/importer/src/components/import.component.ts @@ -29,15 +29,15 @@ import { combineLatestWith, filter, map, switchMap, takeUntil } from "rxjs/opera // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { - CollectionService, - CollectionTypes, - CollectionView, -} from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { + CollectionView, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -354,7 +354,7 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { switchMap((userId) => { return this.folderService.folderViews$(userId); }), - map((folders) => folders.filter((f) => f.id != null)), + map((folders) => folders.filter((f) => !!f.id)), ); this.formGroup.controls.targetSelector.disable(); diff --git a/libs/importer/src/importers/arc-csv-importer.spec.ts b/libs/importer/src/importers/arc-csv-importer.spec.ts new file mode 100644 index 00000000000..3ecd3de02bf --- /dev/null +++ b/libs/importer/src/importers/arc-csv-importer.spec.ts @@ -0,0 +1,139 @@ +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; +import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; + +import { ArcCsvImporter } from "./arc-csv-importer"; +import { data as missingNameAndUrlData } from "./spec-data/arc-csv/missing-name-and-url-data.csv"; +import { data as missingNameWithUrlData } from "./spec-data/arc-csv/missing-name-with-url-data.csv"; +import { data as passwordWithNoteData } from "./spec-data/arc-csv/password-with-note-data.csv"; +import { data as simplePasswordData } from "./spec-data/arc-csv/simple-password-data.csv"; +import { data as subdomainData } from "./spec-data/arc-csv/subdomain-data.csv"; +import { data as urlWithWwwData } from "./spec-data/arc-csv/url-with-www-data.csv"; + +const CipherData = [ + { + title: "should parse password", + csv: simplePasswordData, + expected: Object.assign(new CipherView(), { + name: "example.com", + login: Object.assign(new LoginView(), { + username: "user@example.com", + password: "password123", + uris: [ + Object.assign(new LoginUriView(), { + uri: "https://example.com/", + }), + ], + }), + notes: null, + type: 1, + }), + }, + { + title: "should parse password with note", + csv: passwordWithNoteData, + expected: Object.assign(new CipherView(), { + name: "example.com", + login: Object.assign(new LoginView(), { + username: "user@example.com", + password: "password123", + uris: [ + Object.assign(new LoginUriView(), { + uri: "https://example.com/", + }), + ], + }), + notes: "This is a test note", + type: 1, + }), + }, + { + title: "should strip www. prefix from name", + csv: urlWithWwwData, + expected: Object.assign(new CipherView(), { + name: "example.com", + login: Object.assign(new LoginView(), { + username: "user@example.com", + password: "password123", + uris: [ + Object.assign(new LoginUriView(), { + uri: "https://www.example.com/", + }), + ], + }), + notes: null, + type: 1, + }), + }, + { + title: "should extract name from URL when name is missing", + csv: missingNameWithUrlData, + expected: Object.assign(new CipherView(), { + name: "example.com", + login: Object.assign(new LoginView(), { + username: "user@example.com", + password: "password123", + uris: [ + Object.assign(new LoginUriView(), { + uri: "https://example.com/login", + }), + ], + }), + notes: null, + type: 1, + }), + }, + { + title: "should use -- as name when both name and URL are missing", + csv: missingNameAndUrlData, + expected: Object.assign(new CipherView(), { + name: "--", + login: Object.assign(new LoginView(), { + username: null, + password: "password123", + uris: null, + }), + notes: null, + type: 1, + }), + }, + { + title: "should preserve subdomain in name", + csv: subdomainData, + expected: Object.assign(new CipherView(), { + name: "login.example.com", + login: Object.assign(new LoginView(), { + username: "user@example.com", + password: "password123", + uris: [ + Object.assign(new LoginUriView(), { + uri: "https://login.example.com/auth", + }), + ], + }), + notes: null, + type: 1, + }), + }, +]; + +describe("Arc CSV Importer", () => { + CipherData.forEach((data) => { + it(data.title, async () => { + jest.useFakeTimers().setSystemTime(data.expected.creationDate); + const importer = new ArcCsvImporter(); + const result = await importer.parse(data.csv); + expect(result != null).toBe(true); + expect(result.ciphers.length).toBeGreaterThan(0); + + const cipher = result.ciphers.shift(); + let property: keyof typeof data.expected; + for (property in data.expected) { + if (Object.prototype.hasOwnProperty.call(data.expected, property)) { + expect(Object.prototype.hasOwnProperty.call(cipher, property)).toBe(true); + expect(cipher[property]).toEqual(data.expected[property]); + } + } + }); + }); +}); diff --git a/libs/importer/src/importers/arc-csv-importer.ts b/libs/importer/src/importers/arc-csv-importer.ts new file mode 100644 index 00000000000..eb262717009 --- /dev/null +++ b/libs/importer/src/importers/arc-csv-importer.ts @@ -0,0 +1,30 @@ +import { ImportResult } from "../models/import-result"; + +import { BaseImporter } from "./base-importer"; +import { Importer } from "./importer"; + +export class ArcCsvImporter extends BaseImporter implements Importer { + parse(data: string): Promise<ImportResult> { + const result = new ImportResult(); + const results = this.parseCsv(data, true); + if (results == null) { + result.success = false; + return Promise.resolve(result); + } + + results.forEach((value) => { + const cipher = this.initLoginCipher(); + const url = this.getValueOrDefault(value.url); + cipher.name = this.getValueOrDefault(this.nameFromUrl(url) ?? "", "--"); + cipher.login.username = this.getValueOrDefault(value.username); + cipher.login.password = this.getValueOrDefault(value.password); + cipher.login.uris = this.makeUriArray(value.url); + cipher.notes = this.getValueOrDefault(value.note); + this.cleanupCipher(cipher); + result.ciphers.push(cipher); + }); + + result.success = true; + return Promise.resolve(result); + } +} diff --git a/libs/importer/src/importers/base-importer.ts b/libs/importer/src/importers/base-importer.ts index 7196a83783a..9c617971f8f 100644 --- a/libs/importer/src/importers/base-importer.ts +++ b/libs/importer/src/importers/base-importer.ts @@ -2,14 +2,12 @@ // @ts-strict-ignore import * as papa from "papaparse"; -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { Collection, CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { FieldType, SecureNoteType, CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; @@ -279,8 +277,7 @@ export abstract class BaseImporter { const collection = new CollectionView({ name: f.name, organizationId: this.organizationId, - // FIXME: Folder.id may be null, this should be changed when refactoring Folders to be ts-strict - id: Collection.isCollectionId(f.id) ? f.id : null, + id: f.id && f.id !== "" ? (f.id as CollectionId) : null, }); return collection; }); 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/bitwarden/bitwarden-encrypted-json-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-encrypted-json-importer.ts index 4771f47b4c9..5d165d9a76d 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-encrypted-json-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-encrypted-json-importer.ts @@ -2,9 +2,7 @@ // @ts-strict-ignore import { filter, firstValueFrom } from "rxjs"; -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { Collection } from "@bitwarden/admin-console/common"; +import { Collection } from "@bitwarden/common/admin-console/models/collections"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; diff --git a/libs/importer/src/importers/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<ImportResult> { @@ -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/index.ts b/libs/importer/src/importers/index.ts index a1e2c46868f..8f9eb923180 100644 --- a/libs/importer/src/importers/index.ts +++ b/libs/importer/src/importers/index.ts @@ -1,3 +1,4 @@ +export { ArcCsvImporter } from "./arc-csv-importer"; export { AscendoCsvImporter } from "./ascendo-csv-importer"; export { AvastCsvImporter, AvastJsonImporter } from "./avast"; export { AviraCsvImporter } from "./avira-csv-importer"; diff --git a/libs/importer/src/importers/keepass2-xml-importer.spec.ts b/libs/importer/src/importers/keepass2-xml-importer.spec.ts index 8fbb021883c..e934a442ff5 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", () => { @@ -21,7 +23,7 @@ describe("KeePass2 Xml Importer", () => { const actual = [folder]; const result = await importer.parse(TestData); - expect(result.folders).toEqual(actual); + expect(result.folders[0].name).toEqual(actual[0].name); }); it("parse XML should contains login details", async () => { @@ -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/onepassword/onepassword-1pux-importer.spec.ts b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.spec.ts index 4ec20ba2a87..8dbcf29fd2f 100644 --- a/libs/importer/src/importers/onepassword/onepassword-1pux-importer.spec.ts +++ b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.spec.ts @@ -2,6 +2,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { FieldType, SecureNoteType, CipherType } from "@bitwarden/common/vault/enums"; import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; +import * as sdkInternal from "@bitwarden/sdk-internal"; import { APICredentialsData } from "../spec-data/onepassword-1pux/api-credentials"; import { BankAccountData } from "../spec-data/onepassword-1pux/bank-account"; @@ -25,11 +26,14 @@ import { SanitizedExport } from "../spec-data/onepassword-1pux/sanitized-export" import { SecureNoteData } from "../spec-data/onepassword-1pux/secure-note"; import { ServerData } from "../spec-data/onepassword-1pux/server"; import { SoftwareLicenseData } from "../spec-data/onepassword-1pux/software-license"; +import { SSH_KeyData } from "../spec-data/onepassword-1pux/ssh-key"; import { SSNData } from "../spec-data/onepassword-1pux/ssn"; import { WirelessRouterData } from "../spec-data/onepassword-1pux/wireless-router"; import { OnePassword1PuxImporter } from "./onepassword-1pux-importer"; +jest.mock("@bitwarden/sdk-internal"); + function validateCustomField(fields: FieldView[], fieldName: string, expectedValue: any) { expect(fields).toBeDefined(); const customField = fields.find((f) => f.name === fieldName); @@ -669,6 +673,37 @@ describe("1Password 1Pux Importer", () => { validateCustomField(cipher.fields, "medication notes", "multiple times a day"); }); + it("should parse category 114 - SSH Key", async () => { + // Mock the SDK import_ssh_key function to return converted OpenSSH format + const mockConvertedKey = { + privateKey: + "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACCWsp3FFVVCMGZ23hscRkDPfGzKZ8z1V/ZB9nzbdDFRswAAAJh8F3bYfBd2\n2AAAAAtzc2gtZWQyNTUxOQAAACCWsp3FFVVCMGZ23hscRkDPfGzKZ8z1V/ZB9nzbdDFRsw\nAAAEA59QYE22f+VFHhiyH1Vfqiwz7xLEt1zCuk8M8Ng5LpKpayncUVVUKwZ3beGxxGQM98\nbMpnzPVX9kH2fNt0MVGzAAAAE3Rlc3RAZXhhbXBsZS5jb20BAgMEBQ==\n-----END OPENSSH PRIVATE KEY-----\n", + publicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJayncUVVUKwZ3beGxxGQM98bMpnzPVX9kH2fNt0MVGz", + fingerprint: "SHA256:/9qSxXuic8kaVBhwv3c8PuetiEpaOgIp7xHNCbcSuN8", + } as sdkInternal.SshKeyView; + + jest.spyOn(sdkInternal, "import_ssh_key").mockReturnValue(mockConvertedKey); + + const importer = new OnePassword1PuxImporter(); + const jsonString = JSON.stringify(SSH_KeyData); + const result = await importer.parse(jsonString); + expect(result != null).toBe(true); + const cipher = result.ciphers.shift(); + expect(cipher.type).toEqual(CipherType.SshKey); + expect(cipher.name).toEqual("Some SSH Key"); + expect(cipher.notes).toEqual("SSH Key Note"); + + // Verify that import_ssh_key was called with the PKCS#8 key from 1Password + expect(sdkInternal.import_ssh_key).toHaveBeenCalledWith( + "-----BEGIN PRIVATE KEY-----\nMFECAQEwBQYDK2VwBCIEIDn1BgTbZ/5UUeGLIfVV+qLBOvEsS3XMK6Twzw2Dkukq\ngSEAlrKdxRVVQrBndt4bHEZAz3xsymfM9Vf2QfZ823QxUbM=\n-----END PRIVATE KEY-----\n", + ); + + // Verify the key was converted to OpenSSH format + expect(cipher.sshKey.privateKey).toEqual(mockConvertedKey.privateKey); + expect(cipher.sshKey.publicKey).toEqual(mockConvertedKey.publicKey); + expect(cipher.sshKey.keyFingerprint).toEqual(mockConvertedKey.fingerprint); + }); + it("should create folders", async () => { const importer = new OnePassword1PuxImporter(); const result = await importer.parse(SanitizedExportJson); diff --git a/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts index 4571a6957c4..48de18bc54b 100644 --- a/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts +++ b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts @@ -8,6 +8,8 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view" import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view"; import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view"; +import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view"; +import { import_ssh_key } from "@bitwarden/sdk-internal"; import { ImportResult } from "../../models/import-result"; import { BaseImporter } from "../base-importer"; @@ -80,6 +82,10 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { cipher.type = CipherType.Identity; cipher.identity = new IdentityView(); break; + case Category.SSH_Key: + cipher.type = CipherType.SshKey; + cipher.sshKey = new SshKeyView(); + break; default: break; } @@ -316,6 +322,19 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { default: break; } + } else if (cipher.type === CipherType.SshKey) { + if (valueKey === "sshKey") { + // Use sshKey.metadata.privateKey instead of the sshKey.privateKey field. + // The sshKey.privateKey field doesn't have a consistent format for every item. + const { privateKey } = field.value.sshKey.metadata; + // Convert SSH key from PKCS#8 (1Password format) to OpenSSH format using SDK + // Note: 1Password does not store password-protected SSH keys, so no password handling needed for now + const parsedKey = import_ssh_key(privateKey); + cipher.sshKey.privateKey = parsedKey.privateKey; + cipher.sshKey.publicKey = parsedKey.publicKey; + cipher.sshKey.keyFingerprint = parsedKey.fingerprint; + return; + } } if (valueKey === "email") { diff --git a/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts b/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts index 43f3bc4f7d6..a24c6489c24 100644 --- a/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts +++ b/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts @@ -49,6 +49,7 @@ export const Category = Object.freeze({ EmailAccount: "111", API_Credential: "112", MedicalRecord: "113", + SSH_Key: "114", } as const); /** @@ -133,6 +134,7 @@ export interface Value { creditCardType?: string | null; creditCardNumber?: string | null; reference?: string | null; + sshKey?: SSHKey | null; } export interface Email { @@ -147,6 +149,19 @@ export interface Address { zip: string; state: string; } + +export interface SSHKey { + privateKey: string; + metadata: SSHKeyMetadata; +} + +export interface SSHKeyMetadata { + privateKey: string; + publicKey: string; + fingerprint: string; + keyType: string; +} + export interface InputTraits { keyboard: string; correction: string; diff --git a/libs/importer/src/importers/padlock-csv-importer.ts b/libs/importer/src/importers/padlock-csv-importer.ts index ec781170c4d..4554cd3a9be 100644 --- a/libs/importer/src/importers/padlock-csv-importer.ts +++ b/libs/importer/src/importers/padlock-csv-importer.ts @@ -1,8 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { ImportResult } from "../models/import-result"; diff --git a/libs/importer/src/importers/passpack-csv-importer.ts b/libs/importer/src/importers/passpack-csv-importer.ts index 09ff841b8a4..2cc7135f046 100644 --- a/libs/importer/src/importers/passpack-csv-importer.ts +++ b/libs/importer/src/importers/passpack-csv-importer.ts @@ -1,6 +1,4 @@ -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { ImportResult } from "../models/import-result"; diff --git a/libs/importer/src/importers/password-depot/password-depot-17-xml-importer.spec.ts b/libs/importer/src/importers/password-depot/password-depot-17-xml-importer.spec.ts index 1f14d05c51e..121c1b5dd66 100644 --- a/libs/importer/src/importers/password-depot/password-depot-17-xml-importer.spec.ts +++ b/libs/importer/src/importers/password-depot/password-depot-17-xml-importer.spec.ts @@ -1,6 +1,4 @@ -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { FieldType, SecureNoteType } from "@bitwarden/common/vault/enums"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; @@ -59,10 +57,15 @@ describe("Password Depot 17 Xml Importer", () => { const importer = new PasswordDepot17XmlImporter(); const folder = new FolderView(); folder.name = "tempDB"; - const actual = [folder]; const result = await importer.parse(PasswordTestData); - expect(result.folders).toEqual(actual); + expect(result.folders).toEqual([ + expect.objectContaining({ + id: "", + name: "tempDB", + revisionDate: expect.any(Date), + }), + ]); }); it("should parse password type into logins", async () => { 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/arc-csv/missing-name-and-url-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/missing-name-and-url-data.csv.ts new file mode 100644 index 00000000000..74170cb57bf --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/missing-name-and-url-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +,,,password123,`; diff --git a/libs/importer/src/importers/spec-data/arc-csv/missing-name-with-url-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/missing-name-with-url-data.csv.ts new file mode 100644 index 00000000000..f0271009099 --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/missing-name-with-url-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +,https://example.com/login,user@example.com,password123,`; diff --git a/libs/importer/src/importers/spec-data/arc-csv/password-with-note-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/password-with-note-data.csv.ts new file mode 100644 index 00000000000..bf9d218f01b --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/password-with-note-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +example.com,https://example.com/,user@example.com,password123,This is a test note`; diff --git a/libs/importer/src/importers/spec-data/arc-csv/simple-password-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/simple-password-data.csv.ts new file mode 100644 index 00000000000..695b9d10785 --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/simple-password-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +example.com,https://example.com/,user@example.com,password123,`; diff --git a/libs/importer/src/importers/spec-data/arc-csv/subdomain-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/subdomain-data.csv.ts new file mode 100644 index 00000000000..bde4c3282af --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/subdomain-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +login.example.com,https://login.example.com/auth,user@example.com,password123,`; diff --git a/libs/importer/src/importers/spec-data/arc-csv/url-with-www-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/url-with-www-data.csv.ts new file mode 100644 index 00000000000..17bb1f587e6 --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/url-with-www-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +www.example.com,https://www.example.com/,user@example.com,password123,`; 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</Value> </Group> <DeletedObjects /> </KeePassFile>`; +export const TestDataWithProtectedFields = `<?xml version="1.0" encoding="utf-8" standalone="yes"?> +<KeePassFile> + <Root> + <Group> + <UUID>KvS57lVwl13AfGFLwkvq4Q==</UUID> + <Name>Root</Name> + <Entry> + <UUID>fAa543oYlgnJKkhKag5HLw==</UUID> + <String> + <Key>Title</Key> + <Value>Test Entry</Value> + </String> + <String> + <Key>UserName</Key> + <Value>testuser</Value> + </String> + <String> + <Key>Password</Key> + <Value ProtectInMemory="True">testpass</Value> + </String> + <String> + <Key>URL</Key> + <Value>https://example.com</Value> + </String> + <String> + <Key>Notes</Key> + <Value>Regular notes</Value> + </String> + <String> + <Key>SAFE UN-LOCKING instructions</Key> + <Value ProtectInMemory="True">Secret instructions here</Value> + </String> + <String> + <Key>CustomField</Key> + <Value>Custom value</Value> + </String> + <String> + <Key>LongProtectedField</Key> + <Value ProtectInMemory="True">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.</Value> + </String> + <String> + <Key>MultilineProtectedField</Key> + <Value ProtectInMemory="True">Line 1 +Line 2 +Line 3</Value> + </String> + </Entry> + </Group> + </Root> +</KeePassFile>`; + export const TestData2 = `<?xml version="1.0" encoding="utf-8" standalone="yes"?> <Meta> <Generator>KeePass</Generator> diff --git a/libs/importer/src/importers/spec-data/onepassword-1pux/ssh-key.ts b/libs/importer/src/importers/spec-data/onepassword-1pux/ssh-key.ts new file mode 100644 index 00000000000..3e9cde46271 --- /dev/null +++ b/libs/importer/src/importers/spec-data/onepassword-1pux/ssh-key.ts @@ -0,0 +1,83 @@ +import { ExportData } from "../../onepassword/types/onepassword-1pux-importer-types"; + +export const SSH_KeyData: ExportData = { + accounts: [ + { + attrs: { + accountName: "1Password Customer", + name: "1Password Customer", + avatar: "", + email: "username123123123@gmail.com", + uuid: "TRIZ3XV4JJFRXJ3BARILLTUA6E", + domain: "https://my.1password.com/", + }, + vaults: [ + { + attrs: { + uuid: "pqcgbqjxr4tng2hsqt5ffrgwju", + desc: "Just test entries", + avatar: "ke7i5rxnjrh3tj6uesstcosspu.png", + name: "T's Test Vault", + type: "U", + }, + items: [ + { + uuid: "kf7wevmfiqmbgyao42plvgrasy", + favIndex: 0, + createdAt: 1724868152, + updatedAt: 1724868152, + state: "active", + categoryUuid: "114", + details: { + loginFields: [], + notesPlain: "SSH Key Note", + sections: [ + { + title: "SSH Key Section", + fields: [ + { + title: "private key", + id: "private_key", + value: { + sshKey: { + privateKey: + "-----BEGIN PRIVATE KEY-----\nMFECAQEwBQYDK2VwBCIEIDn1BgTbZ/5UUeGLIfVV+qLBOvEsS3XMK6Twzw2Dkukq\ngSEAlrKdxRVVQrBndt4bHEZAz3xsymfM9Vf2QfZ823QxUbM=\n-----END PRIVATE KEY-----\n", + metadata: { + privateKey: + "-----BEGIN PRIVATE KEY-----\nMFECAQEwBQYDK2VwBCIEIDn1BgTbZ/5UUeGLIfVV+qLBOvEsS3XMK6Twzw2Dkukq\ngSEAlrKdxRVVQrBndt4bHEZAz3xsymfM9Vf2QfZ823QxUbM=\n-----END PRIVATE KEY-----\n", + publicKey: + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJayncUVVUKwZ3beGxxGQM98bMpnzPVX9kH2fNt0MVGz", + fingerprint: "SHA256:/9qSxXuic8kaVBhwv3c8PuetiEpaOgIp7xHNCbcSuN8", + keyType: "ed25519", + }, + }, + }, + guarded: true, + multiline: false, + dontGenerate: false, + inputTraits: { + keyboard: "default", + correction: "default", + capitalization: "default", + }, + }, + ], + hideAddAnotherField: true, + }, + ], + passwordHistory: [], + }, + overview: { + subtitle: "SHA256:/9qSxXuic8kaVBhwv3c8PuetiEpaOgIp7xHNCbcSuN8", + icons: null, + title: "Some SSH Key", + url: "", + watchtowerExclusions: null, + }, + }, + ], + }, + ], + }, + ], +}; diff --git a/libs/importer/src/models/import-options.ts b/libs/importer/src/models/import-options.ts index 22a4f63b248..a4c77483be6 100644 --- a/libs/importer/src/models/import-options.ts +++ b/libs/importer/src/models/import-options.ts @@ -46,6 +46,7 @@ export const regularImportOptions = [ { id: "ascendocsv", name: "Ascendo DataVault (csv)" }, { id: "meldiumcsv", name: "Meldium (csv)" }, { id: "passkeepcsv", name: "PassKeep (csv)" }, + { id: "arccsv", name: "Arc" }, { id: "edgecsv", name: "Edge" }, { id: "operacsv", name: "Opera" }, { id: "vivaldicsv", name: "Vivaldi" }, diff --git a/libs/importer/src/models/import-result.ts b/libs/importer/src/models/import-result.ts index b99068ff83f..cefc80be61d 100644 --- a/libs/importer/src/models/import-result.ts +++ b/libs/importer/src/models/import-result.ts @@ -1,8 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; diff --git a/libs/importer/src/services/import-collection.service.abstraction.ts b/libs/importer/src/services/import-collection.service.abstraction.ts index f74d556b897..1dae6411ac8 100644 --- a/libs/importer/src/services/import-collection.service.abstraction.ts +++ b/libs/importer/src/services/import-collection.service.abstraction.ts @@ -1,8 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { UserId } from "@bitwarden/user-core"; export abstract class ImportCollectionServiceAbstraction { diff --git a/libs/importer/src/services/import.service.abstraction.ts b/libs/importer/src/services/import.service.abstraction.ts index d8f1f6ccd5c..51867212689 100644 --- a/libs/importer/src/services/import.service.abstraction.ts +++ b/libs/importer/src/services/import.service.abstraction.ts @@ -1,9 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore - -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { Importer } from "../importers/importer"; diff --git a/libs/importer/src/services/import.service.spec.ts b/libs/importer/src/services/import.service.spec.ts index b82772669de..33a1e47a4ce 100644 --- a/libs/importer/src/services/import.service.spec.ts +++ b/libs/importer/src/services/import.service.spec.ts @@ -2,11 +2,11 @@ import { mock, MockProxy } from "jest-mock-extended"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports +import { CollectionService } from "@bitwarden/admin-console/common"; import { - CollectionService, - CollectionTypes, CollectionView, -} from "@bitwarden/admin-console/common"; + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index 829bd04e994..38c399eb200 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -4,12 +4,11 @@ import { firstValueFrom, map } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports +import { CollectionService, CollectionWithIdRequest } from "@bitwarden/admin-console/common"; import { - CollectionService, - CollectionWithIdRequest, CollectionView, CollectionTypes, -} from "@bitwarden/admin-console/common"; +} from "@bitwarden/common/admin-console/models/collections"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; @@ -32,6 +31,7 @@ import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/res import { KeyService } from "@bitwarden/key-management"; import { + ArcCsvImporter, AscendoCsvImporter, AvastCsvImporter, AvastJsonImporter, @@ -257,6 +257,8 @@ export class ImportService implements ImportServiceAbstraction { return new PadlockCsvImporter(); case "keepass2xml": return new KeePass2XmlImporter(); + case "arccsv": + return new ArcCsvImporter(); case "edgecsv": case "chromecsv": case "operacsv": 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 @@ </button> </ng-container> + <bit-unlock-via-prf (unlockSuccess)="onPrfUnlockSuccess($event)"></bit-unlock-via-prf> + <button type="button" bitButton block (click)="logOut()"> {{ "logOut" | i18n }} </button> @@ -113,6 +115,11 @@ </button> </ng-container> + <bit-unlock-via-prf + [formButton]="true" + (unlockSuccess)="onPrfUnlockSuccess($event)" + ></bit-unlock-via-prf> + <button type="button" bitButton bitFormButton block (click)="logOut()"> {{ "logOut" | i18n }} </button> @@ -127,6 +134,7 @@ [unlockOptions]="unlockOptions" [biometricUnlockBtnText]="biometricUnlockBtnText" (successfulUnlock)="successfulMasterPasswordUnlock($event)" + (prfUnlockSuccess)="onPrfUnlockSuccess($event)" (logOut)="logOut()" ></bit-master-password-lock> } 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<LockComponentService>(); const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>(); const mockBroadcasterService = mock<BroadcasterService>(); + const mockWebAuthnPrfUnlockService = mock<WebAuthnPrfUnlockService>(); const mockEncryptedMigrator = mock<EncryptedMigrator>(); 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<void> { + 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 @@ </button> } + <bit-unlock-via-prf + [formButton]="true" + (unlockSuccess)="onPrfUnlockSuccess($event)" + ></bit-unlock-via-prf> + <button type="button" bitButton bitFormButton block (click)="logOut.emit()"> {{ "logOut" | i18n }} </button> 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<LogService>(); const platformUtilsService = mock<PlatformUtilsService>(); const messageListener = mock<MessageListener>(); + const webAuthnPrfUnlockService = mock<WebAuthnPrfUnlockService>(); + const dialogService = mock<DialogService>(); 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<UserKey>(); logOut = output<void>(); 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()) { + <button + type="button" + bitButton + bitFormButton + buttonType="secondary" + block + (click)="unlockViaPrf()" + [disabled]="unlocking" + [loading]="unlocking" + > + <i class="bwi bwi-passkey tw-mr-1" aria-hidden="true"></i> + {{ "unlockWithPasskey" | i18n }} + </button> + } + @if (!formButton()) { + <button + type="button" + bitButton + buttonType="secondary" + block + (click)="unlockViaPrf()" + [disabled]="unlocking" + [loading]="unlocking" + > + <i class="bwi bwi-passkey tw-mr-1" aria-hidden="true"></i> + {{ "unlockWithPasskey" | i18n }} + </button> + } + } + `, +}) +export class UnlockViaPrfComponent implements OnInit { + readonly formButton = input<boolean>(false); + readonly unlockSuccess = output<UserKey>(); + + 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<void> { + 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<void> { + 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<boolean> { + 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<UserKey> the decrypted user key + * @throws Error if unlock fails for any reason + */ + async unlockVaultWithPrf(userId: UserId): Promise<UserKey> { + // 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<PublicKeyCredential> { + 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<PrfKey> { + // 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<WebAuthnPrfUserDecryptionOption> { + 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<ArrayBuffer> { + 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<UserDecryptionOptions | null> { + 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<string | undefined> { + 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>]: 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<boolean> true if PRF unlock is available + */ + abstract isPrfUnlockAvailable(userId: UserId): Promise<boolean>; + + /** + * Attempt to unlock the vault using WebAuthn PRF + * @param userId The user ID to unlock vault for + * @returns Promise<UserKey> 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<UserKey>; +} diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index 6cf44544422..2dedc78a027 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -68,20 +68,6 @@ export abstract class KeyService { * @param userId The desired user */ abstract setUserKey(key: UserKey, userId: UserId): Promise<void>; - /** - * Sets the provided user keys and stores any other necessary versions - * (such as auto, biometrics, or pin). - * Also sets the user's encrypted private key in storage and - * clears the decrypted private key from memory - * Note: does not clear the private key if null is provided - * - * @throws Error when userKey, encPrivateKey or userId is null - * @throws UserPrivateKeyDecryptionFailedError when the userKey cannot decrypt encPrivateKey - * @param userKey The user key to set - * @param encPrivateKey An encrypted private key - * @param userId The desired user - */ - abstract setUserKeys(userKey: UserKey, encPrivateKey: string, userId: UserId): Promise<void>; /** * Gets the user key from memory and sets it again, * kicking off a refresh of any additional keys @@ -128,18 +114,13 @@ export abstract class KeyService { /** * Generates a new user key - * @deprecated Interacting with the master key directly is prohibited. Use {@link makeUserKeyV1} instead. + * @deprecated Interacting with the master key directly is prohibited. + * For new features please use the KM provided SDK methods for user cryptography initialization or reach out to the KM team. * @throws Error when master key is null or undefined. * @param masterKey The user's master key. * @returns A new user key and the master key protected version of it */ abstract makeUserKey(masterKey: MasterKey): Promise<[UserKey, EncString]>; - /** - * Generates a new user key for a V1 user - * Note: This will be replaced by a higher level function to initialize a whole users cryptographic state in the near future. - * @returns A new user key - */ - abstract makeUserKeyV1(): Promise<UserKey>; /** * Clears the user's stored version of the user key * @param userId The desired user @@ -263,21 +244,6 @@ export abstract class KeyService { * @returns The new encrypted OrgKey | ProviderKey and the decrypted key itself */ abstract makeOrgKey<T extends OrgKey | ProviderKey>(userId: UserId): Promise<[EncString, T]>; - /** - * Sets the user's encrypted private key in storage and - * clears the decrypted private key from memory - * Note: does not clear the private key if null is provided - * @param encPrivateKey An encrypted private key - */ - abstract setPrivateKey(encPrivateKey: string, userId: UserId): Promise<void>; - /** - * Sets the user's encrypted signing key in storage - * In contrast to the private key, the decrypted signing key - * is not stored in memory outside of the SDK. - * @param encryptedSigningKey An encrypted signing key - * @param userId The user id of the user to set the signing key for - */ - abstract setUserSigningKey(encryptedSigningKey: WrappedSigningKey, userId: UserId): Promise<void>; /** * Gets an observable stream of the given users decrypted private key, will emit null if the user @@ -285,8 +251,7 @@ export abstract class KeyService { * encrypted private key at all. * * @param userId The user id of the user to get the data for. - * @returns An observable stream of the decrypted private key or null. - * @throws Error when decryption of the encrypted private key fails. + * @returns An observable stream of the decrypted private key or null if the private key is not present or fails to decrypt */ abstract userPrivateKey$(userId: UserId): Observable<UserPrivateKey | null>; @@ -334,9 +299,9 @@ export abstract class KeyService { abstract getFingerprint(fingerprintMaterial: string, publicKey: Uint8Array): Promise<string[]>; /** * Generates a new keypair - * @param key A key to encrypt the private key with. If not provided, - * defaults to the user key - * @returns A new keypair: [publicKey in Base64, encrypted privateKey] + * @deprecated New use-cases of this function are prohibited. Low-level cryptographic constructions and initialization should be done in the SDK. + * @param key A symmetric key to wrap the newly created private key with. + * @returns A new keypair: [publicKey in Base64, wrapped privateKey] * @throws If the provided key is a null-ish value. */ abstract makeKeyPair(key: SymmetricCryptoKey): Promise<[string, EncString]>; @@ -361,6 +326,8 @@ export abstract class KeyService { /** * Initialize all necessary crypto keys needed for a new account. * Warning! This completely replaces any existing keys! + * @deprecated New use cases for cryptography initialization should be done in the SDK. + * Current usage is actively being migrated see PM-21771 for details. * @param userId The user id of the target user. * @returns The user's newly created public key, private key, and encrypted private key * @throws An error if the userId is null or undefined. @@ -429,7 +396,5 @@ export abstract class KeyService { */ abstract validateUserKey(key: UserKey, userId: UserId): Promise<boolean>; - abstract setSignedPublicKey(signedPublicKey: SignedPublicKey, userId: UserId): Promise<void>; - abstract userSignedPublicKey$(userId: UserId): Observable<SignedPublicKey | null>; } 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<boolean>; // used to be biometricUnlock + abstract biometricUnlockEnabled$(userId?: UserId): Observable<boolean>; /** * 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<boolean>; @@ -103,7 +106,6 @@ export class DefaultBiometricStateService implements BiometricStateService { private promptAutomaticallyState: ActiveUserState<boolean>; private fingerprintValidatedState: GlobalState<boolean>; private lastProcessReloadState: GlobalState<Date>; - biometricUnlockEnabled$: Observable<boolean>; encryptedClientKeyHalf$: Observable<EncString | null>; promptCancelled$: Observable<boolean>; promptAutomatically$: Observable<boolean>; @@ -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<boolean> { + 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<boolean> { return await firstValueFrom( this.stateProvider.getUser(userId, BIOMETRIC_UNLOCK_ENABLED).state$.pipe(map(Boolean)), diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index c0a0ab62347..8fe77a665bc 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -1,7 +1,9 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject, bufferCount, firstValueFrom, lastValueFrom, of, take } from "rxjs"; +import { ClientType } from "@bitwarden/client-type"; import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data"; +import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; @@ -10,7 +12,7 @@ import { EncryptedString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service"; -import { UnsignedPublicKey, WrappedSigningKey } from "@bitwarden/common/key-management/types"; +import { UnsignedPublicKey } from "@bitwarden/common/key-management/types"; import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout"; import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -22,10 +24,8 @@ import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/sym import { USER_ENCRYPTED_ORGANIZATION_KEYS } from "@bitwarden/common/platform/services/key-state/org-keys.state"; import { USER_ENCRYPTED_PROVIDER_KEYS } from "@bitwarden/common/platform/services/key-state/provider-keys.state"; import { - USER_ENCRYPTED_PRIVATE_KEY, USER_EVER_HAD_USER_KEY, USER_KEY, - USER_KEY_ENCRYPTED_SIGNING_KEY, } from "@bitwarden/common/platform/services/key-state/user-key.state"; import { UserKeyDefinition } from "@bitwarden/common/platform/state"; import { @@ -49,7 +49,6 @@ import { } from "@bitwarden/common/types/key"; import { KdfConfigService } from "./abstractions/kdf-config.service"; -import { UserPrivateKeyDecryptionFailedError } from "./abstractions/key.service"; import { DefaultKeyService } from "./key.service"; import { KdfConfig } from "./models/kdf-config"; @@ -63,6 +62,7 @@ describe("keyService", () => { const logService = mock<LogService>(); const stateService = mock<StateService>(); const kdfConfigService = mock<KdfConfigService>(); + const accountCryptographicStateService = mock<AccountCryptographicStateService>(); let stateProvider: FakeStateProvider; const mockUserId = Utils.newGuid() as UserId; @@ -87,6 +87,7 @@ describe("keyService", () => { accountService, stateProvider, kdfConfigService, + accountCryptographicStateService, ); }); @@ -259,7 +260,18 @@ describe("keyService", () => { }); }); - it("clears the Auto key if vault timeout is set to anything other than null", async () => { + it("sets an Auto key if vault timeout is set to 10 minutes and is Cli", async () => { + await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId); + platformUtilService.getClientType.mockReturnValue(ClientType.Cli); + + await keyService.setUserKey(mockUserKey, mockUserId); + + expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(mockUserKey.keyB64, { + userId: mockUserId, + }); + }); + + it("clears the Auto key if vault timeout is set to 10 minutes", async () => { await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId); await keyService.setUserKey(mockUserKey, mockUserId); @@ -283,70 +295,6 @@ describe("keyService", () => { }); }); - describe("setUserKeys", () => { - let mockUserKey: UserKey; - let mockEncPrivateKey: EncryptedString; - let everHadUserKeyState: FakeSingleUserState<boolean>; - - beforeEach(() => { - const mockRandomBytes = new Uint8Array(64) as CsprngArray; - mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey; - mockEncPrivateKey = new SymmetricCryptoKey(mockRandomBytes).toString() as EncryptedString; - everHadUserKeyState = stateProvider.singleUser.getFake(mockUserId, USER_EVER_HAD_USER_KEY); - - // Initialize storage - everHadUserKeyState.nextState(null); - - // Mock private key decryption - encryptService.unwrapDecapsulationKey.mockResolvedValue(mockRandomBytes); - }); - - it("throws if userKey is null", async () => { - await expect( - keyService.setUserKeys(null as unknown as UserKey, mockEncPrivateKey, mockUserId), - ).rejects.toThrow("No userKey provided."); - }); - - it("throws if encPrivateKey is null", async () => { - await expect( - keyService.setUserKeys(mockUserKey, null as unknown as EncryptedString, mockUserId), - ).rejects.toThrow("No encPrivateKey provided."); - }); - - it("throws if userId is null", async () => { - await expect( - keyService.setUserKeys(mockUserKey, mockEncPrivateKey, null as unknown as UserId), - ).rejects.toThrow("No userId provided."); - }); - - it("throws if encPrivateKey cannot be decrypted with the userKey", async () => { - encryptService.unwrapDecapsulationKey.mockResolvedValue(null); - - await expect( - keyService.setUserKeys(mockUserKey, mockEncPrivateKey, mockUserId), - ).rejects.toThrow(UserPrivateKeyDecryptionFailedError); - }); - - // We already have tests for setUserKey, so we just need to test that the correct methods are called - it("calls setUserKey with the userKey and userId", async () => { - const setUserKeySpy = jest.spyOn(keyService, "setUserKey"); - - await keyService.setUserKeys(mockUserKey, mockEncPrivateKey, mockUserId); - - expect(setUserKeySpy).toHaveBeenCalledWith(mockUserKey, mockUserId); - }); - - // We already have tests for setPrivateKey, so we just need to test that the correct methods are called - // TODO: Move those tests into here since `setPrivateKey` will be converted to a private method - it("calls setPrivateKey with the encPrivateKey and userId", async () => { - const setEncryptedPrivateKeySpy = jest.spyOn(keyService, "setPrivateKey"); - - await keyService.setUserKeys(mockUserKey, mockEncPrivateKey, mockUserId); - - expect(setEncryptedPrivateKeySpy).toHaveBeenCalledWith(mockEncPrivateKey, mockUserId); - }); - }); - describe("makeSendKey", () => { const mockRandomBytes = new Uint8Array(16) as CsprngArray; it("calls keyGenerationService with expected hard coded parameters", async () => { @@ -393,22 +341,19 @@ describe("keyService", () => { }, ); - describe.each([ - USER_ENCRYPTED_ORGANIZATION_KEYS, - USER_ENCRYPTED_PROVIDER_KEYS, - USER_ENCRYPTED_PRIVATE_KEY, - USER_KEY_ENCRYPTED_SIGNING_KEY, - USER_KEY, - ])("key removal", (key: UserKeyDefinition<unknown>) => { - it(`clears ${key.key} for the specified user when specified`, async () => { - const userId = "someOtherUser" as UserId; - await keyService.clearKeys(userId); + describe.each([USER_ENCRYPTED_ORGANIZATION_KEYS, USER_ENCRYPTED_PROVIDER_KEYS, USER_KEY])( + "key removal", + (key: UserKeyDefinition<unknown>) => { + it(`clears ${key.key} for the specified user when specified`, async () => { + const userId = "someOtherUser" as UserId; + await keyService.clearKeys(userId); - const encryptedOrgKeyState = stateProvider.singleUser.getFake(userId, key); - expect(encryptedOrgKeyState.nextMock).toHaveBeenCalledTimes(1); - expect(encryptedOrgKeyState.nextMock).toHaveBeenCalledWith(null); - }); - }); + const encryptedOrgKeyState = stateProvider.singleUser.getFake(userId, key); + expect(encryptedOrgKeyState.nextMock).toHaveBeenCalledTimes(1); + expect(encryptedOrgKeyState.nextMock).toHaveBeenCalledWith(null); + }); + }, + ); }); describe("userPrivateKey$", () => { @@ -421,9 +366,9 @@ describe("keyService", () => { mockEncryptedPrivateKey = makeEncString("encryptedPrivateKey").encryptedString!; mockUserPrivateKey = makeStaticByteArray(10, 1); stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(mockUserKey); - stateProvider.singleUser - .getFake(mockUserId, USER_ENCRYPTED_PRIVATE_KEY) - .nextState(mockEncryptedPrivateKey); + accountCryptographicStateService.accountCryptographicState$.mockReturnValue( + of({ V1: { private_key: mockEncryptedPrivateKey } }), + ); encryptService.unwrapDecapsulationKey.mockResolvedValue(mockUserPrivateKey); }); @@ -437,14 +382,13 @@ describe("keyService", () => { ); }); - it("throws an error if unwrapping encrypted private key fails", async () => { + it("emits null if unwrapping encrypted private key fails", async () => { encryptService.unwrapDecapsulationKey.mockImplementationOnce(() => { throw new Error("Unwrapping failed"); }); - await expect(firstValueFrom(keyService.userPrivateKey$(mockUserId))).rejects.toThrow( - "Unwrapping failed", - ); + const result = await firstValueFrom(keyService.userPrivateKey$(mockUserId)); + expect(result).toBeNull(); }); it("returns null if user key is not set", async () => { @@ -457,7 +401,7 @@ describe("keyService", () => { }); it("returns null if encrypted private key is not set", async () => { - stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_PRIVATE_KEY).nextState(null); + accountCryptographicStateService.accountCryptographicState$.mockReturnValue(of(null)); const result = await firstValueFrom(keyService.userPrivateKey$(mockUserId)); @@ -467,6 +411,13 @@ describe("keyService", () => { it("reacts to changes in user key or encrypted private key", async () => { // Initial state: both set + const accountStateSubject = new BehaviorSubject({ + V1: { private_key: mockEncryptedPrivateKey }, + }); + accountCryptographicStateService.accountCryptographicState$.mockReturnValue( + accountStateSubject.asObservable(), + ); + let result = await firstValueFrom(keyService.userPrivateKey$(mockUserId)); expect(result).toEqual(mockUserPrivateKey); @@ -480,7 +431,7 @@ describe("keyService", () => { // Restore user key, remove encrypted private key stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(mockUserKey); - stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_PRIVATE_KEY).nextState(null); + accountStateSubject.next(null); result = await firstValueFrom(keyService.userPrivateKey$(mockUserId)); @@ -488,52 +439,16 @@ describe("keyService", () => { }); }); - describe("userSigningKey$", () => { - it("returns the signing key when the user has a signing key set", async () => { - const fakeSigningKey = "" as WrappedSigningKey; - const fakeSigningKeyState = stateProvider.singleUser.getFake( - mockUserId, - USER_KEY_ENCRYPTED_SIGNING_KEY, - ); - fakeSigningKeyState.nextState(fakeSigningKey); - - const signingKey = await firstValueFrom(keyService.userSigningKey$(mockUserId)); - - expect(signingKey).toEqual(fakeSigningKey); - }); - - it("returns null when the user does not have a signing key set", async () => { - const signingKey = await firstValueFrom(keyService.userSigningKey$(mockUserId)); - - expect(signingKey).toBeFalsy(); - }); - }); - - describe("setUserSigningKey", () => { - it("throws if the signing key is null", async () => { - await expect(keyService.setUserSigningKey(null as any, mockUserId)).rejects.toThrow( - "No user signing key provided.", - ); - }); - it("throws if the userId is null", async () => { - await expect( - keyService.setUserSigningKey("" as WrappedSigningKey, null as unknown as UserId), - ).rejects.toThrow("No userId provided."); - }); - it("sets the signing key for the user", async () => { - const fakeSigningKey = "" as WrappedSigningKey; - const fakeSigningKeyState = stateProvider.singleUser.getFake( - mockUserId, - USER_KEY_ENCRYPTED_SIGNING_KEY, - ); - fakeSigningKeyState.nextState(null); - await keyService.setUserSigningKey(fakeSigningKey, mockUserId); - expect(fakeSigningKeyState.nextMock).toHaveBeenCalledTimes(1); - expect(fakeSigningKeyState.nextMock).toHaveBeenCalledWith(fakeSigningKey); - }); - }); - describe("cipherDecryptionKeys$", () => { + let accountStateSubject: BehaviorSubject<any>; + + beforeEach(() => { + accountStateSubject = new BehaviorSubject(null); + accountCryptographicStateService.accountCryptographicState$.mockReturnValue( + accountStateSubject.asObservable(), + ); + }); + function fakePrivateKeyDecryption(encryptedPrivateKey: EncString, key: SymmetricCryptoKey) { const output = new Uint8Array(64); output.set(encryptedPrivateKey.dataBytes); @@ -570,11 +485,9 @@ describe("keyService", () => { } if ("encryptedPrivateKey" in keys) { - const userEncryptedPrivateKey = stateProvider.singleUser.getFake( - mockUserId, - USER_ENCRYPTED_PRIVATE_KEY, - ); - userEncryptedPrivateKey.nextState(keys.encryptedPrivateKey!.encryptedString!); + accountStateSubject.next({ + V1: { private_key: keys.encryptedPrivateKey!.encryptedString! }, + }); } if ("orgKeys" in keys) { @@ -1266,17 +1179,16 @@ describe("keyService", () => { it("successfully initializes account with new keys", async () => { const keyCreationSize = 512; - const privateKeyState = stateProvider.singleUser.getFake( - mockUserId, - USER_ENCRYPTED_PRIVATE_KEY, - ); const result = await keyService.initAccount(mockUserId); expect(keyGenerationService.createKey).toHaveBeenCalledWith(keyCreationSize); expect(keyService.makeKeyPair).toHaveBeenCalledWith(userKey); expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, mockUserId); - expect(privateKeyState.nextMock).toHaveBeenCalledWith(mockPrivateKey.encryptedString); + expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith( + { V1: { private_key: mockPrivateKey.encryptedString } }, + mockUserId, + ); expect(result).toEqual({ userKey: userKey, publicKey: mockPublicKey, diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index 8cb072a4c2a..7b4e8d83127 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -14,12 +14,14 @@ import { switchMap, } from "rxjs"; +import { ClientType } from "@bitwarden/client-type"; import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data"; import { BaseEncryptedOrganizationKey } from "@bitwarden/common/admin-console/models/domain/encrypted-organization-key"; import { ProfileOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-organization.response"; import { ProfileProviderOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-provider-organization.response"; import { ProfileProviderResponse } from "@bitwarden/common/admin-console/models/response/profile-provider.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; @@ -42,11 +44,8 @@ import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/sym import { USER_ENCRYPTED_ORGANIZATION_KEYS } from "@bitwarden/common/platform/services/key-state/org-keys.state"; import { USER_ENCRYPTED_PROVIDER_KEYS } from "@bitwarden/common/platform/services/key-state/provider-keys.state"; import { - USER_ENCRYPTED_PRIVATE_KEY, USER_EVER_HAD_USER_KEY, USER_KEY, - USER_KEY_ENCRYPTED_SIGNING_KEY, - USER_SIGNED_PUBLIC_KEY, } from "@bitwarden/common/platform/services/key-state/user-key.state"; import { StateProvider } from "@bitwarden/common/platform/state"; import { CsprngArray } from "@bitwarden/common/types/csprng"; @@ -60,12 +59,12 @@ import { UserPrivateKey, UserPublicKey, } from "@bitwarden/common/types/key"; +import { WrappedAccountCryptographicState } from "@bitwarden/sdk-internal"; import { KdfConfigService } from "./abstractions/kdf-config.service"; import { CipherDecryptionKeys, KeyService as KeyServiceAbstraction, - UserPrivateKeyDecryptionFailedError, } from "./abstractions/key.service"; import { KdfConfig } from "./models/kdf-config"; @@ -90,6 +89,7 @@ export class DefaultKeyService implements KeyServiceAbstraction { protected accountService: AccountService, protected stateProvider: StateProvider, protected kdfConfigService: KdfConfigService, + protected accountCryptographyStateService: AccountCryptographicStateService, ) { this.activeUserOrgKeys$ = this.stateProvider.activeUserId$.pipe( switchMap((userId) => (userId != null ? this.orgKeys$(userId) : NEVER)), @@ -121,30 +121,6 @@ export class DefaultKeyService implements KeyServiceAbstraction { } } - async setUserKeys( - userKey: UserKey, - encPrivateKey: EncryptedString, - userId: UserId, - ): Promise<void> { - if (userKey == null) { - throw new Error("No userKey provided. Lock the user to clear the key"); - } - if (encPrivateKey == null) { - throw new Error("No encPrivateKey provided."); - } - if (userId == null) { - throw new Error("No userId provided."); - } - - const decryptedPrivateKey = await this.decryptPrivateKey(encPrivateKey, userKey); - if (decryptedPrivateKey == null) { - throw new UserPrivateKeyDecryptionFailedError(); - } - - await this.setUserKey(userKey, userId); - await this.setPrivateKey(encPrivateKey, userId); - } - async refreshAdditionalKeys(userId: UserId): Promise<void> { if (userId == null) { throw new Error("UserId is required."); @@ -213,11 +189,6 @@ export class DefaultKeyService implements KeyServiceAbstraction { return this.buildProtectedSymmetricKey(masterKey, newUserKey); } - async makeUserKeyV1(): Promise<UserKey> { - const newUserKey = await this.keyGenerationService.createKey(512); - return newUserKey as UserKey; - } - /** * Clears the user key. Clears all stored versions of the user keys as well, such as the biometrics key * @param userId The desired user @@ -471,16 +442,6 @@ export class DefaultKeyService implements KeyServiceAbstraction { return [encShareKey, shareKey as T]; } - async setPrivateKey(encPrivateKey: EncryptedString, userId: UserId): Promise<void> { - if (encPrivateKey == null) { - return; - } - - await this.stateProvider - .getUser(userId, USER_ENCRYPTED_PRIVATE_KEY) - .update(() => encPrivateKey); - } - async getFingerprint(fingerprintMaterial: string, publicKey: Uint8Array): Promise<string[]> { if (publicKey == null) { throw new Error("Public key is required to generate a fingerprint."); @@ -507,18 +468,6 @@ export class DefaultKeyService implements KeyServiceAbstraction { return [publicB64, privateEnc]; } - /** - * Clears the user's key pair - * @param userId The desired user - */ - private async clearKeyPair(userId: UserId): Promise<void> { - await this.stateProvider.setUserState(USER_ENCRYPTED_PRIVATE_KEY, null, userId); - } - - private async clearSigningKey(userId: UserId): Promise<void> { - await this.stateProvider.setUserState(USER_KEY_ENCRYPTED_SIGNING_KEY, null, userId); - } - async makeSendKey(keyMaterial: CsprngArray): Promise<SymmetricCryptoKey> { return await this.keyGenerationService.deriveKeyFromMaterial( keyMaterial, @@ -540,9 +489,8 @@ export class DefaultKeyService implements KeyServiceAbstraction { await this.clearUserKey(userId); await this.clearOrgKeys(userId); await this.clearProviderKeys(userId); - await this.clearKeyPair(userId); - await this.clearSigningKey(userId); await this.stateProvider.setUserState(USER_EVER_HAD_USER_KEY, null, userId); + await this.accountCryptographyStateService.clearAccountCryptographicState(userId); } // EFForg/OpenWireless @@ -587,9 +535,7 @@ export class DefaultKeyService implements KeyServiceAbstraction { } try { - const encPrivateKey = await firstValueFrom( - this.stateProvider.getUser(userId, USER_ENCRYPTED_PRIVATE_KEY).state$, - ); + const encPrivateKey = await firstValueFrom(this.userEncryptedPrivateKey$(userId)); if (encPrivateKey == null) { return false; @@ -647,9 +593,14 @@ export class DefaultKeyService implements KeyServiceAbstraction { } await this.setUserKey(userKey, userId); - await this.stateProvider - .getUser(userId, USER_ENCRYPTED_PRIVATE_KEY) - .update(() => privateKey.encryptedString!); + await this.accountCryptographyStateService.setAccountCryptographicState( + { + V1: { + private_key: privateKey.encryptedString, + }, + }, + userId, + ); return { userKey, @@ -676,9 +627,13 @@ export class DefaultKeyService implements KeyServiceAbstraction { } protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId: UserId) { - let shouldStoreKey = false; switch (keySuffix) { case KeySuffixOptions.Auto: { + // Cli has fixed Never vault timeout, and it should not be affected by a policy. + if (this.platformUtilService.getClientType() == ClientType.Cli) { + return true; + } + // TODO: Sharing the UserKeyDefinition is temporary to get around a circ dep issue between // the VaultTimeoutSettingsSvc and this service. // This should be fixed as part of the PM-7082 - Auto Key Service work. @@ -688,11 +643,14 @@ export class DefaultKeyService implements KeyServiceAbstraction { .pipe(filter((timeout) => timeout != null)), ); - shouldStoreKey = vaultTimeout == VaultTimeoutStringType.Never; - break; + this.logService.debug( + `[KeyService] Should store auto key for vault timeout ${vaultTimeout}`, + ); + + return vaultTimeout == VaultTimeoutStringType.Never; } } - return shouldStoreKey; + return false; } protected async getKeyFromStorage( @@ -793,10 +751,26 @@ export class DefaultKeyService implements KeyServiceAbstraction { } userEncryptedPrivateKey$(userId: UserId): Observable<EncryptedString | null> { - return this.stateProvider.getUser(userId, USER_ENCRYPTED_PRIVATE_KEY).state$; + return this.accountCryptographyStateService.accountCryptographicState$(userId).pipe( + map((state: WrappedAccountCryptographicState | null) => { + if (state == null) { + return null; + } + if ("V2" in state) { + return state.V2.private_key; + } else if ("V1" in state) { + return state.V1.private_key; + } else { + return null; + } + }), + ); } - private userPrivateKeyHelper$(userId: UserId) { + private userPrivateKeyHelper$(userId: UserId): Observable<{ + userKey: UserKey; + userPrivateKey: UserPrivateKey | null; + } | null> { const userKey$ = this.userKey$(userId); return userKey$.pipe( switchMap((userKey) => { @@ -804,20 +778,22 @@ export class DefaultKeyService implements KeyServiceAbstraction { return of(null); } - return this.stateProvider.getUser(userId, USER_ENCRYPTED_PRIVATE_KEY).state$.pipe( + return this.userEncryptedPrivateKey$(userId).pipe( switchMap(async (encryptedPrivateKey) => { - try { - return await this.decryptPrivateKey(encryptedPrivateKey, userKey); - } catch (e) { - this.logService.error("Failed to decrypt private key for user ", userId, e); - throw e; - } + return await this.decryptPrivateKey(encryptedPrivateKey, userKey); }), // Combine outerscope info with user private key map((userPrivateKey) => ({ userKey, userPrivateKey, })), + catchError((err: unknown) => { + this.logService.error(`Failed to decrypt private key for user ${userId}`); + return of({ + userKey, + userPrivateKey: null, + }); + }), ); }), ); @@ -871,23 +847,17 @@ export class DefaultKeyService implements KeyServiceAbstraction { ); } - async setUserSigningKey(userSigningKey: WrappedSigningKey, userId: UserId): Promise<void> { - if (userSigningKey == null) { - throw new Error("No user signing key provided."); - } - if (userId == null) { - throw new Error("No userId provided."); - } - await this.stateProvider.setUserState(USER_KEY_ENCRYPTED_SIGNING_KEY, userSigningKey, userId); - } - userSigningKey$(userId: UserId): Observable<WrappedSigningKey | null> { - return this.stateProvider.getUser(userId, USER_KEY_ENCRYPTED_SIGNING_KEY).state$.pipe( - map((encryptedSigningKey) => { - if (encryptedSigningKey == null) { + return this.accountCryptographyStateService.accountCryptographicState$(userId).pipe( + map((state: WrappedAccountCryptographicState | null) => { + if (state == null) { + return null; + } + if ("V2" in state) { + return state.V2.signing_key as WrappedSigningKey; + } else { return null; } - return encryptedSigningKey as WrappedSigningKey; }), ); } @@ -1009,11 +979,18 @@ export class DefaultKeyService implements KeyServiceAbstraction { ); } - async setSignedPublicKey(signedPublicKey: SignedPublicKey, userId: UserId): Promise<void> { - await this.stateProvider.setUserState(USER_SIGNED_PUBLIC_KEY, signedPublicKey, userId); - } - userSignedPublicKey$(userId: UserId): Observable<SignedPublicKey | null> { - return this.stateProvider.getUserState$(USER_SIGNED_PUBLIC_KEY, userId); + return this.accountCryptographyStateService.accountCryptographicState$(userId).pipe( + map((state: WrappedAccountCryptographicState | null) => { + if (state == null) { + return null; + } + if ("V2" in state) { + return state.V2.signed_public_key as SignedPublicKey; + } else { + return null; + } + }), + ); } } diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts b/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts index 58620f49ed1..469ed2a00d0 100644 --- a/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts +++ b/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts @@ -12,6 +12,8 @@ export abstract class UserAsymmetricKeysRegenerationService { * Performs the regeneration of the user's public/private key pair without checking any preconditions. * This should only be used for V1 encryption accounts * @param userId The user id. + * @returns True if regeneration was performed, false otherwise. + * @throws An error if the regeneration could not be attempted due to missing state */ - abstract regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<void>; + abstract regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<boolean>; } diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts index 92e5240a187..3e771b242bb 100644 --- a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts +++ b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts @@ -2,6 +2,7 @@ import { MockProxy, mock } from "jest-mock-extended"; import { of, throwError } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptedString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -70,6 +71,7 @@ describe("regenerateIfNeeded", () => { let apiService: MockProxy<ApiService>; let configService: MockProxy<ConfigService>; let encryptService: MockProxy<EncryptService>; + let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>; beforeEach(() => { keyService = mock<KeyService>(); @@ -80,6 +82,7 @@ describe("regenerateIfNeeded", () => { apiService = mock<ApiService>(); configService = mock<ConfigService>(); encryptService = mock<EncryptService>(); + accountCryptographicStateService = mock<AccountCryptographicStateService>(); sut = new DefaultUserAsymmetricKeysRegenerationService( keyService, @@ -89,6 +92,7 @@ describe("regenerateIfNeeded", () => { sdkService, apiService, configService, + accountCryptographicStateService, ); configService.getFeatureFlag.mockResolvedValue(true); @@ -131,7 +135,7 @@ describe("regenerateIfNeeded", () => { expect( userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys, ).not.toHaveBeenCalled(); - expect(keyService.setPrivateKey).not.toHaveBeenCalled(); + expect(accountCryptographicStateService.setAccountCryptographicState).not.toHaveBeenCalled(); }); it("should not regenerate when private key is decryptable and valid", async () => { @@ -146,7 +150,7 @@ describe("regenerateIfNeeded", () => { expect( userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys, ).not.toHaveBeenCalled(); - expect(keyService.setPrivateKey).not.toHaveBeenCalled(); + expect(accountCryptographicStateService.setAccountCryptographicState).not.toHaveBeenCalled(); }); it("should not regenerate when user symmetric key is unavailable", async () => { @@ -162,7 +166,7 @@ describe("regenerateIfNeeded", () => { expect( userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys, ).not.toHaveBeenCalled(); - expect(keyService.setPrivateKey).not.toHaveBeenCalled(); + expect(accountCryptographicStateService.setAccountCryptographicState).not.toHaveBeenCalled(); }); it("should not regenerate when user's encrypted private key is unavailable", async () => { @@ -180,7 +184,7 @@ describe("regenerateIfNeeded", () => { expect( userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys, ).not.toHaveBeenCalled(); - expect(keyService.setPrivateKey).not.toHaveBeenCalled(); + expect(accountCryptographicStateService.setAccountCryptographicState).not.toHaveBeenCalled(); }); it("should not regenerate when user's public key is unavailable", async () => { @@ -196,7 +200,7 @@ describe("regenerateIfNeeded", () => { expect( userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys, ).not.toHaveBeenCalled(); - expect(keyService.setPrivateKey).not.toHaveBeenCalled(); + expect(accountCryptographicStateService.setAccountCryptographicState).not.toHaveBeenCalled(); }); it("should regenerate when private key is decryptable and invalid", async () => { @@ -211,7 +215,7 @@ describe("regenerateIfNeeded", () => { expect( userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys, ).toHaveBeenCalled(); - expect(keyService.setPrivateKey).toHaveBeenCalled(); + expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalled(); }); it("should not set private key on known API error", async () => { @@ -230,7 +234,7 @@ describe("regenerateIfNeeded", () => { expect( userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys, ).toHaveBeenCalled(); - expect(keyService.setPrivateKey).not.toHaveBeenCalled(); + expect(accountCryptographicStateService.setAccountCryptographicState).not.toHaveBeenCalled(); }); it("should not set private key on unknown API error", async () => { @@ -249,7 +253,7 @@ describe("regenerateIfNeeded", () => { expect( userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys, ).toHaveBeenCalled(); - expect(keyService.setPrivateKey).not.toHaveBeenCalled(); + expect(accountCryptographicStateService.setAccountCryptographicState).not.toHaveBeenCalled(); }); it("should regenerate when private key is not decryptable and user key is valid", async () => { @@ -265,7 +269,7 @@ describe("regenerateIfNeeded", () => { expect( userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys, ).toHaveBeenCalled(); - expect(keyService.setPrivateKey).toHaveBeenCalled(); + expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalled(); }); it("should not regenerate when private key is not decryptable and user key is invalid", async () => { @@ -283,7 +287,7 @@ describe("regenerateIfNeeded", () => { expect( userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys, ).not.toHaveBeenCalled(); - expect(keyService.setPrivateKey).not.toHaveBeenCalled(); + expect(accountCryptographicStateService.setAccountCryptographicState).not.toHaveBeenCalled(); }); it("should not regenerate when private key is not decryptable and no ciphers to check", async () => { @@ -299,7 +303,7 @@ describe("regenerateIfNeeded", () => { expect( userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys, ).not.toHaveBeenCalled(); - expect(keyService.setPrivateKey).not.toHaveBeenCalled(); + expect(accountCryptographicStateService.setAccountCryptographicState).not.toHaveBeenCalled(); }); it("should regenerate when private key is not decryptable and invalid and user key is valid", async () => { @@ -315,7 +319,7 @@ describe("regenerateIfNeeded", () => { expect( userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys, ).toHaveBeenCalled(); - expect(keyService.setPrivateKey).toHaveBeenCalled(); + expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalled(); }); it("should not regenerate when private key is not decryptable and invalid and user key is invalid", async () => { @@ -333,7 +337,7 @@ describe("regenerateIfNeeded", () => { expect( userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys, ).not.toHaveBeenCalled(); - expect(keyService.setPrivateKey).not.toHaveBeenCalled(); + expect(accountCryptographicStateService.setAccountCryptographicState).not.toHaveBeenCalled(); }); it("should not regenerate when private key is not decryptable and invalid and no ciphers to check", async () => { @@ -349,7 +353,7 @@ describe("regenerateIfNeeded", () => { expect( userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys, ).not.toHaveBeenCalled(); - expect(keyService.setPrivateKey).not.toHaveBeenCalled(); + expect(accountCryptographicStateService.setAccountCryptographicState).not.toHaveBeenCalled(); }); it("should not regenerate when userKey type is CoseEncrypt0 (V2 encryption)", async () => { @@ -364,7 +368,7 @@ describe("regenerateIfNeeded", () => { expect( userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys, ).not.toHaveBeenCalled(); - expect(keyService.setPrivateKey).not.toHaveBeenCalled(); + expect(accountCryptographicStateService.setAccountCryptographicState).not.toHaveBeenCalled(); expect(logService.error).toHaveBeenCalledWith( "[UserAsymmetricKeyRegeneration] Cannot regenerate asymmetric keys for accounts on V2 encryption.", ); @@ -382,6 +386,7 @@ describe("regenerateUserPublicKeyEncryptionKeyPair", () => { let sdkService: MockSdkService; let apiService: MockProxy<ApiService>; let configService: MockProxy<ConfigService>; + let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>; beforeEach(() => { keyService = mock<KeyService>(); @@ -391,6 +396,7 @@ describe("regenerateUserPublicKeyEncryptionKeyPair", () => { sdkService = new MockSdkService(); apiService = mock<ApiService>(); configService = mock<ConfigService>(); + accountCryptographicStateService = mock<AccountCryptographicStateService>(); sut = new DefaultUserAsymmetricKeysRegenerationService( keyService, @@ -400,6 +406,7 @@ describe("regenerateUserPublicKeyEncryptionKeyPair", () => { sdkService, apiService, configService, + accountCryptographicStateService, ); }); diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts index 36bf9c8a421..a3bde5326c5 100644 --- a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts +++ b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts @@ -2,6 +2,7 @@ import { combineLatest, firstValueFrom, map } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -24,6 +25,7 @@ export class DefaultUserAsymmetricKeysRegenerationService implements UserAsymmet private sdkService: SdkService, private apiService: ApiService, private configService: ConfigService, + private accountCryptographyStateService: AccountCryptographicStateService, ) {} async regenerateIfNeeded(userId: UserId): Promise<void> { @@ -123,7 +125,7 @@ export class DefaultUserAsymmetricKeysRegenerationService implements UserAsymmet return false; } - async regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<void> { + async regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<boolean> { const userKey = await firstValueFrom(this.keyService.userKey$(userId)); if (userKey == null) { throw new Error("User key not found"); @@ -152,19 +154,28 @@ export class DefaultUserAsymmetricKeysRegenerationService implements UserAsymmet this.logService.info( "[UserAsymmetricKeyRegeneration] Regeneration not supported for this user at this time.", ); + return false; } else { this.logService.error( "[UserAsymmetricKeyRegeneration] Regeneration error when submitting the request to the server: " + error, ); + return false; } - return; } - await this.keyService.setPrivateKey(makeKeyPairResponse.userKeyEncryptedPrivateKey, userId); + await this.accountCryptographyStateService.setAccountCryptographicState( + { + V1: { + private_key: makeKeyPairResponse.userKeyEncryptedPrivateKey, + }, + }, + userId, + ); this.logService.info( "[UserAsymmetricKeyRegeneration] User's asymmetric keys successfully regenerated.", ); + return true; } private async userKeyCanDecrypt(userKey: UserKey, userId: UserId): Promise<boolean> { diff --git a/libs/nx-plugin/src/generators/basic-lib.spec.ts b/libs/nx-plugin/src/generators/basic-lib.spec.ts index 9fd7a702375..2018593046b 100644 --- a/libs/nx-plugin/src/generators/basic-lib.spec.ts +++ b/libs/nx-plugin/src/generators/basic-lib.spec.ts @@ -24,7 +24,7 @@ describe("basic-lib generator", () => { expect(tsconfigContent).not.toBeNull(); const tsconfig = JSON.parse(tsconfigContent?.toString() ?? ""); expect(tsconfig.compilerOptions.paths[`@bitwarden/${options.name}`]).toEqual([ - `libs/test/src/index.ts`, + `./libs/test/src/index.ts`, ]); }); diff --git a/libs/nx-plugin/src/generators/basic-lib.ts b/libs/nx-plugin/src/generators/basic-lib.ts index 4f2f542ac89..c0d8a528841 100644 --- a/libs/nx-plugin/src/generators/basic-lib.ts +++ b/libs/nx-plugin/src/generators/basic-lib.ts @@ -82,7 +82,7 @@ function updateTsConfigPath(tree: Tree, name: string, srcRoot: string) { updateJson(tree, "tsconfig.base.json", (json) => { const paths = json.compilerOptions.paths || {}; - paths[`@bitwarden/${name}`] = [`${srcRoot}/index.ts`]; + paths[`@bitwarden/${name}`] = [`./${srcRoot}/index.ts`]; json.compilerOptions.paths = paths; return json; diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.html b/libs/pricing/src/components/cart-summary/cart-summary.component.html index e2fe7d80dc0..4e3e75baed9 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.html +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.html @@ -1,6 +1,6 @@ @let cart = this.cart(); @let term = this.term(); - +@let hideTerm = this.hidePricingTerm(); <div class="tw-size-full"> <div class="tw-flex tw-items-center tw-pb-2"> <div class="tw-flex tw-items-center"> @@ -16,7 +16,9 @@ {{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD </h2> <span bitTypography="h3"> </span> - <span bitTypography="body1" class="tw-text-main">/ {{ term }}</span> + @if (!hideTerm) { + <span bitTypography="body1" class="tw-text-muted tw-ms-2"> / {{ term | i18n }} </span> + } } </div> <button @@ -38,21 +40,38 @@ <!-- Password Manager Section --> <div id="password-manager" class="tw-border-b tw-border-secondary-100 tw-pb-2"> <div class="tw-flex tw-justify-between tw-mb-1"> - <h3 bitTypography="h5" class="tw-text-muted">{{ "passwordManager" | i18n }}</h3> + <h3 bitTypography="h5" class="tw-text-muted tw-font-semibold"> + {{ "passwordManager" | i18n }} + </h3> </div> <!-- Password Manager Members --> <div id="password-manager-members" class="tw-flex tw-justify-between"> <div class="tw-flex-1"> @let passwordManagerSeats = cart.passwordManager.seats; - <div bitTypography="body1" class="tw-text-muted"> - {{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.name | i18n }} x - {{ passwordManagerSeats.cost | currency: "USD" : "symbol" }} - / - {{ term }} + <div bitTypography="body1" class="tw-text-muted tw-font-normal"> + {{ passwordManagerSeats.quantity }} + {{ + translateWithParams( + passwordManagerSeats.translationKey, + passwordManagerSeats.translationParams + ) + }} + @if (!passwordManagerSeats.hideBreakdown) { + x + {{ passwordManagerSeats.cost | currency: "USD" : "symbol" }} + @if (!hideTerm) { + / + {{ term }} + } + } </div> </div> - <div bitTypography="body1" class="tw-text-muted" data-testid="password-manager-total"> + <div + bitTypography="body1" + class="tw-text-muted tw-font-normal" + data-testid="password-manager-total" + > {{ passwordManagerSeatsTotal() | currency: "USD" : "symbol" }} </div> </div> @@ -62,13 +81,28 @@ @if (additionalStorage) { <div id="additional-storage" class="tw-flex tw-justify-between"> <div class="tw-flex-1"> - <div bitTypography="body1" class="tw-text-muted"> - {{ additionalStorage.quantity }} {{ additionalStorage.name | i18n }} x - {{ additionalStorage.cost | currency: "USD" : "symbol" }} / - {{ term }} + <div bitTypography="body1" class="tw-text-muted tw-font-normal"> + {{ additionalStorage.quantity }} + {{ + translateWithParams( + additionalStorage.translationKey, + additionalStorage.translationParams + ) + }} + @if (!additionalStorage.hideBreakdown) { + x {{ additionalStorage.cost | currency: "USD" : "symbol" }} + @if (!hideTerm) { + / + {{ term }} + } + } </div> </div> - <div bitTypography="body1" class="tw-text-muted" data-testid="additional-storage-total"> + <div + bitTypography="body1" + class="tw-text-muted tw-font-normal" + data-testid="additional-storage-total" + > {{ additionalStorageTotal() | currency: "USD" : "symbol" }} </div> </div> @@ -80,19 +114,33 @@ @if (secretsManagerSeats) { <div id="secrets-manager" class="tw-border-b tw-border-secondary-100 tw-py-2"> <div class="tw-flex tw-justify-between"> - <h3 bitTypography="h5" class="tw-text-muted">{{ "secretsManager" | i18n }}</h3> + <div bitTypography="h5" class="tw-text-muted tw-font-semibold"> + {{ "secretsManager" | i18n }} + </div> </div> <!-- Secrets Manager Members --> <div id="secrets-manager-members" class="tw-flex tw-justify-between"> - <div bitTypography="body1" class="tw-text-muted"> - {{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.name | i18n }} x - {{ secretsManagerSeats.cost | currency: "USD" : "symbol" }} - / {{ term }} + <div bitTypography="body1" class="tw-text-muted tw-font-normal"> + {{ secretsManagerSeats.quantity }} + {{ + translateWithParams( + secretsManagerSeats.translationKey, + secretsManagerSeats.translationParams + ) + }} + @if (!secretsManagerSeats.hideBreakdown) { + x + {{ secretsManagerSeats.cost | currency: "USD" : "symbol" }} + @if (!hideTerm) { + / + {{ term }} + } + } </div> <div bitTypography="body1" - class="tw-text-muted" + class="tw-text-muted tw-font-normal" data-testid="secrets-manager-seats-total" > {{ secretsManagerSeatsTotal() | currency: "USD" : "symbol" }} @@ -103,12 +151,22 @@ @let additionalServiceAccounts = cart.secretsManager?.additionalServiceAccounts; @if (additionalServiceAccounts) { <div id="additional-service-accounts" class="tw-flex tw-justify-between"> - <div bitTypography="body1" class="tw-text-muted"> + <div bitTypography="body1" class="tw-text-muted tw-font-normal"> {{ additionalServiceAccounts.quantity }} - {{ additionalServiceAccounts.name | i18n }} x - {{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }} - / - {{ term }} + {{ + translateWithParams( + additionalServiceAccounts.translationKey, + additionalServiceAccounts.translationParams + ) + }} + @if (!additionalServiceAccounts.hideBreakdown) { + x + {{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }} + @if (!hideTerm) { + / + {{ term }} + } + } </div> <div bitTypography="body1" @@ -129,19 +187,41 @@ class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-py-2" data-testid="discount-section" > - <h3 bitTypography="h5" class="tw-text-success-600">{{ discountLabel() }}</h3> + <div bitTypography="body1" class="tw-text-success-600">{{ discountLabel() }}</div> <div bitTypography="body1" class="tw-text-success-600" data-testid="discount-amount"> -{{ discountAmount() | currency: "USD" : "symbol" }} </div> </div> } + <!-- Credit --> + @if (creditAmount() > 0) { + <div + id="credit-section" + class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-py-2" + data-testid="credit-section" + > + <div bitTypography="body1" class="tw-text-muted tw-font-normal"> + {{ translateWithParams(cart.credit!.translationKey, cart.credit!.translationParams) }} + </div> + <div + bitTypography="body1" + class="tw-text-muted tw-font-normal" + data-testid="credit-amount" + > + -{{ creditAmount() | currency: "USD" : "symbol" }} + </div> + </div> + } + <!-- Estimated Tax --> <div id="estimated-tax-section" class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-pt-2 tw-pb-0.5" > - <h3 bitTypography="h5" class="tw-text-muted">{{ "estimatedTax" | i18n }}</h3> + <h3 bitTypography="h5" class="tw-text-muted tw-font-semibold"> + {{ "estimatedTax" | i18n }} + </h3> <div bitTypography="body1" class="tw-text-muted" data-testid="estimated-tax"> {{ estimatedTax() | currency: "USD" : "symbol" }} </div> @@ -149,9 +229,12 @@ <!-- Total --> <div id="total-section" class="tw-flex tw-justify-between tw-items-center tw-pt-2"> - <h3 bitTypography="h5" class="tw-text-muted">{{ "total" | i18n }}</h3> + <h3 bitTypography="h5" class="tw-text-muted tw-font-semibold">{{ "total" | i18n }}</h3> <div bitTypography="body1" class="tw-text-muted" data-testid="final-total"> - {{ total() | currency: "USD" : "symbol" }} / {{ term | i18n }} + {{ total() | currency: "USD" : "symbol" }} + @if (!hidePricingTerm()) { + / {{ term | i18n }} + } </div> </div> </div> diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.mdx b/libs/pricing/src/components/cart-summary/cart-summary.component.mdx index 02e705276bc..65b5b7ea037 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.mdx +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.mdx @@ -25,8 +25,12 @@ behavior across Bitwarden applications. - [With Secrets Manager](#with-secrets-manager) - [With Secrets Manager and Additional Service Accounts](#with-secrets-manager-and-additional-service-accounts) - [All Products](#all-products) + - [With Account Credit](#with-account-credit) - [With Percent Discount](#with-percent-discount) - [With Amount Discount](#with-amount-discount) + - [With Discount and Credit](#with-discount-and-credit) + - [Hidden Cost Breakdown](#hidden-cost-breakdown) + - [Hidden Pricing Term](#hidden-pricing-term) - [Custom Header Template](#custom-header-template) - [Premium Plan](#premium-plan) - [Families Plan](#families-plan) @@ -51,10 +55,16 @@ import { CartSummaryComponent, Cart } from "@bitwarden/pricing"; ### Inputs -| Input | Type | Description | -| -------- | ------------------------ | ------------------------------------------------------------------------------- | -| `cart` | `Cart` | **Required.** The cart data containing all products, discount, tax, and cadence | -| `header` | `TemplateRef<{ total }>` | **Optional.** Custom header template to replace the default header | +| Input | Type | Description | +| ----------------- | ------------------------ | ------------------------------------------------------------------------------------------- | +| `cart` | `Cart` | **Required.** The cart data containing all products, discount, tax, and cadence | +| `header` | `TemplateRef<{ total }>` | **Optional.** Custom header template to replace the default header | +| `hidePricingTerm` | `boolean` | **Optional.** When true, hides the billing term (e.g., "/ month", "/ year") from the header | + +**Note:** Individual `CartItem` objects in the cart can include: + +- `hideBreakdown` (boolean): Hides the cost breakdown (quantity × unit price) for that specific line + item ### Events @@ -67,10 +77,11 @@ The component uses the following Cart and CartItem data structures: ```typescript export type CartItem = { - name: string; // Display name for i18n lookup + translationKey: string; // Translation key for i18n lookup quantity: number; // Number of items cost: number; // Cost per item discount?: Discount; // Optional item-level discount + hideBreakdown?: boolean; // Optional: hide cost breakdown (quantity × unit price) }; export type Cart = { @@ -85,14 +96,20 @@ export type Cart = { }; cadence: "annually" | "monthly"; // Billing period for entire cart discount?: Discount; // Optional cart-level discount + credit?: Credit; // Optional account credit estimatedTax: number; // Tax amount }; +export type Credit = { + translationKey: string; // Translation key for credit label + translationParams?: Array<string | number>; // Optional params for translation + value: number; // Credit amount to subtract from subtotal +}; + import { DiscountTypes, DiscountType } from "@bitwarden/pricing"; export type Discount = { type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff - active: boolean; // Whether discount is currently applied value: number; // Dollar amount or percentage (20 for 20%) }; ``` @@ -108,7 +125,7 @@ The cart summary component provides flexibility through its structured Cart inpu passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, @@ -124,12 +141,12 @@ The cart summary component provides flexibility through its structured Cart inpu passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, @@ -145,14 +162,13 @@ The cart summary component provides flexibility through its structured Cart inpu passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, cadence: 'monthly', discount: { type: 'percent-off', - active: true, value: 20 }, estimatedTax: 8.00 @@ -188,7 +204,7 @@ Show cart with yearly subscription: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 500.00 } }, @@ -211,12 +227,12 @@ Show cart with password manager and additional storage: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, @@ -239,14 +255,14 @@ Show cart with password manager and secrets manager seats only: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 } }, @@ -269,19 +285,19 @@ Show cart with password manager, secrets manager seats, and additional service a passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 }, additionalServiceAccounts: { quantity: 2, - name: 'additionalServiceAccounts', + translationKey: 'additionalServiceAccounts', cost: 6.00 } }, @@ -304,24 +320,24 @@ Show a cart with all available products: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 }, additionalServiceAccounts: { quantity: 2, - name: 'additionalServiceAccounts', + translationKey: 'additionalServiceAccounts', cost: 6.00 } }, @@ -332,6 +348,33 @@ Show a cart with all available products: </billing-cart-summary> ``` +### With Account Credit + +Show cart with account credit applied: + +<Canvas of={CartSummaryStories.WithCredit} /> + +```html +<billing-cart-summary + [cart]="{ + passwordManager: { + seats: { + quantity: 5, + translationKey: 'members', + cost: 50.00 + } + }, + cadence: 'monthly', + credit: { + translationKey: 'accountCredit', + value: 25.00 + }, + estimatedTax: 10.00 + }" +> +</billing-cart-summary> +``` + ### With Percent Discount Show cart with percentage-based discount: @@ -344,19 +387,18 @@ Show cart with percentage-based discount: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, cadence: 'monthly', discount: { type: 'percent-off', - active: true, value: 20 }, estimatedTax: 10.40 @@ -377,21 +419,20 @@ Show cart with fixed amount discount: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 } }, cadence: 'annually', discount: { type: 'amount-off', - active: true, value: 50.00 }, estimatedTax: 95.00 @@ -400,6 +441,110 @@ Show cart with fixed amount discount: </billing-cart-summary> ``` +### With Discount and Credit + +Show cart with both discount and credit applied: + +<Canvas of={CartSummaryStories.WithDiscountAndCredit} /> + +```html +<billing-cart-summary + [cart]="{ + passwordManager: { + seats: { + quantity: 5, + translationKey: 'members', + cost: 50.00 + }, + additionalStorage: { + quantity: 2, + translationKey: 'additionalStorageGB', + cost: 10.00 + } + }, + cadence: 'annually', + discount: { + type: 'percent-off', + value: 15 + }, + credit: { + translationKey: 'accountCredit', + value: 50.00 + }, + estimatedTax: 15.00 + }" +> +</billing-cart-summary> +``` + +### Hidden Cost Breakdown + +Show cart with hidden cost breakdowns (hides quantity × unit price for line items): + +<Canvas of={CartSummaryStories.WithHiddenBreakdown} /> + +```html +<billing-cart-summary + [cart]="{ + passwordManager: { + seats: { + quantity: 5, + translationKey: 'members', + cost: 50.00, + hideBreakdown: true + }, + additionalStorage: { + quantity: 2, + translationKey: 'additionalStorageGB', + cost: 10.00, + hideBreakdown: true + } + }, + secretsManager: { + seats: { + quantity: 3, + translationKey: 'members', + cost: 30.00, + hideBreakdown: true + }, + additionalServiceAccounts: { + quantity: 2, + translationKey: 'additionalServiceAccountsV2', + cost: 6.00, + hideBreakdown: true + } + }, + cadence: 'monthly', + estimatedTax: 19.2 + }" +> +</billing-cart-summary> +``` + +### Hidden Pricing Term + +Show cart with hidden pricing term (hides "/ month" or "/ year" from header): + +<Canvas of={CartSummaryStories.HiddenPricingTerm} /> + +```html +<billing-cart-summary + [cart]="{ + passwordManager: { + seats: { + quantity: 5, + translationKey: 'members', + cost: 50.00 + } + }, + cadence: 'monthly', + estimatedTax: 9.6 + }" + [hidePricingTerm]="true" +> +</billing-cart-summary> +``` + ### Custom Header Template Show cart with custom header template: @@ -431,7 +576,7 @@ Show cart with premium plan: passwordManager: { seats: { quantity: 1, - name: 'premiumMembership', + translationKey: 'premiumMembership', cost: 10.00 } }, @@ -454,7 +599,7 @@ Show cart with families plan: passwordManager: { seats: { quantity: 1, - name: 'familiesMembership', + translationKey: 'familiesMembership', cost: 40.00 } }, @@ -470,12 +615,18 @@ Show cart with families plan: - **Collapsible Interface**: Users can toggle between a summary view showing only the total and a detailed view showing all line items - **Line Item Grouping**: Organizes items by product category (Password Manager, Secrets Manager) -- **Dynamic Calculations**: Automatically calculates subtotals, discounts, taxes, and totals using - Angular signals and computed values +- **Dynamic Calculations**: Automatically calculates subtotals, discounts, credits, taxes, and + totals using Angular signals and computed values - **Discount Support**: Displays both percentage-based and fixed-amount discounts with green success styling +- **Credit Support**: Shows account credit deductions with clear labeling using i18n translation + keys - **Custom Header Templates**: Optional header input allows for custom header designs while maintaining cart functionality +- **Hidden Cost Breakdown**: Individual cart items can hide their cost breakdown (quantity × unit + price) using the `hideBreakdown` property +- **Hidden Pricing Term**: Component can hide the billing term ("/ month" or "/ year") from the + header using the `hidePricingTerm` input - **Flexible Structure**: Accommodates different combinations of products, add-ons, and discounts - **Consistent Formatting**: Maintains uniform display of prices, quantities, and cadence - **Modern Angular Patterns**: Uses `@let` to efficiently store and reuse signal values, OnPush @@ -488,10 +639,12 @@ Show cart with families plan: - Use consistent naming and formatting for cart items - Include clear quantity and unit pricing information - Ensure tax estimates are accurate and clearly labeled -- Set `active: true` on discounts that should be displayed -- Use localized strings for CartItem names (for i18n lookup) +- Use valid translation keys for CartItem translationKey (for i18n lookup) - Provide complete Cart object with all required fields - Use "annually" or "monthly" for cadence (not "year" or "month") +- Use `hideBreakdown` on individual cart items when you want to hide cost breakdowns +- Use the `hidePricingTerm` component input when the billing term shouldn't be displayed in the + header ### ❌ Don't diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts index f019322e4db..ac5dfcc610a 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts @@ -16,24 +16,24 @@ describe("CartSummaryComponent", () => { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10, }, }, secretsManager: { seats: { quantity: 3, - name: "secretsManagerSeats", + translationKey: "secretsManagerSeats", cost: 30, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6, }, }, @@ -89,6 +89,8 @@ describe("CartSummaryComponent", () => { return "Premium membership"; case "discount": return "discount"; + case "accountCredit": + return "accountCredit"; default: return key; } @@ -190,7 +192,7 @@ describe("CartSummaryComponent", () => { it("should display correct secrets manager information", () => { // Arrange const smSection = fixture.debugElement.query(By.css('[id="secrets-manager"]')); - const smHeading = smSection.query(By.css("h3")); + const smHeading = smSection?.query(By.css('div[bitTypography="h5"]')); const sectionText = fixture.debugElement.query(By.css('[id="secrets-manager-members"]')) .nativeElement.textContent; const additionalSA = fixture.debugElement.query(By.css('[id="additional-service-accounts"]')) @@ -198,7 +200,8 @@ describe("CartSummaryComponent", () => { // Act/ Assert expect(smSection).toBeTruthy(); - expect(smHeading.nativeElement.textContent.trim()).toBe("Secrets Manager"); + expect(smHeading).toBeTruthy(); + expect(smHeading!.nativeElement.textContent.trim()).toBe("Secrets Manager"); // Check seats line item expect(sectionText).toContain("3 Secrets Manager seats"); @@ -243,7 +246,7 @@ describe("CartSummaryComponent", () => { it("should display term (month/year) in default header", () => { // Arrange / Act - const allSpans = fixture.debugElement.queryAll(By.css("span.tw-text-main")); + const allSpans = fixture.debugElement.queryAll(By.css("span.tw-text-muted")); // Find the span that contains the term const termElement = allSpans.find((span) => span.nativeElement.textContent.includes("/")); @@ -251,6 +254,162 @@ describe("CartSummaryComponent", () => { expect(termElement).toBeTruthy(); expect(termElement!.nativeElement.textContent.trim()).toBe("/ month"); }); + + it("should hide term when hidePricingTerm is true", () => { + // Arrange + const cartWithHiddenTerm: Cart = { + ...mockCart, + }; + fixture.componentRef.setInput("cart", cartWithHiddenTerm); + fixture.componentRef.setInput("hidePricingTerm", true); + fixture.detectChanges(); + + // Act + const allSpans = fixture.debugElement.queryAll(By.css("span.tw-text-muted")); + const termElement = allSpans.find((span) => span.nativeElement.textContent.includes("/")); + + // Assert + expect(component.hidePricingTerm()).toBe(true); + expect(termElement).toBeFalsy(); + }); + + it("should show term when hidePricingTerm is false", () => { + // Arrange + const cartWithVisibleTerm: Cart = { + ...mockCart, + }; + fixture.componentRef.setInput("cart", cartWithVisibleTerm); + fixture.detectChanges(); + + // Act + const allSpans = fixture.debugElement.queryAll(By.css("span.tw-text-muted")); + const termElement = allSpans.find((span) => span.nativeElement.textContent.includes("/")); + + // Assert + expect(component.hidePricingTerm()).toBe(false); + expect(termElement).toBeTruthy(); + expect(termElement!.nativeElement.textContent).toContain("/ month"); + }); + }); + + describe("hideBreakdown Property", () => { + it("should hide cost breakdown when hideBreakdown is true for password manager seats", () => { + // Arrange + const cartWithHiddenBreakdown: Cart = { + ...mockCart, + passwordManager: { + seats: { + quantity: 5, + translationKey: "members", + cost: 50, + hideBreakdown: true, + }, + }, + }; + fixture.componentRef.setInput("cart", cartWithHiddenBreakdown); + fixture.detectChanges(); + + const pmLineItem = fixture.debugElement.query( + By.css('[id="password-manager-members"] .tw-flex-1 .tw-text-muted'), + ); + + // Act / Assert + expect(pmLineItem.nativeElement.textContent).toContain("5 Members"); + }); + + it("should show cost breakdown when hideBreakdown is false for password manager seats", () => { + // Arrange / Act + const pmLineItem = fixture.debugElement.query( + By.css('[id="password-manager-members"] .tw-flex-1 .tw-text-muted'), + ); + + // Assert + expect(pmLineItem.nativeElement.textContent).toContain("5 Members x $50.00 / month"); + }); + + it("should hide cost breakdown for additional storage when hideBreakdown is true", () => { + // Arrange + const cartWithHiddenBreakdown: Cart = { + ...mockCart, + passwordManager: { + ...mockCart.passwordManager, + additionalStorage: { + quantity: 2, + translationKey: "additionalStorageGB", + cost: 10, + hideBreakdown: true, + }, + }, + }; + fixture.componentRef.setInput("cart", cartWithHiddenBreakdown); + fixture.detectChanges(); + + const storageItem = fixture.debugElement.query(By.css("[id='additional-storage']")); + const storageLineItem = storageItem.query(By.css(".tw-flex-1 .tw-text-muted")); + const storageTotal = storageItem.query(By.css("[data-testid='additional-storage-total']")); + + // Act / Assert + expect(storageLineItem.nativeElement.textContent).toContain("2 Additional storage GB"); + expect(storageTotal.nativeElement.textContent).toContain("$20.00"); + }); + + it("should hide cost breakdown for secrets manager seats when hideBreakdown is true", () => { + // Arrange + const cartWithHiddenBreakdown: Cart = { + ...mockCart, + secretsManager: { + seats: { + quantity: 3, + translationKey: "secretsManagerSeats", + cost: 30, + hideBreakdown: true, + }, + additionalServiceAccounts: mockCart.secretsManager!.additionalServiceAccounts, + }, + }; + fixture.componentRef.setInput("cart", cartWithHiddenBreakdown); + fixture.detectChanges(); + + const smLineItem = fixture.debugElement.query( + By.css('[id="secrets-manager-members"] .tw-text-muted'), + ); + const smTotal = fixture.debugElement.query( + By.css('[data-testid="secrets-manager-seats-total"]'), + ); + + // Act / Assert + expect(smLineItem.nativeElement.textContent).toContain("3 Secrets Manager seats"); + expect(smTotal.nativeElement.textContent).toContain("$90.00"); + }); + + it("should hide cost breakdown for additional service accounts when hideBreakdown is true", () => { + // Arrange + const cartWithHiddenBreakdown: Cart = { + ...mockCart, + secretsManager: { + seats: mockCart.secretsManager!.seats, + additionalServiceAccounts: { + quantity: 2, + translationKey: "additionalServiceAccountsV2", + cost: 6, + hideBreakdown: true, + }, + }, + }; + fixture.componentRef.setInput("cart", cartWithHiddenBreakdown); + fixture.detectChanges(); + + const saLineItem = fixture.debugElement.query( + By.css('[id="additional-service-accounts"] .tw-text-muted'), + ); + const saTotal = fixture.debugElement.query( + By.css('[data-testid="additional-service-accounts-total"]'), + ); + + // Act / Assert + expect(saLineItem.nativeElement.textContent).toContain("2 Additional machine accounts"); + expect(saTotal.nativeElement.textContent).toContain("$12.00"); + }); }); describe("Discount Display", () => { @@ -270,7 +429,6 @@ describe("CartSummaryComponent", () => { ...mockCart, discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, }, }; @@ -280,7 +438,7 @@ describe("CartSummaryComponent", () => { const discountSection = fixture.debugElement.query( By.css('[data-testid="discount-section"]'), ); - const discountLabel = discountSection.query(By.css("h3")); + const discountLabel = discountSection.query(By.css("div.tw-text-success-600")); const discountAmount = discountSection.query(By.css('[data-testid="discount-amount"]')); // Act / Assert @@ -296,7 +454,6 @@ describe("CartSummaryComponent", () => { ...mockCart, discount: { type: DiscountTypes.AmountOff, - active: true, value: 50.0, }, }; @@ -306,7 +463,7 @@ describe("CartSummaryComponent", () => { const discountSection = fixture.debugElement.query( By.css('[data-testid="discount-section"]'), ); - const discountLabel = discountSection.query(By.css("h3")); + const discountLabel = discountSection.query(By.css("div.tw-text-success-600")); const discountAmount = discountSection.query(By.css('[data-testid="discount-amount"]')); // Act / Assert @@ -315,33 +472,12 @@ describe("CartSummaryComponent", () => { expect(discountAmount.nativeElement.textContent).toContain("-$50.00"); }); - it("should not display discount when discount is inactive", () => { - // Arrange - const cartWithInactiveDiscount: Cart = { - ...mockCart, - discount: { - type: DiscountTypes.PercentOff, - active: false, - value: 20, - }, - }; - fixture.componentRef.setInput("cart", cartWithInactiveDiscount); - fixture.detectChanges(); - - // Act / Assert - const discountSection = fixture.debugElement.query( - By.css('[data-testid="discount-section"]'), - ); - expect(discountSection).toBeFalsy(); - }); - it("should apply discount to total calculation", () => { // Arrange const cartWithDiscount: Cart = { ...mockCart, discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, }, }; @@ -359,6 +495,94 @@ describe("CartSummaryComponent", () => { expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal); }); }); + + describe("Credit Display", () => { + it("should not display credit section when no credit is present", () => { + // Arrange / Act + const creditSection = fixture.debugElement.query(By.css('[data-testid="credit-section"]')); + + // Assert + expect(creditSection).toBeFalsy(); + }); + + it("should display credit correctly", () => { + // Arrange + const cartWithCredit: Cart = { + ...mockCart, + credit: { + translationKey: "accountCredit", + value: 25.0, + }, + }; + fixture.componentRef.setInput("cart", cartWithCredit); + fixture.detectChanges(); + + const creditSection = fixture.debugElement.query(By.css('[data-testid="credit-section"]')); + const creditLabel = creditSection.query(By.css('div[bitTypography="body1"]')); + const creditAmount = creditSection.query(By.css('[data-testid="credit-amount"]')); + + // Act / Assert + expect(creditSection).toBeTruthy(); + expect(creditLabel.nativeElement.textContent.trim()).toBe("accountCredit"); + expect(creditAmount.nativeElement.textContent).toContain("-$25.00"); + }); + + it("should apply credit to total calculation", () => { + // Arrange + const cartWithCredit: Cart = { + ...mockCart, + credit: { + translationKey: "accountCredit", + value: 50.0, + }, + }; + fixture.componentRef.setInput("cart", cartWithCredit); + fixture.detectChanges(); + + // Subtotal = 372, credit = 50, tax = 9.6 + // Total = 372 - 50 + 9.6 = 331.6 + const expectedTotal = "$331.60"; + const topTotal = fixture.debugElement.query(By.css("h2")); + const bottomTotal = fixture.debugElement.query(By.css("[data-testid='final-total']")); + + // Act / Assert + expect(topTotal.nativeElement.textContent).toContain(expectedTotal); + expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal); + }); + + it("should display and apply both discount and credit correctly", () => { + // Arrange + const cartWithBoth: Cart = { + ...mockCart, + discount: { + type: DiscountTypes.PercentOff, + value: 10, + }, + credit: { + translationKey: "accountCredit", + value: 30.0, + }, + }; + fixture.componentRef.setInput("cart", cartWithBoth); + fixture.detectChanges(); + + // Subtotal = 372, discount = 37.2 (10%), credit = 30, tax = 9.6 + // Total = 372 - 37.2 - 30 + 9.6 = 314.4 + const expectedTotal = "$314.40"; + const discountSection = fixture.debugElement.query( + By.css('[data-testid="discount-section"]'), + ); + const creditSection = fixture.debugElement.query(By.css('[data-testid="credit-section"]')); + const topTotal = fixture.debugElement.query(By.css("h2")); + const bottomTotal = fixture.debugElement.query(By.css("[data-testid='final-total']")); + + // Act / Assert + expect(discountSection).toBeTruthy(); + expect(creditSection).toBeTruthy(); + expect(topTotal.nativeElement.textContent).toContain(expectedTotal); + expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal); + }); + }); }); describe("CartSummaryComponent - Custom Header Template", () => { @@ -382,24 +606,24 @@ describe("CartSummaryComponent - Custom Header Template", () => { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10, }, }, secretsManager: { seats: { quantity: 3, - name: "secretsManagerSeats", + translationKey: "secretsManagerSeats", cost: 30, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6, }, }, @@ -447,6 +671,8 @@ describe("CartSummaryComponent - Custom Header Template", () => { return "Collapse purchase details"; case "discount": return "discount"; + case "accountCredit": + return "accountCredit"; default: return key; } diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts index aed23c54a30..f51919e45e7 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts @@ -57,6 +57,8 @@ export default { return "Your next charge is for"; case "dueOn": return "due on"; + case "premiumSubscriptionCredit": + return "Premium subscription credit"; default: return key; } @@ -71,7 +73,7 @@ export default { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, @@ -98,12 +100,12 @@ export const WithAdditionalStorage: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10.0, }, }, @@ -120,7 +122,7 @@ export const PasswordManagerYearlyCadence: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 500.0, }, }, @@ -137,14 +139,14 @@ export const SecretsManagerSeatsOnly: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, }, @@ -161,19 +163,19 @@ export const SecretsManagerSeatsAndServiceAccounts: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6.0, }, }, @@ -190,24 +192,24 @@ export const AllProducts: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6.0, }, }, @@ -223,7 +225,7 @@ export const FamiliesPlan: Story = { passwordManager: { seats: { quantity: 1, - name: "familiesMembership", + translationKey: "familiesMembership", cost: 40.0, }, }, @@ -239,7 +241,7 @@ export const PremiumPlan: Story = { passwordManager: { seats: { quantity: 1, - name: "premiumMembership", + translationKey: "premiumMembership", cost: 10.0, }, }, @@ -255,7 +257,7 @@ export const CustomHeaderTemplate: Story = { passwordManager: { seats: { quantity: 1, - name: "premiumMembership", + translationKey: "premiumMembership", cost: 10.0, }, }, @@ -296,19 +298,18 @@ export const WithPercentDiscount: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10.0, }, }, cadence: "monthly", discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, }, estimatedTax: 10.4, @@ -322,24 +323,130 @@ export const WithAmountDiscount: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, }, cadence: "annually", discount: { type: DiscountTypes.AmountOff, - active: true, value: 50.0, }, estimatedTax: 95.0, } satisfies Cart, }, }; + +export const WithHiddenBreakdown: Story = { + name: "Hidden Cost Breakdown", + args: { + cart: { + passwordManager: { + seats: { + quantity: 5, + translationKey: "members", + cost: 50.0, + hideBreakdown: true, + }, + additionalStorage: { + quantity: 2, + translationKey: "additionalStorageGB", + cost: 10.0, + hideBreakdown: true, + }, + }, + secretsManager: { + seats: { + quantity: 3, + translationKey: "members", + cost: 30.0, + hideBreakdown: true, + }, + additionalServiceAccounts: { + quantity: 2, + translationKey: "additionalServiceAccountsV2", + cost: 6.0, + hideBreakdown: true, + }, + }, + cadence: "monthly", + estimatedTax: 19.2, + } satisfies Cart, + }, +}; + +export const WithCredit: Story = { + name: "With Account Credit", + args: { + cart: { + passwordManager: { + seats: { + quantity: 5, + translationKey: "members", + cost: 50.0, + }, + }, + cadence: "monthly", + credit: { + translationKey: "premiumSubscriptionCredit", + value: 25.0, + }, + estimatedTax: 10.0, + } satisfies Cart, + }, +}; + +export const WithDiscountAndCredit: Story = { + name: "With Both Discount and Credit", + args: { + cart: { + passwordManager: { + seats: { + quantity: 5, + translationKey: "members", + cost: 50.0, + }, + additionalStorage: { + quantity: 2, + translationKey: "additionalStorageGB", + cost: 10.0, + }, + }, + cadence: "annually", + discount: { + type: DiscountTypes.PercentOff, + value: 15, + }, + credit: { + translationKey: "premiumSubscriptionCredit", + value: 50.0, + }, + estimatedTax: 15.0, + } satisfies Cart, + }, +}; + +export const HiddenPricingTerm: Story = { + name: "Hidden Pricing Term", + args: { + cart: { + passwordManager: { + seats: { + quantity: 5, + translationKey: "members", + cost: 50.0, + }, + }, + cadence: "monthly", + estimatedTax: 9.6, + } satisfies Cart, + hidePricingTerm: true, + }, +}; diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.ts index b92a465169c..c98340defeb 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.ts @@ -37,6 +37,9 @@ export class CartSummaryComponent { // Optional inputs readonly header = input<TemplateRef<{ total: number }>>(); + // Hide pricing term (e.g., "/ month" or "/ year") if true + readonly hidePricingTerm = input<boolean>(false); + // UI state readonly isExpanded = signal(true); @@ -116,7 +119,7 @@ export class CartSummaryComponent { */ readonly discountAmount = computed<number>(() => { const { discount } = this.cart(); - if (!discount || !discount.active) { + if (!discount) { return 0; } @@ -136,17 +139,28 @@ export class CartSummaryComponent { */ readonly discountLabel = computed<string>(() => { const { discount } = this.cart(); - if (!discount || !discount.active) { + if (!discount) { return ""; } return getLabel(this.i18nService, discount); }); + /** + * Calculates the credit amount from the cart credit + */ + readonly creditAmount = computed<number>(() => { + const { credit } = this.cart(); + if (!credit) { + return 0; + } + return credit.value; + }); + /** * Calculates the total of all line items including discount and tax */ readonly total = computed<number>( - () => this.subtotal() - this.discountAmount() + this.estimatedTax(), + () => this.subtotal() - this.discountAmount() - this.creditAmount() + this.estimatedTax(), ); /** @@ -154,6 +168,16 @@ export class CartSummaryComponent { */ readonly total$ = toObservable(this.total); + /** + * Translates a key with optional parameters + */ + translateWithParams(key: string, params?: Array<string | number>): string { + if (!params || params.length === 0) { + return this.i18nService.t(key); + } + return this.i18nService.t(key, ...params); + } + /** * Toggles the expanded/collapsed state of the cart items */ diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.mdx b/libs/pricing/src/components/discount-badge/discount-badge.component.mdx index f9b9ba85619..8988f79ea07 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.mdx +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.mdx @@ -38,8 +38,6 @@ import { DiscountTypes, DiscountType } from "@bitwarden/pricing"; type Discount = { /** The type of discount */ type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff - /** Whether the discount is currently active */ - active: boolean; /** The discount value (percentage or amount depending on type) */ value: number; }; @@ -47,8 +45,7 @@ type Discount = { ## Behavior -- The badge is only displayed when `discount` is provided, `active` is `true`, and `value` is - greater than 0. +- The badge is only displayed when `discount` is provided and `value` is greater than 0. - For `percent-off` type: percentage values can be provided as 0-100 (e.g., `20` for 20%) or 0-1 (e.g., `0.2` for 20%). - For `amount-off` type: amount values are formatted as currency (USD) with 2 decimal places. @@ -62,7 +59,3 @@ type Discount = { ### Amount Discount <Canvas of={DiscountBadgeStories.AmountDiscount} /> - -### Inactive Discount - -<Canvas of={DiscountBadgeStories.InactiveDiscount} /> diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts index 6f8e7ab9e74..540ae48adb4 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts @@ -35,30 +35,18 @@ describe("DiscountBadgeComponent", () => { expect(component.display()).toBe(false); }); - it("should return false when discount is inactive", () => { + it("should return true when discount has percent-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: false, - value: 20, - }); - fixture.detectChanges(); - expect(component.display()).toBe(false); - }); - - it("should return true when discount is active with percent-off", () => { - fixture.componentRef.setInput("discount", { - type: DiscountTypes.PercentOff, - active: true, value: 20, }); fixture.detectChanges(); expect(component.display()).toBe(true); }); - it("should return true when discount is active with amount-off", () => { + it("should return true when discount has amount-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.AmountOff, - active: true, value: 10.99, }); fixture.detectChanges(); @@ -68,7 +56,6 @@ describe("DiscountBadgeComponent", () => { it("should return false when value is 0 (percent-off)", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: true, value: 0, }); fixture.detectChanges(); @@ -78,7 +65,6 @@ describe("DiscountBadgeComponent", () => { it("should return false when value is 0 (amount-off)", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.AmountOff, - active: true, value: 0, }); fixture.detectChanges(); @@ -96,7 +82,6 @@ describe("DiscountBadgeComponent", () => { it("should return percentage text when type is percent-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: true, value: 20, }); fixture.detectChanges(); @@ -108,7 +93,6 @@ describe("DiscountBadgeComponent", () => { it("should convert decimal value to percentage for percent-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: true, value: 0.15, }); fixture.detectChanges(); @@ -119,7 +103,6 @@ describe("DiscountBadgeComponent", () => { it("should return amount text when type is amount-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.AmountOff, - active: true, value: 10.99, }); fixture.detectChanges(); diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts index 1d2d15e84c5..610e7b815a8 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts @@ -40,7 +40,6 @@ export const PercentDiscount: Story = { args: { discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, } as Discount, }, @@ -54,7 +53,6 @@ export const PercentDiscountDecimal: Story = { args: { discount: { type: DiscountTypes.PercentOff, - active: true, value: 0.15, // 15% in decimal format } as Discount, }, @@ -68,7 +66,6 @@ export const AmountDiscount: Story = { args: { discount: { type: DiscountTypes.AmountOff, - active: true, value: 10.99, } as Discount, }, @@ -82,26 +79,11 @@ export const LargeAmountDiscount: Story = { args: { discount: { type: DiscountTypes.AmountOff, - active: true, value: 99.99, } as Discount, }, }; -export const InactiveDiscount: Story = { - render: (args) => ({ - props: args, - template: `<billing-discount-badge [discount]="discount"></billing-discount-badge>`, - }), - args: { - discount: { - type: DiscountTypes.PercentOff, - active: false, - value: 20, - } as Discount, - }, -}; - export const NoDiscount: Story = { render: (args) => ({ props: args, diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.ts index 17204be85ff..8937ea274d4 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.ts +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.ts @@ -23,7 +23,7 @@ export class DiscountBadgeComponent { if (!discount) { return false; } - return discount.active && discount.value > 0; + return discount.value > 0; }); readonly label = computed<Maybe<string>>(() => { diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.html b/libs/pricing/src/components/pricing-card/pricing-card.component.html index 5c80ebd7e99..39e88252998 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.html +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.html @@ -22,22 +22,24 @@ @if (price(); as priceValue) { <div class="tw-mb-6"> <div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap"> + <!-- Show no decimals for whole numbers (e.g. $5), but always show 2 decimals when present (e.g. $120.50) --> <span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{ - priceValue.amount | currency: "$" + priceValue.amount + | currency: "$" : true : (priceValue.amount % 1 === 0 ? "1.0-0" : "1.2-2") }}</span> <span bitTypography="helper" class="tw-text-muted"> - / {{ priceValue.cadence }} + / {{ priceValue.cadence | i18n }} @if (priceValue.showPerUser) { - per user + {{ "perUser" | i18n }} } </span> </div> </div> } - <!-- Button space (always reserved) --> - <div class="tw-mb-6 tw-h-12"> - @if (button(); as buttonConfig) { + <!-- Button --> + @if (button(); as buttonConfig) { + <div class="tw-mb-6 tw-h-12"> <button bitButton [buttonType]="buttonConfig.type" @@ -46,19 +48,19 @@ (click)="buttonClick.emit()" type="button" > - @if (buttonConfig.icon?.position === "before") { - <i class="bwi {{ buttonConfig.icon.type }} tw-me-2" aria-hidden="true"></i> + @if (buttonConfig.icon && buttonConfig.icon.position === "before") { + <bit-icon [name]="buttonConfig.icon.type" class="tw-me-2" aria-hidden="true"></bit-icon> } {{ buttonConfig.text }} @if ( buttonConfig.icon && (buttonConfig.icon.position === "after" || !buttonConfig.icon.position) ) { - <i class="bwi {{ buttonConfig.icon.type }} tw-ms-2" aria-hidden="true"></i> + <bit-icon [name]="buttonConfig.icon.type" class="tw-ms-2" aria-hidden="true"></bit-icon> } </button> - } - </div> + </div> + } <!-- Features List --> <div class="tw-flex-grow"> @@ -67,10 +69,12 @@ <ul class="tw-list-none tw-p-0 tw-m-0"> @for (feature of featureList; track feature) { <li class="tw-flex tw-items-start tw-gap-2 tw-mb-2 last:tw-mb-0"> - <i - class="bwi bwi-check tw-text-primary-600 tw-mt-0.5 tw-flex-shrink-0" + <bit-icon + name="bwi-check" + class="tw-text-primary-600 tw-mt-0.5 tw-flex-shrink-0" aria-hidden="true" - ></i> + > + </bit-icon> <span bitTypography="helper" class="tw-text-muted tw-leading-relaxed">{{ feature }}</span> 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: </billing-pricing-card> ``` +### With Button Icons + +Add icons to buttons for enhanced visual communication: + +<Canvas of={PricingCardStories.WithButtonIcon} /> + +```html +<!-- Icon after text (default) --> +<billing-pricing-card + title="Premium Plan" + tagline="Upgrade for advanced features" + [price]="{ amount: 10, cadence: 'monthly' }" + [button]="{ + text: 'Upgrade Now', + type: 'primary', + icon: { type: 'bwi-external-link', position: 'after' } + }" + [features]="premiumFeatures" +> +</billing-pricing-card> + +<!-- Icon before text --> +<billing-pricing-card + title="Business Plan" + tagline="Add more features to your plan" + [price]="{ amount: 5, cadence: 'monthly', showPerUser: true }" + [button]="{ + text: 'Add Features', + type: 'secondary', + icon: { type: 'bwi-plus', position: 'before' } + }" + [features]="businessFeatures" +> +</billing-pricing-card> +``` + +### Active Plan Badge + +Show which plan is currently active: + +<Canvas of={PricingCardStories.ActivePlan} /> + +```html +<billing-pricing-card + title="Free Plan" + tagline="Your current plan with essential features" + [features]="freeFeatures" + [activeBadge]="{ text: 'Active plan' }" +> +</billing-pricing-card> +``` + ### 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<string>(); 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<string[]>(); readonly activeBadge = input<{ text: string; variant?: BadgeVariant }>(); diff --git a/libs/pricing/src/types/cart.ts b/libs/pricing/src/types/cart.ts index d27a867b785..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 = { - name: string; + translationKey: string; + translationParams?: Array<string | number>; 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<string | number>; + value: number; +}; diff --git a/libs/pricing/src/types/discount.ts b/libs/pricing/src/types/discount.ts index c12998ef609..afea56fce0a 100644 --- a/libs/pricing/src/types/discount.ts +++ b/libs/pricing/src/types/discount.ts @@ -9,7 +9,6 @@ export type DiscountType = (typeof DiscountTypes)[keyof typeof DiscountTypes]; export type Discount = { type: DiscountType; - active: boolean; value: number; }; diff --git a/libs/state/src/state-migrations/migrate.ts b/libs/state/src/state-migrations/migrate.ts index a051c25695a..42cb4aa3ae1 100644 --- a/libs/state/src/state-migrations/migrate.ts +++ b/libs/state/src/state-migrations/migrate.ts @@ -71,12 +71,13 @@ import { RemoveNewCustomizationOptionsCalloutDismissed } from "./migrations/71-r import { RemoveAccountDeprovisioningBannerDismissed } from "./migrations/72-remove-account-deprovisioning-banner-dismissed"; import { AddMasterPasswordUnlockData } from "./migrations/73-add-master-password-unlock-data"; import { RemoveLegacyPin } from "./migrations/74-remove-legacy-pin"; +import { RemoveUserEncryptedPrivateKey } from "./migrations/75-remove-user-encrypted-private-key"; import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global"; import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 74; +export const CURRENT_VERSION = 75; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -152,7 +153,8 @@ export function createMigrationBuilder() { .with(RemoveNewCustomizationOptionsCalloutDismissed, 70, 71) .with(RemoveAccountDeprovisioningBannerDismissed, 71, 72) .with(AddMasterPasswordUnlockData, 72, 73) - .with(RemoveLegacyPin, 73, CURRENT_VERSION); + .with(RemoveLegacyPin, 73, 74) + .with(RemoveUserEncryptedPrivateKey, 74, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/state/src/state-migrations/migrations/75-remove-user-encrypted-private-key.spec.ts b/libs/state/src/state-migrations/migrations/75-remove-user-encrypted-private-key.spec.ts new file mode 100644 index 00000000000..7c3b765dbd8 --- /dev/null +++ b/libs/state/src/state-migrations/migrations/75-remove-user-encrypted-private-key.spec.ts @@ -0,0 +1,172 @@ +import { runMigrator } from "../migration-helper.spec"; +import { IRREVERSIBLE } from "../migrator"; + +import { RemoveUserEncryptedPrivateKey } from "./75-remove-user-encrypted-private-key"; + +describe("RemoveUserEncryptedPrivateKey", () => { + const sut = new RemoveUserEncryptedPrivateKey(74, 75); + + describe("migrate", () => { + it("migrates V1 cryptographic state (privateKey only)", async () => { + const output = await runMigrator(sut, { + global_account_accounts: { + user1: { + email: "user1@email.com", + name: "User 1", + emailVerified: true, + }, + }, + user_user1_crypto_privateKey: "encryptedPrivateKey", + }); + + expect(output).toEqual({ + global_account_accounts: { + user1: { + email: "user1@email.com", + name: "User 1", + emailVerified: true, + }, + }, + user_user1_crypto_accountCryptographicState: { + V1: { + private_key: "encryptedPrivateKey", + }, + }, + }); + }); + + it("migrates V2 cryptographic state (all keys present)", async () => { + const output = await runMigrator(sut, { + global_account_accounts: { + user1: { + email: "user1@email.com", + name: "User 1", + emailVerified: true, + }, + }, + user_user1_crypto_privateKey: "encryptedPrivateKey", + user_user1_crypto_userSigningKey: "signingKey", + user_user1_crypto_userSignedPublicKey: "signedPublicKey", + user_user1_crypto_accountSecurityState: "securityState", + }); + + expect(output).toEqual({ + global_account_accounts: { + user1: { + email: "user1@email.com", + name: "User 1", + emailVerified: true, + }, + }, + user_user1_crypto_accountCryptographicState: { + V2: { + private_key: "encryptedPrivateKey", + signing_key: "signingKey", + signed_public_key: "signedPublicKey", + security_state: "securityState", + }, + }, + }); + }); + + it("migrates multiple users with different cryptographic states", async () => { + const output = await runMigrator(sut, { + global_account_accounts: { + user1: { + email: "user1@email.com", + name: "User 1", + emailVerified: true, + }, + user2: { + email: "user2@email.com", + name: "User 2", + emailVerified: true, + }, + user3: { + email: "user3@email.com", + name: "User 3", + emailVerified: true, + }, + }, + // user1: V1 state + user_user1_crypto_privateKey: "privateKey1", + // user2: V2 state + user_user2_crypto_privateKey: "privateKey2", + user_user2_crypto_userSigningKey: "signingKey2", + user_user2_crypto_userSignedPublicKey: "signedPublicKey2", + user_user2_crypto_accountSecurityState: "securityState2", + // user3: no cryptographic state + }); + + expect(output).toEqual({ + global_account_accounts: { + user1: { + email: "user1@email.com", + name: "User 1", + emailVerified: true, + }, + user2: { + email: "user2@email.com", + name: "User 2", + emailVerified: true, + }, + user3: { + email: "user3@email.com", + name: "User 3", + emailVerified: true, + }, + }, + user_user1_crypto_accountCryptographicState: { + V1: { + private_key: "privateKey1", + }, + }, + user_user2_crypto_accountCryptographicState: { + V2: { + private_key: "privateKey2", + signing_key: "signingKey2", + signed_public_key: "signedPublicKey2", + security_state: "securityState2", + }, + }, + }); + }); + + it("does not overwrite existing accountCryptographicState", async () => { + const existingState = { + V1: { + private_key: "existingPrivateKey", + }, + }; + + const output = await runMigrator(sut, { + global_account_accounts: { + user1: { + email: "user1@email.com", + name: "User 1", + emailVerified: true, + }, + }, + user_user1_crypto_accountCryptographicState: existingState, + user_user1_crypto_privateKey: "newPrivateKey", + }); + + expect(output).toEqual({ + global_account_accounts: { + user1: { + email: "user1@email.com", + name: "User 1", + emailVerified: true, + }, + }, + user_user1_crypto_accountCryptographicState: existingState, + }); + }); + }); + + describe("rollback", () => { + it("is irreversible", async () => { + await expect(runMigrator(sut, {}, "rollback")).rejects.toThrow(IRREVERSIBLE); + }); + }); +}); diff --git a/libs/state/src/state-migrations/migrations/75-remove-user-encrypted-private-key.ts b/libs/state/src/state-migrations/migrations/75-remove-user-encrypted-private-key.ts new file mode 100644 index 00000000000..249de5b93f2 --- /dev/null +++ b/libs/state/src/state-migrations/migrations/75-remove-user-encrypted-private-key.ts @@ -0,0 +1,117 @@ +import { + SignedPublicKey, + WrappedAccountCryptographicState, + EncString, + SignedSecurityState, +} from "@bitwarden/sdk-internal"; + +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { IRREVERSIBLE, Migrator } from "../migrator"; + +type ExpectedAccountType = NonNullable<unknown>; + +export const userEncryptedPrivateKey: KeyDefinitionLike = { + key: "privateKey", + stateDefinition: { + name: "crypto", + }, +}; + +export const userKeyEncryptedSigningKey: KeyDefinitionLike = { + key: "userSigningKey", + stateDefinition: { + name: "crypto", + }, +}; + +export const userSignedPublicKey: KeyDefinitionLike = { + key: "userSignedPublicKey", + stateDefinition: { + name: "crypto", + }, +}; + +export const accountSecurityState: KeyDefinitionLike = { + key: "accountSecurityState", + stateDefinition: { + name: "crypto", + }, +}; + +export const accountCryptographicState: KeyDefinitionLike = { + key: "accountCryptographicState", + stateDefinition: { + name: "crypto", + }, +}; + +export class RemoveUserEncryptedPrivateKey extends Migrator<74, 75> { + async migrate(helper: MigrationHelper): Promise<void> { + const accounts = await helper.getAccounts<ExpectedAccountType>(); + for (const { userId } of accounts) { + // Check if account cryptographic state already exists + const existingAccountCryptoState = await helper.getFromUser( + userId, + accountCryptographicState, + ); + + // Gather all individual cryptographic key state parts + const privateKey = await helper.getFromUser(userId, userEncryptedPrivateKey); + const signingKey = await helper.getFromUser(userId, userKeyEncryptedSigningKey); + const signedPubKey = await helper.getFromUser(userId, userSignedPublicKey); + const accountSecurity = await helper.getFromUser(userId, accountSecurityState); + + // Only migrate if account cryptographic state does not exist + if (!existingAccountCryptoState) { + // Build the new account cryptographic state object + let newAccountCryptographicState: WrappedAccountCryptographicState; + if ( + privateKey != null && + signingKey == null && + signedPubKey == null && + accountSecurity == null + ) { + newAccountCryptographicState = { V1: { private_key: privateKey as EncString } }; + await helper.setToUser(userId, accountCryptographicState, newAccountCryptographicState); + } else if ( + privateKey != null && + signingKey != null && + signedPubKey != null && + accountSecurity != null + ) { + newAccountCryptographicState = { + V2: { + private_key: privateKey as EncString, + signing_key: signingKey as EncString, + signed_public_key: signedPubKey as SignedPublicKey, + security_state: accountSecurity as SignedSecurityState, + }, + }; + await helper.setToUser(userId, accountCryptographicState, newAccountCryptographicState); + } else { + helper.logService.warning( + `Incomplete cryptographic state for user ${userId}, skipping migration of account cryptographic state.`, + ); + } + } + + // Always remove the old states + if (privateKey != null) { + await helper.removeFromUser(userId, userEncryptedPrivateKey); + } + if (signingKey != null) { + await helper.removeFromUser(userId, userKeyEncryptedSigningKey); + } + if (signedPubKey != null) { + await helper.removeFromUser(userId, userSignedPublicKey); + } + if (accountSecurity != null) { + await helper.removeFromUser(userId, accountSecurityState); + } + } + } + + async rollback(helper: MigrationHelper): Promise<void> { + throw IRREVERSIBLE; + } +} diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.html b/libs/subscription/src/components/additional-options-card/additional-options-card.component.html index 851ae32ddb3..c4d3d291b26 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.html +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.html @@ -13,8 +13,8 @@ bitButton buttonType="secondary" type="button" - [disabled]="callsToActionDisabled()" - (click)="callToActionClicked.emit('download-license')" + [disabled]="downloadLicenseDisabled()" + (click)="callToActionClicked.emit(actions.DownloadLicense)" > {{ "downloadLicense" | i18n }} </button> @@ -22,8 +22,8 @@ bitButton buttonType="danger" type="button" - [disabled]="callsToActionDisabled()" - (click)="callToActionClicked.emit('cancel-subscription')" + [disabled]="cancelSubscriptionDisabled()" + (click)="callToActionClicked.emit(actions.CancelSubscription)" > {{ "cancelSubscription" | i18n }} </button> diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx b/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx index 4519d19a530..3162e740cb0 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx @@ -21,6 +21,8 @@ subscription actions. - [Examples](#examples) - [Default](#default) - [Actions Disabled](#actions-disabled) + - [Download License Disabled](#download-license-disabled) + - [Cancel Subscription Disabled](#cancel-subscription-disabled) - [Features](#features) - [Do's and Don'ts](#dos-and-donts) - [Accessibility](#accessibility) @@ -44,9 +46,10 @@ import { AdditionalOptionsCardComponent } from "@bitwarden/subscription"; ### Inputs -| Input | Type | Description | -| ----------------------- | --------- | ---------------------------------------------------------------------- | -| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. | +| Input | Type | Description | +| ---------------------------- | --------- | ----------------------------------------------------------------------------- | +| `downloadLicenseDisabled` | `boolean` | Optional. Disables download license button when true. Defaults to `false`. | +| `cancelSubscriptionDisabled` | `boolean` | Optional. Disables cancel subscription button when true. Defaults to `false`. | ### Outputs @@ -109,14 +112,46 @@ Component with action buttons disabled (useful during async operations): ```html <billing-additional-options-card - [callsToActionDisabled]="true" + [downloadLicenseDisabled]="true" + [cancelSubscriptionDisabled]="true" (callToActionClicked)="handleAction($event)" > </billing-additional-options-card> ``` -**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like -downloading the license or processing subscription cancellation. +**Note:** Use `downloadLicenseDisabled` and `cancelSubscriptionDisabled` independently to control +button states during async operations like downloading the license or processing subscription +cancellation. + +### Download License Disabled + +Component with only the download license button disabled: + +<Canvas of={AdditionalOptionsCardStories.DownloadLicenseDisabled} /> + +```html +<billing-additional-options-card + [downloadLicenseDisabled]="true" + [cancelSubscriptionDisabled]="false" + (callToActionClicked)="handleAction($event)" +> +</billing-additional-options-card> +``` + +### Cancel Subscription Disabled + +Component with only the cancel subscription button disabled: + +<Canvas of={AdditionalOptionsCardStories.CancelSubscriptionDisabled} /> + +```html +<billing-additional-options-card + [downloadLicenseDisabled]="false" + [cancelSubscriptionDisabled]="true" + (callToActionClicked)="handleAction($event)" +> +</billing-additional-options-card> +``` ## Features @@ -133,9 +168,11 @@ downloading the license or processing subscription cancellation. - Handle both `download-license` and `cancel-subscription` events in parent components - Show appropriate confirmation dialogs before executing destructive actions (cancel subscription) -- Disable buttons or show loading states during async operations +- Use `downloadLicenseDisabled` and `cancelSubscriptionDisabled` to control button states during + operations - Provide clear user feedback after action completion - Consider adding additional safety measures for subscription cancellation +- Control button states independently based on business logic ### ❌ Don't diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts index 345de037fd3..3346c287beb 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts @@ -66,9 +66,32 @@ describe("AdditionalOptionsCardComponent", () => { }); }); - describe("callsToActionDisabled", () => { - it("should disable both buttons when callsToActionDisabled is true", () => { - fixture.componentRef.setInput("callsToActionDisabled", true); + describe("button disabled states", () => { + it("should enable both buttons by default", () => { + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); + + it("should disable download license button when downloadLicenseDisabled is true", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + }); + + it("should disable cancel subscription button when cancelSubscriptionDisabled is true", () => { + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); + }); + + it("should disable both buttons independently", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); @@ -76,18 +99,23 @@ describe("AdditionalOptionsCardComponent", () => { expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); - it("should enable both buttons when callsToActionDisabled is false", () => { - fixture.componentRef.setInput("callsToActionDisabled", false); + it("should allow download enabled while cancel disabled", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", false); + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); expect(buttons[0].nativeElement.disabled).toBe(false); - expect(buttons[1].nativeElement.disabled).toBe(false); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); - it("should enable both buttons by default", () => { + it("should allow cancel enabled while download disabled", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.componentRef.setInput("cancelSubscriptionDisabled", false); + fixture.detectChanges(); + const buttons = fixture.debugElement.queryAll(By.css("button")); - expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); expect(buttons[1].nativeElement.disabled).toBe(false); }); }); diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts index 66c151f536f..7dd7a5375fe 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts @@ -44,6 +44,23 @@ export const Default: Story = { export const ActionsDisabled: Story = { name: "Actions Disabled", args: { - callsToActionDisabled: true, + downloadLicenseDisabled: true, + cancelSubscriptionDisabled: true, + }, +}; + +export const DownloadLicenseDisabled: Story = { + name: "Download License Disabled", + args: { + downloadLicenseDisabled: true, + cancelSubscriptionDisabled: false, + }, +}; + +export const CancelSubscriptionDisabled: Story = { + name: "Cancel Subscription Disabled", + args: { + downloadLicenseDisabled: false, + cancelSubscriptionDisabled: true, }, }; diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts index a962a167ec6..6c633a43d93 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts @@ -3,7 +3,13 @@ import { Component, ChangeDetectionStrategy, output, input } from "@angular/core import { ButtonModule, CardComponent, TypographyModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; -export type AdditionalOptionsCardAction = "download-license" | "cancel-subscription"; +export const AdditionalOptionsCardActions = { + DownloadLicense: "download-license", + CancelSubscription: "cancel-subscription", +} as const; + +export type AdditionalOptionsCardAction = + (typeof AdditionalOptionsCardActions)[keyof typeof AdditionalOptionsCardActions]; @Component({ selector: "billing-additional-options-card", @@ -12,6 +18,10 @@ export type AdditionalOptionsCardAction = "download-license" | "cancel-subscript imports: [ButtonModule, CardComponent, TypographyModule, I18nPipe], }) export class AdditionalOptionsCardComponent { - readonly callsToActionDisabled = input<boolean>(false); + readonly downloadLicenseDisabled = input<boolean>(false); + readonly cancelSubscriptionDisabled = input<boolean>(false); + readonly callToActionClicked = output<AdditionalOptionsCardAction>(); + + protected readonly actions = AdditionalOptionsCardActions; } diff --git a/libs/subscription/src/components/storage-card/storage-card.component.html b/libs/subscription/src/components/storage-card/storage-card.component.html index c11f1917176..f8ac4b18604 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.html +++ b/libs/subscription/src/components/storage-card/storage-card.component.html @@ -21,8 +21,8 @@ bitButton buttonType="secondary" type="button" - [disabled]="callsToActionDisabled()" - (click)="callToActionClicked.emit('add-storage')" + [disabled]="addStorageDisabled()" + (click)="callToActionClicked.emit(actions.AddStorage)" > {{ "addStorage" | i18n }} </button> @@ -30,8 +30,8 @@ bitButton buttonType="secondary" type="button" - [disabled]="callsToActionDisabled() || !canRemoveStorage()" - (click)="callToActionClicked.emit('remove-storage')" + [disabled]="removeStorageDisabled()" + (click)="callToActionClicked.emit(actions.RemoveStorage)" > {{ "removeStorage" | i18n }} </button> diff --git a/libs/subscription/src/components/storage-card/storage-card.component.mdx b/libs/subscription/src/components/storage-card/storage-card.component.mdx index 43215cb863c..7e06fa23553 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.mdx +++ b/libs/subscription/src/components/storage-card/storage-card.component.mdx @@ -30,6 +30,8 @@ full). - [Large Storage Pool (1TB)](#large-storage-pool-1tb) - [Small Storage Pool (1GB)](#small-storage-pool-1gb) - [Actions Disabled](#actions-disabled) + - [Add Storage Disabled](#add-storage-disabled) + - [Remove Storage Disabled](#remove-storage-disabled) - [Features](#features) - [Do's and Don'ts](#dos-and-donts) - [Accessibility](#accessibility) @@ -53,10 +55,11 @@ import { StorageCardComponent, Storage } from "@bitwarden/subscription"; ### Inputs -| Input | Type | Description | -| ----------------------- | --------- | ---------------------------------------------------------------------- | -| `storage` | `Storage` | **Required.** Storage data including available, used, and readable | -| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. | +| Input | Type | Description | +| ----------------------- | --------- | ------------------------------------------------------------------------ | +| `storage` | `Storage` | **Required.** Storage data including available, used, and readable | +| `addStorageDisabled` | `boolean` | Optional. Disables add storage button when true. Defaults to `false`. | +| `removeStorageDisabled` | `boolean` | Optional. Disables remove storage button when true. Defaults to `false`. | ### Outputs @@ -93,7 +96,8 @@ The component automatically adapts its appearance based on storage usage: Key behaviors: - Progress bar color changes from blue (primary) to red (danger) when full -- Remove storage button is disabled when storage is full +- Button disabled states are controlled independently via `addStorageDisabled` and + `removeStorageDisabled` inputs - Title changes to "Storage full" when at capacity - Description provides context-specific messaging @@ -123,7 +127,7 @@ Storage with no files uploaded: [storage]="{ available: 5, used: 0, - readableUsed: '0 GB' + readableUsed: '0 GB', }" (callToActionClicked)="handleAction($event)" > @@ -141,7 +145,7 @@ Storage with partial usage (50%): [storage]="{ available: 5, used: 2.5, - readableUsed: '2.5 GB' + readableUsed: '2.5 GB', }" (callToActionClicked)="handleAction($event)" > @@ -159,15 +163,15 @@ Storage at full capacity with disabled remove button: [storage]="{ available: 5, used: 5, - readableUsed: '5 GB' + readableUsed: '5 GB', }" (callToActionClicked)="handleAction($event)" > </billing-storage-card> ``` -**Note:** When storage is full, the "Remove storage" button is disabled and the progress bar turns -red. +**Note:** When storage is full, the progress bar turns red. Button disabled states are controlled +independently via the `addStorageDisabled` and `removeStorageDisabled` inputs. ### Low Usage (10%) @@ -180,7 +184,7 @@ Minimal storage usage: [storage]="{ available: 5, used: 0.5, - readableUsed: '500 MB' + readableUsed: '500 MB', }" (callToActionClicked)="handleAction($event)" > @@ -198,7 +202,7 @@ Substantial storage usage: [storage]="{ available: 5, used: 3.75, - readableUsed: '3.75 GB' + readableUsed: '3.75 GB', }" (callToActionClicked)="handleAction($event)" > @@ -216,7 +220,7 @@ Storage approaching capacity: [storage]="{ available: 5, used: 4.75, - readableUsed: '4.75 GB' + readableUsed: '4.75 GB', }" (callToActionClicked)="handleAction($event)" > @@ -234,7 +238,7 @@ Enterprise-level storage allocation: [storage]="{ available: 1000, used: 734, - readableUsed: '734 GB' + readableUsed: '734 GB', }" (callToActionClicked)="handleAction($event)" > @@ -252,7 +256,7 @@ Minimal storage allocation: [storage]="{ available: 1, used: 0.8, - readableUsed: '800 MB' + readableUsed: '800 MB', }" (callToActionClicked)="handleAction($event)" > @@ -270,16 +274,57 @@ Storage card with action buttons disabled (useful during async operations): [storage]="{ available: 5, used: 2.5, - readableUsed: '2.5 GB' + readableUsed: '2.5 GB', }" - [callsToActionDisabled]="true" + [addStorageDisabled]="true" + [removeStorageDisabled]="true" (callToActionClicked)="handleAction($event)" > </billing-storage-card> ``` -**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like -adding or removing storage. +**Note:** Use `addStorageDisabled` and `removeStorageDisabled` independently to control button +states during async operations like adding or removing storage. + +### Add Storage Disabled + +Storage card with only the add button disabled: + +<Canvas of={StorageCardStories.AddStorageDisabled} /> + +```html +<billing-storage-card + [storage]="{ + available: 5, + used: 2.5, + readableUsed: '2.5 GB', + }" + [addStorageDisabled]="true" + [removeStorageDisabled]="false" + (callToActionClicked)="handleAction($event)" +> +</billing-storage-card> +``` + +### Remove Storage Disabled + +Storage card with only the remove button disabled: + +<Canvas of={StorageCardStories.RemoveStorageDisabled} /> + +```html +<billing-storage-card + [storage]="{ + available: 5, + used: 2.5, + readableUsed: '2.5 GB', + }" + [addStorageDisabled]="false" + [removeStorageDisabled]="true" + (callToActionClicked)="handleAction($event)" +> +</billing-storage-card> +``` ## Features @@ -304,13 +349,14 @@ adding or removing storage. - Use human-readable format strings (e.g., "2.5 GB", "500 MB") for `readableUsed` - Keep `used` value less than or equal to `available` under normal circumstances - Update storage data in real-time when user adds or removes storage -- Disable UI interactions when storage operations are in progress +- Use `addStorageDisabled` and `removeStorageDisabled` to control button states during operations - Show loading states during async storage operations +- Control button states independently based on business logic ### ❌ Don't -- Omit the `readableUsed` field - it's required for display -- Use inconsistent units between `available` and `used` (both should be in GB) +- Omit the `readableUsed` field - it's required +- Use inconsistent units between `available` and `used` (all should be in GB) - Allow negative values for storage amounts - Ignore the `callToActionClicked` events - they require handling - Display inaccurate or stale storage information diff --git a/libs/subscription/src/components/storage-card/storage-card.component.spec.ts b/libs/subscription/src/components/storage-card/storage-card.component.spec.ts index ae0d7ad9dcb..fe2223f1449 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.spec.ts +++ b/libs/subscription/src/components/storage-card/storage-card.component.spec.ts @@ -163,18 +163,6 @@ describe("StorageCardComponent", () => { }); }); - describe("canRemoveStorage", () => { - it("should return true when storage is not full", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); - expect(component.canRemoveStorage()).toBe(true); - }); - - it("should return false when storage is full", () => { - setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); - expect(component.canRemoveStorage()).toBe(false); - }); - }); - describe("button rendering", () => { it("should render both buttons", () => { setupComponent(baseStorage); @@ -182,25 +170,46 @@ describe("StorageCardComponent", () => { expect(buttons.length).toBe(2); }); - it("should enable remove button when storage is not full", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + it("should enable add button by default", () => { + setupComponent(baseStorage); + const buttons = fixture.debugElement.queryAll(By.css("button")); + const addButton = buttons[0].nativeElement; + expect(addButton.disabled).toBe(false); + }); + + it("should disable add button when addStorageDisabled is true", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + const addButton = buttons[0]; + expect(addButton.attributes["aria-disabled"]).toBe("true"); + }); + + it("should enable remove button by default", () => { + setupComponent(baseStorage); const buttons = fixture.debugElement.queryAll(By.css("button")); const removeButton = buttons[1].nativeElement; expect(removeButton.disabled).toBe(false); }); - it("should disable remove button when storage is full", () => { - setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + it("should disable remove button when removeStorageDisabled is true", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("removeStorageDisabled", true); + fixture.detectChanges(); + const buttons = fixture.debugElement.queryAll(By.css("button")); const removeButton = buttons[1]; expect(removeButton.attributes["aria-disabled"]).toBe("true"); }); }); - describe("callsToActionDisabled", () => { - it("should disable both buttons when callsToActionDisabled is true", () => { + describe("independent button disabled states", () => { + it("should disable both buttons independently", () => { setupComponent(baseStorage); - fixture.componentRef.setInput("callsToActionDisabled", true); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.componentRef.setInput("removeStorageDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); @@ -208,9 +217,10 @@ describe("StorageCardComponent", () => { expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); - it("should enable both buttons when callsToActionDisabled is false and storage is not full", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); - fixture.componentRef.setInput("callsToActionDisabled", false); + it("should enable both buttons when both disabled inputs are false", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", false); + fixture.componentRef.setInput("removeStorageDisabled", false); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); @@ -218,15 +228,27 @@ describe("StorageCardComponent", () => { expect(buttons[1].nativeElement.disabled).toBe(false); }); - it("should keep remove button disabled when callsToActionDisabled is false but storage is full", () => { - setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); - fixture.componentRef.setInput("callsToActionDisabled", false); + it("should allow add button enabled while remove button disabled", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", false); + fixture.componentRef.setInput("removeStorageDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); expect(buttons[0].nativeElement.disabled).toBe(false); expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); + + it("should allow remove button enabled while add button disabled", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.componentRef.setInput("removeStorageDisabled", false); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); }); describe("button click events", () => { @@ -243,7 +265,7 @@ describe("StorageCardComponent", () => { }); it("should emit remove-storage action when remove button is clicked", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + setupComponent(baseStorage); const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); diff --git a/libs/subscription/src/components/storage-card/storage-card.component.stories.ts b/libs/subscription/src/components/storage-card/storage-card.component.stories.ts index 8c2070e59f9..2afbaf0d0b1 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.stories.ts +++ b/libs/subscription/src/components/storage-card/storage-card.component.stories.ts @@ -143,6 +143,33 @@ export const ActionsDisabled: Story = { used: 2.5, readableUsed: "2.5 GB", } satisfies Storage, - callsToActionDisabled: true, + addStorageDisabled: true, + removeStorageDisabled: true, + }, +}; + +export const AddStorageDisabled: Story = { + name: "Add Storage Disabled", + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + addStorageDisabled: true, + removeStorageDisabled: false, + }, +}; + +export const RemoveStorageDisabled: Story = { + name: "Remove Storage Disabled", + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + addStorageDisabled: false, + removeStorageDisabled: true, }, }; diff --git a/libs/subscription/src/components/storage-card/storage-card.component.ts b/libs/subscription/src/components/storage-card/storage-card.component.ts index 988f4a0ec60..483649434ff 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.ts +++ b/libs/subscription/src/components/storage-card/storage-card.component.ts @@ -12,7 +12,12 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { Storage } from "../../types/storage"; -export type StorageCardAction = "add-storage" | "remove-storage"; +export const StorageCardActions = { + AddStorage: "add-storage", + RemoveStorage: "remove-storage", +} as const; + +export type StorageCardAction = (typeof StorageCardActions)[keyof typeof StorageCardActions]; @Component({ selector: "billing-storage-card", @@ -25,7 +30,8 @@ export class StorageCardComponent { readonly storage = input.required<Storage>(); - readonly callsToActionDisabled = input<boolean>(false); + readonly addStorageDisabled = input<boolean>(false); + readonly removeStorageDisabled = input<boolean>(false); readonly callToActionClicked = output<StorageCardAction>(); @@ -64,5 +70,5 @@ export class StorageCardComponent { return this.isFull() ? "danger" : "primary"; }); - readonly canRemoveStorage = computed<boolean>(() => !this.isFull()); + protected readonly actions = StorageCardActions; } diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.mdx b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx index 0f605f0f05e..c9cc6df7263 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.mdx +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx @@ -67,14 +67,14 @@ import { SubscriptionCardComponent, BitwardenSubscription } from "@bitwarden/sub ### Outputs -| Output | Type | Description | -| --------------------- | ---------------- | ---------------------------------------------------------- | -| `callToActionClicked` | `PlanCardAction` | Emitted when a user clicks an action button in the callout | +| Output | Type | Description | +| --------------------- | ------------------------ | ---------------------------------------------------------- | +| `callToActionClicked` | `SubscriptionCardAction` | Emitted when a user clicks an action button in the callout | -**PlanCardAction Type:** +**SubscriptionCardAction Type:** ```typescript -type PlanCardAction = +type SubscriptionCardAction = | "contact-support" | "manage-invoices" | "reinstate-subscription" diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts index 3485f2a493a..cdb85360c74 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts @@ -14,7 +14,7 @@ describe("SubscriptionCardComponent", () => { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50, }, }, diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts index abe5789382b..32976c89cc2 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts @@ -103,7 +103,7 @@ export const Active: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -131,7 +131,7 @@ export const ActiveWithUpgrade: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -157,7 +157,7 @@ export const Trial: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -185,7 +185,7 @@ export const TrialWithUpgrade: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -212,7 +212,7 @@ export const Incomplete: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -239,7 +239,7 @@ export const IncompleteExpired: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -266,7 +266,7 @@ export const PastDue: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -293,7 +293,7 @@ export const PendingCancellation: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -320,7 +320,7 @@ export const Unpaid: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -346,7 +346,7 @@ export const Canceled: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -372,31 +372,30 @@ export const Enterprise: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 7, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 0.5, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 13, }, additionalServiceAccounts: { quantity: 5, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 1, }, }, discount: { type: DiscountTypes.PercentOff, - active: true, - value: 0.25, + value: 25, }, cadence: "monthly", estimatedTax: 6.4, diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.ts index f52127a0104..ebfb41df6c2 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.ts @@ -16,12 +16,16 @@ import { CartSummaryComponent, Maybe } from "@bitwarden/pricing"; import { BitwardenSubscription, SubscriptionStatuses } from "@bitwarden/subscription"; import { I18nPipe } from "@bitwarden/ui-common"; -export type PlanCardAction = - | "contact-support" - | "manage-invoices" - | "reinstate-subscription" - | "update-payment" - | "upgrade-plan"; +export const SubscriptionCardActions = { + ContactSupport: "contact-support", + ManageInvoices: "manage-invoices", + ReinstateSubscription: "reinstate-subscription", + UpdatePayment: "update-payment", + UpgradePlan: "upgrade-plan", +} as const; + +export type SubscriptionCardAction = + (typeof SubscriptionCardActions)[keyof typeof SubscriptionCardActions]; type Badge = { text: string; variant: BadgeVariant }; @@ -33,7 +37,7 @@ type Callout = Maybe<{ callsToAction?: { text: string; buttonType: ButtonType; - action: PlanCardAction; + action: SubscriptionCardAction; }[]; }>; @@ -64,7 +68,7 @@ export class SubscriptionCardComponent { readonly showUpgradeButton = input<boolean>(false); - readonly callToActionClicked = output<PlanCardAction>(); + readonly callToActionClicked = output<SubscriptionCardAction>(); readonly badge = computed<Badge>(() => { const subscription = this.subscription(); @@ -136,12 +140,12 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("updatePayment"), buttonType: "unstyled", - action: "update-payment", + action: SubscriptionCardActions.UpdatePayment, }, { text: this.i18nService.t("contactSupportShort"), buttonType: "unstyled", - action: "contact-support", + action: SubscriptionCardActions.ContactSupport, }, ], }; @@ -155,7 +159,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("contactSupportShort"), buttonType: "unstyled", - action: "contact-support", + action: SubscriptionCardActions.ContactSupport, }, ], }; @@ -172,7 +176,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("reinstateSubscription"), buttonType: "unstyled", - action: "reinstate-subscription", + action: SubscriptionCardActions.ReinstateSubscription, }, ], }; @@ -189,7 +193,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("upgradeNow"), buttonType: "unstyled", - action: "upgrade-plan", + action: SubscriptionCardActions.UpgradePlan, }, ], }; @@ -208,7 +212,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("manageInvoices"), buttonType: "unstyled", - action: "manage-invoices", + action: SubscriptionCardActions.ManageInvoices, }, ], }; @@ -225,7 +229,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("manageInvoices"), buttonType: "unstyled", - action: "manage-invoices", + action: SubscriptionCardActions.ManageInvoices, }, ], }; diff --git a/libs/subscription/src/types/bitwarden-subscription.ts b/libs/subscription/src/types/bitwarden-subscription.ts index 15bf64d03aa..5c43ed20590 100644 --- a/libs/subscription/src/types/bitwarden-subscription.ts +++ b/libs/subscription/src/types/bitwarden-subscription.ts @@ -12,6 +12,8 @@ export const SubscriptionStatuses = { Unpaid: "unpaid", } as const; +export type SubscriptionStatus = (typeof SubscriptionStatuses)[keyof typeof SubscriptionStatuses]; + type HasCart = { cart: Cart; }; diff --git a/libs/subscription/src/types/storage.ts b/libs/subscription/src/types/storage.ts index beb187250dd..35df54cb4f2 100644 --- a/libs/subscription/src/types/storage.ts +++ b/libs/subscription/src/types/storage.ts @@ -1,3 +1,5 @@ +export const MAX_STORAGE_GB = 100; + export type Storage = { available: number; readableUsed: string; diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/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/services/individual-vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts index 33dde9ae51a..55ad39bc8e0 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts @@ -138,7 +138,7 @@ function expectEqualCiphers(ciphers: CipherView[] | Cipher[], jsonResult: string } function expectEqualFolderViews(folderViews: FolderView[] | Folder[], jsonResult: string) { - const actual = JSON.stringify(JSON.parse(jsonResult).folders); + const actual = JSON.parse(jsonResult).folders; const folders: FolderResponse[] = []; folderViews.forEach((c) => { const folder = new FolderResponse(); @@ -148,21 +148,19 @@ function expectEqualFolderViews(folderViews: FolderView[] | Folder[], jsonResult }); expect(actual.length).toBeGreaterThan(0); - expect(actual).toEqual(JSON.stringify(folders)); + expect(actual).toEqual(folders); } function expectEqualFolders(folders: Folder[], jsonResult: string) { - const actual = JSON.stringify(JSON.parse(jsonResult).folders); - const items: Folder[] = []; - folders.forEach((c) => { - const item = new Folder(); - item.id = c.id; - item.name = c.name; - items.push(item); - }); + const actual = JSON.parse(jsonResult).folders; + + const expected = folders.map((c) => ({ + id: c.id, + name: c.name?.encryptedString, + })); expect(actual.length).toBeGreaterThan(0); - expect(actual).toEqual(JSON.stringify(items)); + expect(actual).toEqual(expected); } describe("VaultExportService", () => { @@ -525,6 +523,20 @@ describe("VaultExportService", () => { const exportedData = actual as ExportedVaultAsString; expectEqualFolders(UserFolders, exportedData.data); }); + + it("does not export the key property in unencrypted exports", async () => { + // Create a cipher with a key property + const cipherWithKey = generateCipherView(false); + (cipherWithKey as any).key = "shouldBeDeleted"; + cipherService.getAllDecrypted.mockResolvedValue([cipherWithKey]); + + const actual = await exportService.getExport(userId, "json"); + expect(typeof actual.data).toBe("string"); + const exportedData = actual as ExportedVaultAsString; + const parsed = JSON.parse(exportedData.data); + expect(parsed.items.length).toBe(1); + expect(parsed.items[0].key).toBeUndefined(); + }); }); export class FolderResponse { diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts index ddda96b21e0..a5e9f8aea6e 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts @@ -240,7 +240,7 @@ export class IndividualVaultExportService }; folders.forEach((f) => { - if (f.id == null) { + if (!f.id) { return; } const folder = new FolderWithIdExport(); @@ -268,7 +268,7 @@ export class IndividualVaultExportService private buildCsvExport(decFolders: FolderView[], decCiphers: CipherView[]): string { const foldersMap = new Map<string, FolderView>(); decFolders.forEach((f) => { - if (f.id != null) { + if (f.id) { foldersMap.set(f.id, f); } }); @@ -302,7 +302,7 @@ export class IndividualVaultExportService }; decFolders.forEach((f) => { - if (f.id == null) { + if (!f.id) { return; } const folder = new FolderWithIdExport(); @@ -317,6 +317,7 @@ export class IndividualVaultExportService const cipher = new CipherWithIdExport(); cipher.build(c); cipher.collectionIds = null; + delete cipher.key; jsonDoc.items.push(cipher); }); diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts index ed3a16516f2..c3df13c7945 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts @@ -3,13 +3,13 @@ import * as papa from "papaparse"; import { filter, firstValueFrom, map } from "rxjs"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { - CollectionService, - CollectionData, - Collection, - CollectionDetailsResponse, CollectionView, -} from "@bitwarden/admin-console/common"; + CollectionDetailsResponse, + Collection, + CollectionData, +} from "@bitwarden/common/admin-console/models/collections"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; @@ -383,6 +383,7 @@ export class OrganizationVaultExportService decCiphers.forEach((c) => { const cipher = new CipherWithIdExport(); cipher.build(c); + delete cipher.key; jsonDoc.items.push(cipher); }); return JSON.stringify(jsonDoc, null, " "); diff --git a/libs/tools/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/export/vault-export/vault-export-ui/src/components/export.component.html b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html index f41375edd5a..6f2fcbeafcf 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html @@ -34,9 +34,11 @@ <bit-form-field> <bit-label>{{ "fileFormat" | i18n }}</bit-label> - <bit-select formControlName="format"> - <bit-option *ngFor="let f of formatOptions$ | async" [value]="f.format" [label]="f.name" /> - </bit-select> + <select bitInput formControlName="format"> + @for (f of formatOptions$ | async; track f.format) { + <option [value]="f.format">{{ f.name }}</option> + } + </select> </bit-form-field> <ng-container *ngIf="format === 'encrypted_json'"> diff --git a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts index 38257df603a..15b50a3809c 100644 --- a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts +++ b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts @@ -6,9 +6,9 @@ import { FormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { DIALOG_DATA, DialogRef, @@ -44,8 +44,10 @@ export const SendItemDialogResult = Object.freeze({ } as const); /** A result of the Send add/edit dialog. */ -export type SendItemDialogResult = (typeof SendItemDialogResult)[keyof typeof SendItemDialogResult]; - +export type SendItemDialogResult = { + result: (typeof SendItemDialogResult)[keyof typeof SendItemDialogResult]; + send?: SendView; +}; /** * Component for adding or editing a send item. */ @@ -93,7 +95,7 @@ export class SendAddEditDialogComponent { */ async onSendCreated(send: SendView) { // FIXME Add dialogService.open send-created dialog - this.dialogRef.close(SendItemDialogResult.Saved); + this.dialogRef.close({ result: SendItemDialogResult.Saved, send }); return; } @@ -101,14 +103,14 @@ export class SendAddEditDialogComponent { * Handles the event when the send is updated. */ async onSendUpdated(send: SendView) { - this.dialogRef.close(SendItemDialogResult.Saved); + this.dialogRef.close({ result: SendItemDialogResult.Saved }); } /** * Handles the event when the send is deleted. */ async onSendDeleted() { - this.dialogRef.close(SendItemDialogResult.Deleted); + this.dialogRef.close({ result: SendItemDialogResult.Deleted }); this.toastService.showToast({ variant: "success", diff --git a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts index 8f8390a170c..acdb7b56c2b 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts +++ b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts @@ -5,7 +5,7 @@ import { BehaviorSubject, of } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { NewSendDropdownV2Component } from "./new-send-dropdown-v2.component"; diff --git a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts index 7e7c4a2005b..f586373de70 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts +++ b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts @@ -6,7 +6,7 @@ import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/pre import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components"; diff --git a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html index fb9b82c44e5..7b966bb0345 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html +++ b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html @@ -7,11 +7,13 @@ <i class="bwi bwi-file-text" slot="start" aria-hidden="true"></i> {{ "sendTypeText" | i18n }} </a> - <a bitMenuItem (click)="sendFileClick()"> + <a bitMenuItem (click)="sendFileClick()" [title]="'popOutNewWindow' | i18n"> <div class="tw-flex tw-items-center tw-gap-2"> <i class="bwi bwi-file" slot="start" aria-hidden="true"></i> {{ "sendTypeFile" | i18n }} <app-premium-badge></app-premium-badge> </div> + <span class="tw-sr-only">{{ "popOutNewWindow" | i18n }}</span> + <i class="bwi bwi-popout tw-text-muted" slot="end" aria-hidden="true"></i> </a> </bit-menu> diff --git a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts index e1474175267..b5cbeced209 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts +++ b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts @@ -7,7 +7,7 @@ import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/pre import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components"; diff --git a/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts b/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts index 0859986664a..4f30860b6a6 100644 --- a/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts +++ b/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts @@ -1,5 +1,5 @@ -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { Send } from "@bitwarden/common/tools/send/models/domain/send"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; /** diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.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 @@ <bit-label>{{ "limitSendViews" | i18n }}</bit-label> <input bitInput type="number" formControlName="maxAccessCount" min="1" /> <bit-hint>{{ "limitSendViewsHint" | i18n }}</bit-hint> - <bit-hint *ngIf="shouldShowCount" - > {{ "limitSendViewsCount" | i18n: viewsLeft }}</bit-hint - > + @if (shouldShowCount) { + <bit-hint> {{ "limitSendViewsCount" | i18n: viewsLeft }}</bit-hint> + } </bit-form-field> - <bit-form-field> - <bit-label>{{ (passwordRemoved ? "newPassword" : "password") | i18n }}</bit-label> - <input bitInput type="password" formControlName="password" /> - <button - data-testid="toggle-visibility-for-password" - type="button" - bitIconButton - bitSuffix - bitPasswordInputToggle - *ngIf="!hasPassword" - ></button> - <button - type="button" - bitIconButton="bwi-generate" - bitSuffix - [label]="'generatePassword' | i18n" - [disabled]="!config.areSendsAllowed" - (click)="generatePassword()" - data-testid="generate-password" - *ngIf="!hasPassword" - ></button> - <button - type="button" - bitIconButton="bwi-clone" - bitSuffix - [label]="'copyPassword' | i18n" - [disabled]="!config.areSendsAllowed || !sendOptionsForm.get('password').value" - [valueLabel]="'password' | i18n" - [appCopyClick]="sendOptionsForm.get('password').value" - showToast - *ngIf="!hasPassword" - ></button> - <button - *ngIf="hasPassword" - class="tw-border-l-0 last:tw-rounded-r focus-visible:tw-border-l focus-visible:tw-ml-[-1px]" - bitSuffix - type="button" - buttonType="danger" - bitIconButton="bwi-minus-circle" - [label]="'removePassword' | i18n" - [bitAction]="removePassword" - showToast - ></button> - <bit-hint>{{ "sendPasswordDescV3" | i18n }}</bit-hint> - </bit-form-field> - <bit-form-control *ngIf="!disableHideEmail || originalSendView?.hideEmail"> - <input - [disabled]="disableHideEmail && !sendOptionsForm.get('hideEmail').value" - bitCheckbox - type="checkbox" - formControlName="hideEmail" - /> - <bit-label>{{ "hideYourEmail" | i18n }}</bit-label> - </bit-form-control> + + @if (!disableHideEmail || originalSendView?.hideEmail) { + <bit-form-control> + <input + [disabled]="disableHideEmail && !sendOptionsForm.get('hideEmail').value" + bitCheckbox + type="checkbox" + formControlName="hideEmail" + /> + <bit-label>{{ "hideYourEmail" | i18n }}</bit-label> + </bit-form-control> + } <bit-form-field disableMargin> <bit-label>{{ "privateNote" | i18n }}</bit-label> <textarea bitInput rows="3" formControlName="notes"></textarea> diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts index 6724bb324c3..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 { SendType } from "@bitwarden/common/tools/send/enums/send-type"; -import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; -import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; -import { DialogService, ToastService } from "@bitwarden/components"; -import { CredentialGeneratorService } from "@bitwarden/generator-core"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendFormContainer } from "../../send-form-container"; @@ -32,14 +27,9 @@ describe("SendOptionsComponent", () => { declarations: [], providers: [ { provide: SendFormContainer, useValue: mockSendFormContainer }, - { provide: DialogService, useValue: mock<DialogService>() }, - { provide: SendApiService, useValue: mock<SendApiService>() }, { provide: PolicyService, useValue: mock<PolicyService>() }, { provide: I18nService, useValue: mock<I18nService>() }, - { provide: ToastService, useValue: mock<ToastService>() }, - { provide: CredentialGeneratorService, useValue: mock<CredentialGeneratorService>() }, { provide: AccountService, useValue: mockAccountService }, - { provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() }, ], }).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<GenerateRequest>({ 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..dc1894b0935 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 @@ <bit-card> <bit-form-field> <bit-label>{{ "name" | i18n }}</bit-label> - <input appAutofocus bitInput type="text" formControlName="name" /> + <input bitInput type="text" formControlName="name" /> </bit-form-field> <tools-send-text-details @@ -30,11 +30,12 @@ showToast bitIconButton="bwi-clone" [appCopyClick]="sendLink" + [valueLabel]="'sendLink' | i18n" [label]="'copySendLink' | i18n" ></button> </bit-form-field> - <bit-form-field disableMargin> + <bit-form-field> <bit-label>{{ "deletionDate" | i18n }}</bit-label> <bit-select id="deletionDate" @@ -49,6 +50,82 @@ </bit-select> <bit-hint>{{ "deletionDateDescV2" | i18n }}</bit-hint> </bit-form-field> + + <bit-form-field [disableMargin]="!sendDetailsForm.get('authType').value"> + <bit-label>{{ "whoCanView" | i18n }}</bit-label> + <bit-select formControlName="authType"> + @for (option of availableAuthTypes$ | async; track option.value) { + <bit-option [value]="option.value" [label]="option.name"></bit-option> + } + </bit-select> + @if (sendDetailsForm.get("authType").value === AuthType.Email) { + <bit-hint class="tw-mt-2">{{ "emailVerificationDesc" | i18n }}</bit-hint> + } + @if (sendDetailsForm.get("authType").value === AuthType.Password) { + <bit-hint class="tw-mt-2">{{ "sendPasswordHelperText" | i18n }}</bit-hint> + } + </bit-form-field> + + @if (sendDetailsForm.get("authType").value === AuthType.Password) { + <bit-form-field disableMargin> + <bit-label>{{ (passwordRemoved ? "newPassword" : "password") | i18n }}</bit-label> + <input bitInput type="password" formControlName="password" /> + <div bitSuffix ngProjectAs="[bitSuffix]" class="tw-flex tw-items-center"> + @if (!hasPassword) { + <button + data-testid="toggle-visibility-for-password" + type="button" + bitIconButton + size="small" + bitPasswordInputToggle + ></button> + <button + type="button" + bitIconButton="bwi-generate" + size="small" + [label]="'generatePassword' | i18n" + [disabled]="!config.areSendsAllowed" + (click)="generatePassword()" + data-testid="generate-password" + ></button> + <button + type="button" + bitIconButton="bwi-clone" + size="small" + [label]="'copyPassword' | i18n" + [disabled]="!config.areSendsAllowed || !sendDetailsForm.get('password').value" + [valueLabel]="'password' | i18n" + [appCopyClick]="sendDetailsForm.get('password').value" + showToast + ></button> + } @else { + <button + class="tw-border-l-0 last:tw-rounded-r focus-visible:tw-border-l focus-visible:tw-ml-[-1px]" + type="button" + buttonType="danger" + bitIconButton="bwi-minus-circle" + size="small" + [label]="'removePassword' | i18n" + [bitAction]="removePassword" + showToast + ></button> + } + </div> + </bit-form-field> + } + + @if (sendDetailsForm.get("authType").value === AuthType.Email) { + <bit-form-field disableMargin class="tw-mt-4"> + <bit-label>{{ "emails" | i18n }}</bit-label> + <textarea + bitInput + formControlName="emails" + rows="3" + [placeholder]="'emailPlaceholder' | i18n" + ></textarea> + <bit-hint>{{ "enterMultipleEmailsSeparatedByComma" | i18n }}</bit-hint> + </bit-form-field> + } </bit-card> <tools-send-options [config]="config" [originalSendView]="originalSendView"></tools-send-options> </bit-section> 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<SendDetailsComponent>; + const mockSendFormContainer = mock<SendFormContainer>(); + const mockI18nService = mock<I18nService>(); + const mockConfigService = mock<ConfigService>(); + const mockAccountService = mock<AccountService>(); + const mockBillingStateService = mock<BillingAccountProfileStateService>(); + const mockGeneratorService = mock<CredentialGeneratorService>(); + const mockSendApiService = mock<SendApiService>(); + const mockEnvironmentService = mock<EnvironmentService>(); + + 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<PolicyService>() }, + { provide: DialogService, useValue: mock<DialogService>() }, + { provide: ToastService, useValue: mock<ToastService>() }, + ], + }).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 ec351bee923..ac1453a925c 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,14 +3,29 @@ 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 { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +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, SectionHeaderComponent, @@ -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,111 @@ 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, + authType: value.authType, + 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 +245,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 +304,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<GenerateRequest>({ 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/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts index 4e4900039c7..7b00f17cc9c 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts @@ -4,9 +4,9 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, Validators, ReactiveFormsModule, FormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendFileView } from "@bitwarden/common/tools/send/models/view/send-file.view"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ButtonModule, FormFieldModule, diff --git a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts index 0471ed90eef..53a9365bf99 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts @@ -18,8 +18,8 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { AsyncActionsModule, BitSubmitDirective, diff --git a/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts b/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts index 343fa880795..9178991a028 100644 --- a/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts +++ b/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts @@ -7,8 +7,8 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; import { diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html index 2ece050e8c3..c4367d3ac57 100644 --- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html @@ -26,16 +26,22 @@ ></i> </div> {{ send.name }} - <ng-container *ngIf="send.maxAccessCountReached"> - <i - class="bwi bwi-exclamation-triangle" - appStopProp - title="{{ 'maxAccessCountReached' | i18n }}" - aria-hidden="true" - ></i> - <span class="tw-sr-only">{{ "maxAccessCountReached" | i18n }}</span> - </ng-container> - + <div slot="default-trailing" class="tw-flex tw-gap-2 tw-relative tw-z-10"> + @if (send.authType !== authType.None) { + @let titleKey = + send.authType === authType.Email ? "emailProtected" : "passwordProtected"; + <i class="bwi bwi-lock" appA11yTitle="{{ titleKey | i18n }}" aria-hidden="true"></i> + <span class="tw-sr-only">{{ titleKey | i18n }}</span> + } + @if (send.maxAccessCountReached) { + <i + class="bwi bwi-exclamation-triangle" + appA11yTitle="{{ 'maxAccessCountReached' | i18n }}" + aria-hidden="true" + ></i> + <span class="tw-sr-only">{{ "maxAccessCountReached" | i18n }}</span> + } + </div> <span slot="secondary"> {{ "deletionDate" | i18n }}: {{ send.deletionDate | date: "mediumDate" }} </span> diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts index d885f279bc6..2f543fb5879 100644 --- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts @@ -10,9 +10,10 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BadgeModule, ButtonModule, @@ -45,6 +46,7 @@ import { }) export class SendListItemsContainerComponent { sendType = SendType; + authType = AuthType; /** * The list of sends to display. */ diff --git a/libs/tools/send/send-ui/src/send-list/send-list.component.ts b/libs/tools/send/send-ui/src/send-list/send-list.component.ts index d90f77913aa..17fc006feef 100644 --- a/libs/tools/send/send-ui/src/send-list/send-list.component.ts +++ b/libs/tools/send/send-ui/src/send-list/send-list.component.ts @@ -1,17 +1,8 @@ import { CommonModule } from "@angular/common"; -import { - ChangeDetectionStrategy, - Component, - computed, - effect, - inject, - input, - output, -} from "@angular/core"; +import { ChangeDetectionStrategy, Component, computed, effect, input, output } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { NoResults, NoSendsIcon } from "@bitwarden/assets/svg"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { ButtonModule, @@ -57,8 +48,6 @@ export class SendListComponent { protected readonly noResultsIcon = NoResults; protected readonly sendListState = SendListState; - private i18nService = inject(I18nService); - readonly sends = input.required<SendView[]>(); readonly loading = input<boolean>(false); readonly disableSend = input<boolean>(false); @@ -70,7 +59,7 @@ export class SendListComponent { ); protected readonly noSearchResults = computed( - () => this.showSearchBar() && (this.sends().length === 0 || this.searchText().length > 0), + () => this.showSearchBar() && this.sends().length === 0, ); // Reusable data source instance - updated reactively when sends change diff --git a/libs/tools/send/send-ui/src/send-search/send-search.component.html b/libs/tools/send/send-ui/src/send-search/send-search.component.html index 7cf154c0ee8..fbbe436d158 100644 --- a/libs/tools/send/send-ui/src/send-search/send-search.component.html +++ b/libs/tools/send/send-ui/src/send-search/send-search.component.html @@ -1,7 +1 @@ -<bit-search - [placeholder]="'search' | i18n" - [(ngModel)]="searchText" - (ngModelChange)="onSearchTextChanged()" - appAutofocus -> -</bit-search> +<bit-search [placeholder]="'search' | i18n" [(ngModel)]="searchText" appAutofocus /> diff --git a/libs/tools/send/send-ui/src/send-search/send-search.component.ts b/libs/tools/send/send-ui/src/send-search/send-search.component.ts index 02cb5ef2eda..03eaf9b3430 100644 --- a/libs/tools/send/send-ui/src/send-search/send-search.component.ts +++ b/libs/tools/send/send-ui/src/send-search/send-search.component.ts @@ -1,50 +1,55 @@ -import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ChangeDetectionStrategy, Component, inject, model } from "@angular/core"; +import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; -import { Subject, Subscription, debounceTime, filter } from "rxjs"; +import { debounceTime, filter } from "rxjs"; -import { JslibModule } from "@bitwarden/angular/jslib.module"; import { SearchModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; import { SendItemsService } from "../services/send-items.service"; const SearchTextDebounceInterval = 200; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +/** + * Search component for filtering Send items. + * + * Provides a search input that filters the Send list with debounced updates. + * Syncs with the service's latest search text to maintain state across navigation. + */ @Component({ - imports: [CommonModule, SearchModule, JslibModule, FormsModule], selector: "tools-send-search", templateUrl: "send-search.component.html", + imports: [FormsModule, I18nPipe, SearchModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SendSearchComponent { - searchText: string = ""; + private sendListItemService = inject(SendItemsService); - private searchText$ = new Subject<string>(); + /** The current search text entered by the user. */ + protected readonly searchText = model(""); - constructor(private sendListItemService: SendItemsService) { + constructor() { this.subscribeToLatestSearchText(); this.subscribeToApplyFilter(); } - onSearchTextChanged() { - this.searchText$.next(this.searchText); - } - - subscribeToLatestSearchText(): Subscription { - return this.sendListItemService.latestSearchText$ + private subscribeToLatestSearchText(): void { + this.sendListItemService.latestSearchText$ .pipe( takeUntilDestroyed(), filter((data) => !!data), ) .subscribe((text) => { - this.searchText = text; + this.searchText.set(text); }); } - subscribeToApplyFilter(): Subscription { - return this.searchText$ + /** + * Applies the search filter to the Send list with a debounce delay. + * This prevents excessive filtering while the user is still typing. + */ + private subscribeToApplyFilter(): void { + toObservable(this.searchText) .pipe(debounceTime(SearchTextDebounceInterval), takeUntilDestroyed()) .subscribe((data) => { this.sendListItemService.applyFilter(data); diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.html b/libs/tools/send/send-ui/src/send-table/send-table.component.html index 96b9519019e..1c235415cae 100644 --- a/libs/tools/send/send-ui/src/send-table/send-table.component.html +++ b/libs/tools/send/send-ui/src/send-table/send-table.component.html @@ -15,10 +15,10 @@ <div class="tw-flex tw-gap-2 tw-items-center"> <span aria-hidden="true"> @if (s.type == sendType.File) { - <i class="bwi bwi-fw bwi-lg bwi-file"></i> + <i class="bwi bwi-fw bwi-lg bwi-file tw-text-muted"></i> } @if (s.type == sendType.Text) { - <i class="bwi bwi-fw bwi-lg bwi-file-text"></i> + <i class="bwi bwi-fw bwi-lg bwi-file-text tw-text-muted"></i> } </span> <button type="button" bitLink> @@ -33,14 +33,16 @@ ></i> <span class="tw-sr-only">{{ "disabled" | i18n }}</span> } - @if (s.password) { + @if (s.authType !== authType.None) { + @let titleKey = + s.authType === authType.Email ? "emailProtected" : "passwordProtected"; <i - class="bwi bwi-key" + class="bwi bwi-lock" appStopProp - title="{{ 'password' | i18n }}" + title="{{ titleKey | i18n }}" aria-hidden="true" ></i> - <span class="tw-sr-only">{{ "password" | i18n }}</span> + <span class="tw-sr-only">{{ titleKey | i18n }}</span> } @if (s.maxAccessCountReached) { <i @@ -83,6 +85,7 @@ <td bitCell class="tw-w-0 tw-text-right"> <button type="button" + size="small" [bitMenuTriggerFor]="sendOptions" bitIconButton="bwi-ellipsis-v" label="{{ 'options' | i18n }}" diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts b/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts index d2d630b69a2..90969ccd85f 100644 --- a/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts +++ b/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts @@ -1,8 +1,9 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { TableDataSource, I18nMockService } from "@bitwarden/components"; import { SendTableComponent } from "./send-table.component"; @@ -13,6 +14,7 @@ function createMockSend(id: number, overrides: Partial<SendView> = {}): SendView send.id = `send-${id}`; send.name = "My Send"; send.type = SendType.Text; + send.authType = AuthType.None; send.deletionDate = new Date("2030-01-01T12:00:00Z"); send.password = null as any; @@ -34,21 +36,29 @@ dataSource.data = [ createMockSend(2, { name: "Password Protected Send", type: SendType.Text, + authType: AuthType.Password, password: "123", }), createMockSend(3, { + name: "Email Protected Send", + type: SendType.Text, + authType: AuthType.Email, + emails: ["ckent@dailyplanet.com"], + }), + createMockSend(4, { name: "Disabled Send", type: SendType.Text, disabled: true, }), - createMockSend(4, { + createMockSend(5, { name: "Expired Send", type: SendType.File, expirationDate: new Date("2025-12-01T00:00:00Z"), }), - createMockSend(5, { + createMockSend(6, { name: "Max Access Reached", type: SendType.Text, + authType: AuthType.Password, maxAccessCount: 5, accessCount: 5, password: "123", @@ -69,7 +79,8 @@ export default { deletionDate: "Deletion Date", options: "Options", disabled: "Disabled", - password: "Password", + passwordProtected: "Password protected", + emailProtected: "Email protected", maxAccessCountReached: "Max access count reached", expired: "Expired", pendingDeletion: "Pending deletion", diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.ts b/libs/tools/send/send-ui/src/send-table/send-table.component.ts index c912a01f98a..1475d9c65d1 100644 --- a/libs/tools/send/send-ui/src/send-table/send-table.component.ts +++ b/libs/tools/send/send-ui/src/send-table/send-table.component.ts @@ -2,8 +2,9 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BadgeModule, ButtonModule, @@ -37,6 +38,7 @@ import { }) export class SendTableComponent { protected readonly sendType = SendType; + protected readonly authType = AuthType; /** * The data source containing the Send items to display in the table. diff --git a/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts b/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts index ef38938aba8..096ae95ad66 100644 --- a/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts +++ b/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts @@ -4,8 +4,8 @@ import { BehaviorSubject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendListFiltersService } from "./send-list-filters.service"; diff --git a/libs/tools/send/send-ui/src/services/send-list-filters.service.ts b/libs/tools/send/send-ui/src/services/send-list-filters.service.ts index b266ad08a69..cf84204ba0d 100644 --- a/libs/tools/send/send-ui/src/services/send-list-filters.service.ts +++ b/libs/tools/send/send-ui/src/services/send-list-filters.service.ts @@ -5,8 +5,8 @@ import { FormBuilder } from "@angular/forms"; import { map, Observable, startWith } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { ChipSelectOption } from "@bitwarden/components"; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/abstractions/vault-filter.service.ts b/libs/vault/src/abstractions/vault-filter.service.ts similarity index 91% rename from apps/web/src/app/vault/individual-vault/vault-filter/services/abstractions/vault-filter.service.ts rename to libs/vault/src/abstractions/vault-filter.service.ts index 0e3ee69a2c6..22a1cf01cee 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/abstractions/vault-filter.service.ts +++ b/libs/vault/src/abstractions/vault-filter.service.ts @@ -2,7 +2,10 @@ // @ts-strict-ignore import { Observable } from "rxjs"; -import { CollectionAdminView, CollectionView } from "@bitwarden/admin-console/common"; +import { + CollectionAdminView, + CollectionView, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { UserId } from "@bitwarden/common/types/guid"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; @@ -13,7 +16,7 @@ import { CollectionFilter, FolderFilter, OrganizationFilter, -} from "../../shared/models/vault-filter.type"; +} from "../models/vault-filter.type"; export abstract class VaultFilterService { collapsedFilterNodes$: Observable<Set<string>>; diff --git a/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts b/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts index d1792da422c..a4aabbb6f19 100644 --- a/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts +++ b/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts @@ -1,6 +1,4 @@ -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -30,6 +28,7 @@ export type OptionalInitialValues = { // Credit Card Information cardholderName?: string; number?: string; + brand?: string; expMonth?: string; expYear?: string; code?: string; diff --git a/libs/vault/src/cipher-form/cipher-form.stories.ts b/libs/vault/src/cipher-form/cipher-form.stories.ts index e732513913d..475a026ff8b 100644 --- a/libs/vault/src/cipher-form/cipher-form.stories.ts +++ b/libs/vault/src/cipher-form/cipher-form.stories.ts @@ -12,14 +12,12 @@ import { import { BehaviorSubject, of } from "rxjs"; import { action } from "storybook/actions"; -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { CollectionView } from "@bitwarden/admin-console/common"; import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; import { NudgeStatus, NudgesService } from "@bitwarden/angular/vault"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html index 855c37ecab5..0e214abd72d 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 @@ -4,96 +4,124 @@ <ul aria-labelledby="attachments" class="tw-list-none tw-pl-0"> @for (attachment of attachments; track attachment.id) { <li> - <bit-item> - <bit-item-content> - <span data-testid="file-name" [title]="attachment.fileName">{{ - attachment.fileName - }}</span> - <span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span> - <i - *ngIf="attachment.key == null" - slot="default-trailing" - class="bwi bwi-exclamation-triangle bwi-sm tw-text-muted" - [appA11yTitle]="'fixEncryptionTooltip' | i18n" - ></i> - </bit-item-content> - - <ng-container slot="end"> - <bit-item-action> - @if (attachment.key != null) { - <app-download-attachment - [admin]="admin() && organization()?.canEditAllCiphers" - [cipher]="cipher()" - [attachment]="attachment" - ></app-download-attachment> - } @else { - <button - [bitAction]="fixOldAttachment(attachment)" - bitButton - buttonType="primary" - size="small" - type="button" - > - {{ "fixEncryption" | i18n }} - </button> + @if (!attachment.hasDecryptionError) { + <bit-item> + <bit-item-content> + <span data-testid="file-name" [title]="attachment.fileName"> + {{ attachment.fileName }} + </span> + <span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span> + @if (attachment.key == null) { + <i + slot="default-trailing" + class="bwi bwi-exclamation-triangle bwi-sm tw-text-muted" + [appA11yTitle]="'fixEncryptionTooltip' | i18n" + ></i> } - </bit-item-action> - <bit-item-action> - <app-delete-attachment - [admin]="admin() && organization()?.canEditAllCiphers" - [cipherId]="cipher().id" - [attachment]="attachment" - (onDeletionSuccess)="removeAttachment(attachment)" - ></app-delete-attachment> - </bit-item-action> - </ng-container> - </bit-item> + </bit-item-content> + + <ng-container slot="end"> + <bit-item-action> + @if (attachment.key != null) { + <app-download-attachment + [admin]="admin() && organization()?.canEditAllCiphers" + [cipher]="cipher()" + [attachment]="attachment" + ></app-download-attachment> + } @else { + <button + [bitAction]="fixOldAttachment(attachment)" + bitButton + buttonType="primary" + size="small" + type="button" + > + {{ "fixEncryption" | i18n }} + </button> + } + </bit-item-action> + @if (cipher().edit) { + <bit-item-action> + <app-delete-attachment + [admin]="admin() && organization()?.canEditAllCiphers" + [cipherId]="cipher().id" + [attachment]="attachment" + (onDeletionSuccess)="removeAttachment(attachment)" + ></app-delete-attachment> + </bit-item-action> + } + </ng-container> + </bit-item> + } @else { + <bit-item> + <bit-item-content> + <span data-testid="file-name" [title]="'errorCannotDecrypt' | i18n"> + {{ "errorCannotDecrypt" | i18n }} + </span> + </bit-item-content> + + <ng-container slot="end"> + @if (cipher().edit) { + <bit-item-action> + <app-delete-attachment + [admin]="admin() && organization()?.canEditAllCiphers" + [cipherId]="cipher().id" + [attachment]="attachment" + (onDeletionSuccess)="removeAttachment(attachment)" + ></app-delete-attachment> + </bit-item-action> + } + </ng-container> + </bit-item> + } </li> } </ul> } <form [id]="attachmentFormId" [formGroup]="attachmentForm" [bitSubmit]="submit"> - <bit-card> - <label for="file" bitTypography="body2" class="tw-block tw-text-muted tw-px-1 tw-pb-1.5"> - {{ "addAttachment" | i18n }} - </label> - <div class="tw-relative"> - <!-- Input elements are notoriously difficult to style, ---> - <!-- The native `<input>` will be used for screen readers --> - <!-- Visual & keyboard users will interact with the styled button element --> - <input - #fileInput - class="tw-sr-only" - type="file" - id="file" - name="file" - aria-describedby="fileHelp" - tabindex="-1" - required - (change)="onFileChange($event)" - /> - <div class="tw-flex tw-gap-2 tw-items-center" aria-hidden="true"> - <button - bitButton - buttonType="secondary" - type="button" - (click)="fileInput.click()" - class="tw-whitespace-nowrap" - > - {{ "chooseFile" | i18n }} - </button> - <p bitTypography="body2" class="tw-text-muted tw-mb-0"> - {{ - this.attachmentForm.controls.file?.value - ? this.attachmentForm.controls.file.value.name - : ("noFileChosen" | i18n) - }} - </p> + @if (cipher()?.edit) { + <bit-card> + <label for="file" bitTypography="body2" class="tw-block tw-text-muted tw-px-1 tw-pb-1.5"> + {{ "addAttachment" | i18n }} + </label> + <div class="tw-relative"> + <!-- Input elements are notoriously difficult to style, ---> + <!-- The native `<input>` will be used for screen readers --> + <!-- Visual & keyboard users will interact with the styled button element --> + <input + #fileInput + class="tw-sr-only" + type="file" + id="file" + name="file" + aria-describedby="fileHelp" + tabindex="-1" + required + (change)="onFileChange($event)" + /> + <div class="tw-flex tw-gap-2 tw-items-center" aria-hidden="true"> + <button + bitButton + buttonType="secondary" + type="button" + (click)="fileInput.click()" + class="tw-whitespace-nowrap" + > + {{ "chooseFile" | i18n }} + </button> + <p bitTypography="body2" class="tw-text-muted tw-mb-0"> + {{ + this.attachmentForm.controls.file?.value + ? this.attachmentForm.controls.file.value.name + : ("noFileChosen" | i18n) + }} + </p> + </div> </div> - </div> - <p id="fileHelp" bitTypography="helper" class="tw-text-muted tw-px-1 tw-pt-1 tw-mb-0"> - {{ "maxFileSizeSansPunctuation" | i18n }} - </p> - </bit-card> + <p id="fileHelp" bitTypography="helper" class="tw-text-muted tw-px-1 tw-pt-1 tw-mb-0"> + {{ "maxFileSizeSansPunctuation" | i18n }} + </p> + </bit-card> + } </form> 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..88ee1f9b599 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 = { @@ -172,7 +173,7 @@ describe("CipherAttachmentsComponent", () => { const fileSize = fixture.debugElement.query(By.css('[data-testid="file-size"]')); expect(fileName.nativeElement.textContent.trim()).toEqual(attachment.fileName); - expect(fileSize.nativeElement.textContent).toEqual(attachment.sizeName); + expect(fileSize.nativeElement.textContent.trim()).toEqual(attachment.sizeName); }); describe("bitSubmit", () => { @@ -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<void> { + 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<void>(); + readonly onCloseButtonPress = output<void>(); + protected readonly organization = signal<Organization | null>(null); protected readonly cipher = signal<CipherView | null>(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/components/autofill-options/advanced-uri-option-dialog.component.ts b/libs/vault/src/cipher-form/components/autofill-options/advanced-uri-option-dialog.component.ts index 3580b1fada8..04545730172 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/advanced-uri-option-dialog.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/advanced-uri-option-dialog.component.ts @@ -3,13 +3,13 @@ import { Component, inject } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { - ButtonLinkDirective, ButtonModule, + CenterPositionStrategy, DialogModule, + DialogRef, DialogService, DIALOG_DATA, - DialogRef, - CenterPositionStrategy, + LinkComponent, } from "@bitwarden/components"; export type AdvancedUriOptionDialogParams = { @@ -22,7 +22,7 @@ export type AdvancedUriOptionDialogParams = { // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "advanced-uri-option-dialog.component.html", - imports: [ButtonLinkDirective, ButtonModule, DialogModule, JslibModule], + imports: [LinkComponent, ButtonModule, DialogModule, JslibModule], }) export class AdvancedUriOptionDialogComponent { constructor(private dialogRef: DialogRef<boolean>) {} diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts index ed70b4381d2..771500a9887 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts @@ -153,12 +153,12 @@ describe("UriOptionComponent", () => { component.writeValue({ uri: "https://example.com", matchDetection: UriMatchStrategy.Exact }); fixture.detectChanges(); expect(getToggleMatchDetectionBtn().getAttribute("aria-label")).toBe( - "showMatchDetection https://example.com", + "showMatchDetectionNoPlaceholder", ); getToggleMatchDetectionBtn().click(); fixture.detectChanges(); expect(getToggleMatchDetectionBtn().getAttribute("aria-label")).toBe( - "hideMatchDetection https://example.com", + "hideMatchDetectionNoPlaceholder", ); }); }); diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts index 34ac284c3f3..fca22a5afb6 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts @@ -165,9 +165,11 @@ export class UriOptionComponent implements ControlValueAccessor { } protected get toggleTitle() { - return this.showMatchDetection - ? this.i18nService.t("hideMatchDetection", this.uriForm.value.uri) - : this.i18nService.t("showMatchDetection", this.uriForm.value.uri); + return this.i18nService.t( + this.showMatchDetection + ? "hideMatchDetectionNoPlaceholder" + : "showMatchDetectionNoPlaceholder", + ); } // NG_VALUE_ACCESSOR implementation diff --git a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.spec.ts index 4b0cd0f5f90..650b4e29fe5 100644 --- a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.spec.ts @@ -108,12 +108,17 @@ describe("CardDetailsSectionComponent", () => { const cardholderName = "Ron Burgundy"; const number = "4242 4242 4242 4242"; const code = "619"; + const brand = "Maestro"; + const expMonth = "5"; + const expYear = "2028"; const cardView = new CardView(); cardView.cardholderName = cardholderName; cardView.number = number; cardView.code = code; - cardView.brand = "Visa"; + cardView.brand = brand; + cardView.expMonth = expMonth; + cardView.expYear = expYear; getInitialCipherView.mockReturnValueOnce({ card: cardView }); @@ -123,7 +128,9 @@ describe("CardDetailsSectionComponent", () => { cardholderName, number, code, - brand: cardView.brand, + brand, + expMonth, + expYear, }); }); @@ -154,4 +161,27 @@ describe("CardDetailsSectionComponent", () => { expect(heading.nativeElement.textContent.trim()).toBe("cardDetails"); }); + + it("initializes `cardDetailsForm` from `initialValues` when provided and editing existing cipher", () => { + const initialCardholderName = "New Name"; + const initialBrand = "Amex"; + + (cipherFormProvider as any).config = { + initialValues: { + cardholderName: initialCardholderName, + brand: initialBrand, + }, + }; + + const existingCard = new CardView(); + existingCard.cardholderName = "Old Name"; + existingCard.brand = "Visa"; + + getInitialCipherView.mockReturnValueOnce({ card: existingCard }); + + component.ngOnInit(); + + expect(component.cardDetailsForm.value.cardholderName).toBe(initialCardholderName); + expect(component.cardDetailsForm.value.brand).toBe(initialBrand); + }); }); diff --git a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts index 5fa8d0af131..056b93b6b99 100644 --- a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts @@ -158,6 +158,7 @@ export class CardDetailsSectionComponent implements OnInit { this.cardDetailsForm.patchValue({ cardholderName: this.initialValues?.cardholderName ?? existingCard.cardholderName, number: this.initialValues?.number ?? existingCard.number, + brand: this.initialValues?.brand ?? existingCard.brand, expMonth: this.initialValues?.expMonth ?? existingCard.expMonth, expYear: this.initialValues?.expYear ?? existingCard.expYear, code: this.initialValues?.code ?? existingCard.code, diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index 62f23e77ec2..61ba2d19f8f 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -5,11 +5,13 @@ import { By } from "@angular/platform-browser"; import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { CollectionType, CollectionTypes, CollectionView } from "@bitwarden/admin-console/common"; import { ClientType } from "@bitwarden/client-type"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { + CollectionView, + CollectionType, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index 6dd2dafe5e8..4985f777a0e 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -6,13 +6,14 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms"; import { concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs"; -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ClientType } from "@bitwarden/client-type"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { OrganizationUserType, PolicyType } from "@bitwarden/common/admin-console/enums"; +import { + CollectionView, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; diff --git a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts index 59c583f980b..63a9b3091e2 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,34 @@ 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( + // Updating a cipher with collection changes is not supported with a single request currently + // Save the new collectionIds before overwriting + const newCollectionIdsToSave = cipher.collectionIds; + + // First update the cipher with the original collectionIds + cipher.collectionIds = config.originalCipher.collectionIds; + const newCipher = await this.cipherService.updateWithServer( 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, + originalCipherView, config.admin || originalCollectionIds.size === 0, ); // Then save the new collection changes separately - encryptedCipher.collectionIds = cipher.collectionIds; + newCipher.collectionIds = newCollectionIdsToSave; + // 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-view.component.html b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html index 2ba9a16357a..c8110b9e863 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html +++ b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html @@ -5,11 +5,15 @@ <bit-item-group> <bit-item *ngFor="let attachment of cipher.attachments"> <bit-item-content> - <span data-testid="file-name" [title]="attachment.fileName">{{ attachment.fileName }}</span> - <span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span> + <span data-testid="file-name" [title]="getAttachmentFileName(attachment)"> + {{ getAttachmentFileName(attachment) }} + </span> + @if (!attachment.hasDecryptionError) { + <span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span> + } </bit-item-content> <ng-container slot="end"> - <bit-item-action class="tw-pr-4"> + <bit-item-action class="tw-pr-4 [@media(min-width:650px)]:tw-pr-6"> <app-download-attachment [admin]="admin" [cipher]="cipher" diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.ts b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.ts index 4e324d8002e..3826d3a3ad0 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.ts +++ b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.ts @@ -8,9 +8,11 @@ import { NEVER, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { EmergencyAccessId, OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; +import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { ItemModule, @@ -59,6 +61,7 @@ export class AttachmentsV2ViewComponent { private billingAccountProfileStateService: BillingAccountProfileStateService, private stateProvider: StateProvider, private accountService: AccountService, + private i18nService: I18nService, ) { this.subscribeToHasPremiumCheck(); this.subscribeToOrgKey(); @@ -89,4 +92,12 @@ export class AttachmentsV2ViewComponent { } }); } + + getAttachmentFileName(attachment: AttachmentView): string { + if (attachment.hasDecryptionError) { + return this.i18nService.t("errorCannotDecrypt"); + } + + return attachment.fileName ?? ""; + } } 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()" ></app-cipher-attachments> </ng-container> <ng-container bitDialogFooter> <button bitButton type="submit" buttonType="primary" [attr.form]="attachmentFormId" #submitBtn> - {{ "upload" | i18n }} + {{ buttonText }} </button> </ng-container> </bit-dialog> 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<AttachmentDialogCloseResult>, @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.html b/libs/vault/src/cipher-view/cipher-view.component.html index 3d0cc4c4414..05d2ecede72 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.html +++ b/libs/vault/src/cipher-view/cipher-view.component.html @@ -12,9 +12,15 @@ </bit-callout> <bit-callout *ngIf="showChangePasswordLink()" type="warning" [title]="''"> - <a bitLink href="#" appStopClick (click)="launchChangePassword()" linkType="secondary"> + <a + bitLink + href="#" + appStopClick + (click)="launchChangePassword()" + linkType="secondary" + endIcon="bwi-external-link" + > {{ "changeAtRiskPassword" | i18n }} - <i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i> </a> </bit-callout> 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<boolean>; let hasPremiumFromAnySource$: BehaviorSubject<boolean>; let activeAccount$: BehaviorSubject<Account>; @@ -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<ConfigService>(); - 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 d5adb0b71a0..24713d3f612 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -5,15 +5,14 @@ import { combineLatest, of, switchMap, map, catchError, from, Observable, startW // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { 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"; @@ -31,7 +30,7 @@ import { CalloutModule, SearchModule, TypographyModule, - AnchorLinkDirective, + LinkComponent, } from "@bitwarden/components"; import { ChangeLoginPasswordService } from "../abstractions/change-login-password.service"; @@ -67,7 +66,7 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide ViewIdentitySectionsComponent, LoginCredentialsViewComponent, AutofillOptionsViewComponent, - AnchorLinkDirective, + LinkComponent, TypographyModule, ], }) @@ -112,7 +111,6 @@ export class CipherViewComponent { private logService: LogService, private cipherRiskService: CipherRiskService, private billingAccountService: BillingAccountProfileStateService, - private configService: ConfigService, ) {} readonly resolvedCollections = toSignal<CollectionView[] | undefined>( @@ -247,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/cipher-view/item-details/item-details-v2.component.spec.ts b/libs/vault/src/cipher-view/item-details/item-details-v2.component.spec.ts index ae78c49bdb4..338632fef04 100644 --- a/libs/vault/src/cipher-view/item-details/item-details-v2.component.spec.ts +++ b/libs/vault/src/cipher-view/item-details/item-details-v2.component.spec.ts @@ -4,9 +4,7 @@ import { By } from "@angular/platform-browser"; import { mock, MockProxy } from "jest-mock-extended"; import { of } from "rxjs"; -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { ClientType } from "@bitwarden/common/enums"; diff --git a/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts b/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts index 8132780ccf4..73e7c2706be 100644 --- a/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts +++ b/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts @@ -6,10 +6,12 @@ import { Component, computed, input, signal } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; import { fromEvent, map, startWith } from "rxjs"; -// eslint-disable-next-line no-restricted-imports -import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ClientType } from "@bitwarden/client-type"; +import { + CollectionView, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -17,9 +19,9 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { BadgeModule, - ButtonLinkDirective, CardComponent, FormFieldModule, + LinkComponent, TypographyModule, } from "@bitwarden/components"; @@ -37,7 +39,7 @@ import { OrgIconDirective } from "../../components/org-icon.directive"; TypographyModule, OrgIconDirective, FormFieldModule, - ButtonLinkDirective, + LinkComponent, BadgeModule, ], }) diff --git a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts index 9bf53826333..a783bdc7406 100644 --- a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts +++ b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts @@ -101,7 +101,7 @@ describe("AddEditFolderDialogComponent", () => { const newFolder = new FolderView(); newFolder.name = "New Folder"; - expect(encrypt).toHaveBeenCalledWith(newFolder, ""); + expect(encrypt).toHaveBeenCalledWith(expect.objectContaining({ name: "New Folder" }), ""); expect(save).toHaveBeenCalled(); }); diff --git a/libs/vault/src/components/assign-collections.component.spec.ts b/libs/vault/src/components/assign-collections.component.spec.ts index 414613e67d8..2dc13fef3f6 100644 --- a/libs/vault/src/components/assign-collections.component.spec.ts +++ b/libs/vault/src/components/assign-collections.component.spec.ts @@ -5,12 +5,12 @@ import { of } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { - CollectionService, - CollectionTypes, - CollectionView, -} from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { + CollectionView, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; diff --git a/libs/vault/src/components/assign-collections.component.ts b/libs/vault/src/components/assign-collections.component.ts index f0ce59b0c3c..0d04dd3ab32 100644 --- a/libs/vault/src/components/assign-collections.component.ts +++ b/libs/vault/src/components/assign-collections.component.ts @@ -26,17 +26,17 @@ 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 { - CollectionService, - CollectionTypes, - CollectionView, -} from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { getOrganizationById, OrganizationService, } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; +import { + CollectionView, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; diff --git a/libs/vault/src/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()" > - <bit-icon [icon]="CarouselIcon"></bit-icon> + <bit-svg [content]="CarouselIcon"></bit-svg> </button> 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: ` <vault-carousel label="Storybook Demo"> <vault-carousel-slide label="First Slide"> @@ -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/decryption-failure-dialog/decryption-failure-dialog.component.ts b/libs/vault/src/components/decryption-failure-dialog/decryption-failure-dialog.component.ts index 6b1a0e0d8aa..e829c003c5a 100644 --- a/libs/vault/src/components/decryption-failure-dialog/decryption-failure-dialog.component.ts +++ b/libs/vault/src/components/decryption-failure-dialog/decryption-failure-dialog.component.ts @@ -7,7 +7,7 @@ import { CipherId } from "@bitwarden/common/types/guid"; import { DIALOG_DATA, DialogRef, - AnchorLinkDirective, + LinkComponent, AsyncActionsModule, ButtonModule, DialogModule, @@ -32,7 +32,7 @@ export type DecryptionFailureDialogParams = { JslibModule, AsyncActionsModule, ButtonModule, - AnchorLinkDirective, + LinkComponent, ], }) export class DecryptionFailureDialogComponent { 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" ></button> } 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..05d5e9bc276 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 @@ -36,12 +36,11 @@ describe("DownloadAttachmentComponent", () => { .mockResolvedValue({ url: "https://www.downloadattachement.com" }); const download = jest.fn(); - const attachment = { - id: "222-3333-4444", - url: "https://www.attachment.com", - fileName: "attachment-filename", - size: "1234", - } as AttachmentView; + const attachment = new AttachmentView(); + attachment.id = "222-3333-4444"; + attachment.url = "https://www.attachment.com"; + attachment.fileName = "attachment-filename"; + attachment.size = "1234"; const cipherView = { id: "5555-444-3333", @@ -108,7 +107,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", () => { @@ -123,7 +122,12 @@ describe("DownloadAttachmentComponent", () => { }); it("hides download button when the attachment has decryption failure", () => { - const decryptFailureAttachment = { ...attachment, fileName: DECRYPT_ERROR }; + const decryptFailureAttachment = new AttachmentView(); + decryptFailureAttachment.id = attachment.id; + decryptFailureAttachment.url = attachment.url; + decryptFailureAttachment.size = attachment.size; + decryptFailureAttachment.fileName = DECRYPT_ERROR; + fixture.componentRef.setInput("attachment", decryptFailureAttachment); fixture.detectChanges(); diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.ts b/libs/vault/src/components/download-attachment/download-attachment.component.ts index 31ed609637c..bdca510c5aa 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.ts +++ b/libs/vault/src/components/download-attachment/download-attachment.component.ts @@ -4,7 +4,6 @@ import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { DECRYPT_ERROR } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -46,9 +45,7 @@ export class DownloadAttachmentComponent { private cipherService: CipherService, ) {} - protected readonly isDecryptionFailure = computed( - () => this.attachment().fileName === DECRYPT_ERROR, - ); + protected readonly isDecryptionFailure = computed(() => this.attachment().hasDecryptionError); /** Download the attachment */ download = async () => { diff --git a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html index 00cfa701529..d816b69bc58 100644 --- a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html +++ b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html @@ -4,12 +4,13 @@ bitButton buttonType="primary" type="button" - [bitMenuTriggerFor]="addOptions" + [bitMenuTriggerFor]="isOnlyCollectionCreation() ? null : addOptions" + (click)="handleButtonClick()" id="newItemDropdown" - [appA11yTitle]="'new' | i18n" + [appA11yTitle]="getButtonLabel() | i18n" > - <i class="bwi bwi-plus" aria-hidden="true"></i> - {{ "new" | i18n }} + <i class="bwi bwi-plus tw-me-2" aria-hidden="true"></i> + {{ getButtonLabel() | i18n }} </button> <bit-menu #addOptions aria-labelledby="newItemDropdown"> @for (item of cipherMenuItems$ | async; track item.type) { diff --git a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts index 0a755a9cdb4..1a592809691 100644 --- a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts +++ b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts @@ -1,6 +1,7 @@ import { CommonModule } from "@angular/common"; import { Component, input, output } from "@angular/core"; -import { map, shareReplay } from "rxjs"; +import { toObservable } from "@angular/core/rxjs-interop"; +import { combineLatest, map, shareReplay } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -38,10 +39,18 @@ export class NewCipherMenuComponent { /** * Returns an observable that emits the cipher menu items, filtered by the restricted types. */ - cipherMenuItems$ = this.restrictedItemTypesService.restricted$.pipe( - map((restrictedTypes) => { + cipherMenuItems$ = combineLatest([ + this.restrictedItemTypesService.restricted$, + toObservable(this.canCreateCipher), + toObservable(this.canCreateSshKey), + ]).pipe( + map(([restrictedTypes, canCreateCipher, canCreateSshKey]) => { + // If user cannot create ciphers at all, return empty array + if (!canCreateCipher) { + return []; + } return CIPHER_MENU_ITEMS.filter((item) => { - if (!this.canCreateSshKey() && item.type === CipherType.SshKey) { + if (!canCreateSshKey && item.type === CipherType.SshKey) { return false; } return !restrictedTypes.some((restrictedType) => restrictedType.cipherType === item.type); @@ -49,4 +58,40 @@ export class NewCipherMenuComponent { }), shareReplay({ bufferSize: 1, refCount: true }), ); + + /** + * Returns the appropriate button label based on what can be created. + * If only collections can be created (no ciphers or folders), show "New Collection". + * Otherwise, show "New". + */ + protected getButtonLabel(): string { + const canCreateCipher = this.canCreateCipher(); + const canCreateFolder = this.canCreateFolder(); + const canCreateCollection = this.canCreateCollection(); + + // If only collections can be created, be specific + if (!canCreateCipher && !canCreateFolder && canCreateCollection) { + return "newCollection"; + } + + return "new"; + } + + /** + * Returns true if only collections can be created (no other options). + * When this is true, the button should directly create a collection instead of showing a dropdown. + */ + protected isOnlyCollectionCreation(): boolean { + return !this.canCreateCipher() && !this.canCreateFolder() && this.canCreateCollection(); + } + + /** + * Handles the button click. If only collections can be created, directly emit the collection event. + * Otherwise, the menu trigger will handle opening the dropdown. + */ + protected handleButtonClick(): void { + if (this.isOnlyCollectionCreation()) { + this.collectionAdded.emit(); + } + } } diff --git a/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.html b/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.html index 6d1045e1a86..ac55c3cebd1 100644 --- a/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.html +++ b/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.html @@ -1,9 +1,9 @@ <bit-simple-dialog> - <i + <bit-icon bitDialogIcon - class="bwi bwi-exclamation-triangle tw-text-warning tw-text-3xl" - aria-hidden="true" - ></i> + name="bwi-exclamation-triangle" + class="tw-text-warning tw-text-3xl" + ></bit-icon> <span bitDialogTitle>{{ "leaveConfirmationDialogTitle" | i18n }}</span> @@ -25,9 +25,9 @@ {{ "goBack" | i18n }} </button> - <a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm"> + <a bitLink href="#" (click)="openLearnMore($event)" class="tw-self-center tw-text-sm"> {{ "howToManageMyVault" | i18n }} - <i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i> + <bit-icon name="bwi-external-link" class="tw-ml-1"></bit-icon> </a> </ng-container> </bit-simple-dialog> diff --git a/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.ts b/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.ts index af106376a79..44788a8234a 100644 --- a/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.ts +++ b/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.ts @@ -10,6 +10,7 @@ import { DialogService, ButtonModule, DialogModule, + IconModule, LinkModule, TypographyModule, CenterPositionStrategy, @@ -35,7 +36,7 @@ export type LeaveConfirmationDialogResultType = UnionOfValues<typeof LeaveConfir @Component({ templateUrl: "./leave-confirmation-dialog.component.html", changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ButtonModule, DialogModule, LinkModule, TypographyModule, JslibModule], + imports: [ButtonModule, DialogModule, IconModule, LinkModule, TypographyModule, JslibModule], }) export class LeaveConfirmationDialogComponent { private readonly params = inject<LeaveConfirmationDialogParams>(DIALOG_DATA); diff --git a/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.html b/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.html index 3cf626baaf7..5d1c3ba9aed 100644 --- a/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.html +++ b/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.html @@ -14,9 +14,9 @@ {{ "declineAndLeave" | i18n }} </button> - <a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm"> + <a bitLink href="#" (click)="openLearnMore($event)" class="tw-self-center tw-text-sm"> {{ "whyAmISeeingThis" | i18n }} - <i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i> + <bit-icon name="bwi-external-link" class="tw-ml-1"></bit-icon> </a> </ng-container> </bit-simple-dialog> diff --git a/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.ts b/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.ts index 619181f37fc..45f6305b5b3 100644 --- a/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.ts +++ b/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.ts @@ -10,6 +10,7 @@ import { DialogService, ButtonModule, DialogModule, + IconModule, LinkModule, TypographyModule, CenterPositionStrategy, @@ -35,7 +36,7 @@ export type TransferItemsDialogResultType = UnionOfValues<typeof TransferItemsDi @Component({ templateUrl: "./transfer-items-dialog.component.html", changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ButtonModule, DialogModule, LinkModule, TypographyModule, JslibModule], + imports: [ButtonModule, DialogModule, IconModule, LinkModule, TypographyModule, JslibModule], }) export class TransferItemsDialogComponent { private readonly params = inject<TransferItemsDialogParams>(DIALOG_DATA); diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index 391957d26d8..d06d6f3a95f 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -39,3 +39,14 @@ export * from "./abstractions/vault-items-transfer.service"; export * from "./services/default-vault-items-transfer.service"; export * from "./services/default-change-login-password.service"; export * from "./services/archive-cipher-utilities.service"; + +export * from "./models/vault-filter.type"; +export * from "./models/vault-filter.model"; +export * from "./models/routed-vault-filter.model"; +export * from "./models/routed-vault-filter-bridge.model"; +export * from "./models/vault-filter-section.type"; +export * from "./models/filter-function"; +export { VaultFilterService as VaultFilterServiceAbstraction } from "./abstractions/vault-filter.service"; +export * from "./services/vault-filter.service"; +export * from "./services/routed-vault-filter.service"; +export * from "./services/routed-vault-filter-bridge.service"; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts b/libs/vault/src/models/filter-function.spec.ts similarity index 89% rename from apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts rename to libs/vault/src/models/filter-function.spec.ts index 00c540f6029..de544a1a0d5 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts +++ b/libs/vault/src/models/filter-function.spec.ts @@ -1,6 +1,5 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Unassigned } from "@bitwarden/admin-console/common"; +import { Unassigned } from "@bitwarden/common/admin-console/models/collections"; +import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -117,6 +116,14 @@ describe("createFilter", () => { expect(result).toBe(true); }); + + it("should return true when filtering on empty-string folder id", () => { + const filterFunction = createFilterFunction({ folderId: "" }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); }); describe("given an organizational cipher (with organization and collections)", () => { @@ -127,8 +134,8 @@ describe("createFilter", () => { it("should return true when filter matches collection id", () => { const filterFunction = createFilterFunction({ - collectionId: "collectionId", - organizationId: "organizationId", + collectionId: "collectionId" as CollectionId, + organizationId: "organizationId" as OrganizationId, }); const result = filterFunction(cipher); @@ -138,8 +145,8 @@ describe("createFilter", () => { it("should return false when filter does not match collection id", () => { const filterFunction = createFilterFunction({ - collectionId: "nonMatchingCollectionId", - organizationId: "organizationId", + collectionId: "nonMatchingCollectionId" as CollectionId, + organizationId: "organizationId" as OrganizationId, }); const result = filterFunction(cipher); @@ -149,7 +156,7 @@ describe("createFilter", () => { it("should return false when filter does not match organization id", () => { const filterFunction = createFilterFunction({ - organizationId: "nonMatchingOrganizationId", + organizationId: "nonMatchingOrganizationId" as OrganizationId, }); const result = filterFunction(cipher); @@ -186,7 +193,9 @@ describe("createFilter", () => { }); it("should return true when filter matches organization id", () => { - const filterFunction = createFilterFunction({ organizationId: "organizationId" }); + const filterFunction = createFilterFunction({ + organizationId: "organizationId" as OrganizationId, + }); const result = filterFunction(cipher); diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts b/libs/vault/src/models/filter-function.ts similarity index 89% rename from apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts rename to libs/vault/src/models/filter-function.ts index f010c529110..bbd4127863b 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts +++ b/libs/vault/src/models/filter-function.ts @@ -1,4 +1,4 @@ -import { Unassigned } from "@bitwarden/admin-console/common"; +import { Unassigned } from "@bitwarden/common/admin-console/models/collections"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherViewLike, @@ -55,8 +55,11 @@ export function createFilterFunction( return false; } } + const isNoFolderFilter = filter.folderId === Unassigned || filter.folderId === ""; + const cipherHasFolder = cipher.folderId != null && cipher.folderId !== ""; + // No folder - if (filter.folderId === Unassigned && cipher.folderId != null) { + if (isNoFolderFilter && cipherHasFolder) { return false; } // Folder @@ -64,6 +67,7 @@ export function createFilterFunction( filter.folderId !== undefined && filter.folderId !== All && filter.folderId !== Unassigned && + filter.folderId !== "" && cipher.folderId !== filter.folderId ) { return false; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts b/libs/vault/src/models/routed-vault-filter-bridge.model.ts similarity index 95% rename from apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts rename to libs/vault/src/models/routed-vault-filter-bridge.model.ts index f9a80791030..32523684125 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts +++ b/libs/vault/src/models/routed-vault-filter-bridge.model.ts @@ -1,9 +1,9 @@ -import { Unassigned } from "@bitwarden/admin-console/common"; +import { Unassigned } from "@bitwarden/common/admin-console/models/collections"; import { CollectionId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; -import { RoutedVaultFilterBridgeService } from "../../services/routed-vault-filter-bridge.service"; +import { RoutedVaultFilterBridgeService } from "../services/routed-vault-filter-bridge.service"; import { All, @@ -87,7 +87,7 @@ export class RoutedVaultFilterBridge implements VaultFilter { return this.legacyFilter.selectedFolderNode; } set selectedFolderNode(value: TreeNode<FolderFilter>) { - const folderId = value != null && value.node.id === null ? Unassigned : value?.node.id; + const folderId = value?.node.id ? value.node.id : Unassigned; this.bridgeService.navigate({ ...this.routedFilter, folderId, diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model.ts b/libs/vault/src/models/routed-vault-filter.model.ts similarity index 91% rename from apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model.ts rename to libs/vault/src/models/routed-vault-filter.model.ts index 13f1ed7b1a3..ddc1689b61f 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model.ts +++ b/libs/vault/src/models/routed-vault-filter.model.ts @@ -1,4 +1,4 @@ -import { Unassigned } from "@bitwarden/admin-console/common"; +import { Unassigned } from "@bitwarden/common/admin-console/models/collections"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; /** diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts b/libs/vault/src/models/vault-filter-section.type.ts similarity index 100% rename from apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts rename to libs/vault/src/models/vault-filter-section.type.ts diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.model.spec.ts b/libs/vault/src/models/vault-filter.model.spec.ts similarity index 93% rename from apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.model.spec.ts rename to libs/vault/src/models/vault-filter.model.spec.ts index 51fe837468c..6f90f5487bb 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.model.spec.ts +++ b/libs/vault/src/models/vault-filter.model.spec.ts @@ -1,8 +1,6 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { CollectionId } from "@bitwarden/common/types/guid"; +import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -178,8 +176,8 @@ describe("VaultFilter", () => { it("should return true when filter matches collection id", () => { const filterFunction = createFilterFunction({ selectedCollectionNode: createCollectionFilterNode({ - id: "collectionId", - organizationId: "organizationId", + id: "collectionId" as CollectionId, + organizationId: "organizationId" as OrganizationId, }), }); @@ -191,8 +189,8 @@ describe("VaultFilter", () => { it("should return false when filter does not match collection id", () => { const filterFunction = createFilterFunction({ selectedCollectionNode: createCollectionFilterNode({ - id: "nonMatchingCollectionId", - organizationId: "organizationId", + id: "nonMatchingCollectionId" as CollectionId, + organizationId: "organizationId" as OrganizationId, }), }); @@ -204,7 +202,7 @@ describe("VaultFilter", () => { it("should return false when filter does not match organization id", () => { const filterFunction = createFilterFunction({ selectedOrganizationNode: createOrganizationFilterNode({ - id: "nonMatchingOrganizationId", + id: "nonMatchingOrganizationId" as OrganizationId, }), }); @@ -215,7 +213,9 @@ describe("VaultFilter", () => { it("should return false when filtering for my vault only", () => { const filterFunction = createFilterFunction({ - selectedOrganizationNode: createOrganizationFilterNode({ id: "MyVault" }), + selectedOrganizationNode: createOrganizationFilterNode({ + id: "MyVault" as OrganizationId, + }), }); const result = filterFunction(cipher); @@ -251,7 +251,9 @@ describe("VaultFilter", () => { it("should return true when filter matches organization id", () => { const filterFunction = createFilterFunction({ - selectedOrganizationNode: createOrganizationFilterNode({ id: "organizationId" }), + selectedOrganizationNode: createOrganizationFilterNode({ + id: "organizationId" as OrganizationId, + }), }); const result = filterFunction(cipher); @@ -276,7 +278,9 @@ describe("VaultFilter", () => { it("should return true when filtering for my vault only", () => { const cipher = createCipher({ organizationId: null }); const filterFunction = createFilterFunction({ - selectedOrganizationNode: createOrganizationFilterNode({ id: "MyVault" }), + selectedOrganizationNode: createOrganizationFilterNode({ + id: "MyVault" as OrganizationId, + }), }); const result = filterFunction(cipher); @@ -315,7 +319,7 @@ function createCollectionFilterNode( const collection = new CollectionView({ name: options.name ?? "Test Name", id: options.id ?? null, - organizationId: options.organizationId ?? "Org Id", + organizationId: options.organizationId ?? ("Org Id" as OrganizationId), }) as CollectionFilter; return new TreeNode<CollectionFilter>(collection, {} as TreeNode<CollectionFilter>); } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.model.ts b/libs/vault/src/models/vault-filter.model.ts similarity index 98% rename from apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.model.ts rename to libs/vault/src/models/vault-filter.model.ts index 4617102cebe..5452a9f5c38 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.model.ts +++ b/libs/vault/src/models/vault-filter.model.ts @@ -134,7 +134,7 @@ export class VaultFilter { if (this.selectedFolderNode) { // No folder if (this.folderId === null && cipherPassesFilter) { - cipherPassesFilter = cipher.folderId === null; + cipherPassesFilter = cipher.folderId == null || cipher.folderId === ""; } // Folder if (this.folderId !== null && cipherPassesFilter) { diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.type.ts b/libs/vault/src/models/vault-filter.type.ts similarity index 90% rename from apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.type.ts rename to libs/vault/src/models/vault-filter.type.ts index 7a92db6a381..14e01ba0735 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.type.ts +++ b/libs/vault/src/models/vault-filter.type.ts @@ -1,4 +1,4 @@ -import { CollectionAdminView } from "@bitwarden/admin-console/common"; +import { CollectionAdminView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherType } from "@bitwarden/common/vault/enums"; import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node"; diff --git a/libs/vault/src/services/archive-cipher-utilities.service.spec.ts b/libs/vault/src/services/archive-cipher-utilities.service.spec.ts index 76a4073325e..ea00f482987 100644 --- a/libs/vault/src/services/archive-cipher-utilities.service.spec.ts +++ b/libs/vault/src/services/archive-cipher-utilities.service.spec.ts @@ -5,6 +5,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -24,6 +25,7 @@ describe("ArchiveCipherUtilitiesService", () => { const mockCipher = new CipherView(); mockCipher.id = "cipher-id" as CipherId; const mockUserId = "user-id"; + const mockCipherData = { id: mockCipher.id } as CipherData; beforeEach(() => { cipherArchiveService = mock<CipherArchiveService>(); @@ -37,8 +39,8 @@ describe("ArchiveCipherUtilitiesService", () => { dialogService.openSimpleDialog.mockResolvedValue(true); passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true); - cipherArchiveService.archiveWithServer.mockResolvedValue(undefined); - cipherArchiveService.unarchiveWithServer.mockResolvedValue(undefined); + cipherArchiveService.archiveWithServer.mockResolvedValue(mockCipherData); + cipherArchiveService.unarchiveWithServer.mockResolvedValue(mockCipherData); i18nService.t.mockImplementation((key) => key); service = new ArchiveCipherUtilitiesService( @@ -118,5 +120,19 @@ describe("ArchiveCipherUtilitiesService", () => { message: "errorOccurred", }); }); + + it("calls password reprompt check when unarchiving", async () => { + await service.unarchiveCipher(mockCipher); + + expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(mockCipher); + }); + + it("returns early when password reprompt fails on unarchive", async () => { + passwordRepromptService.passwordRepromptCheck.mockResolvedValue(false); + + await service.unarchiveCipher(mockCipher); + + expect(cipherArchiveService.unarchiveWithServer).not.toHaveBeenCalled(); + }); }); }); diff --git a/libs/vault/src/services/archive-cipher-utilities.service.ts b/libs/vault/src/services/archive-cipher-utilities.service.ts index bbe7dba6715..b747961a701 100644 --- a/libs/vault/src/services/archive-cipher-utilities.service.ts +++ b/libs/vault/src/services/archive-cipher-utilities.service.ts @@ -25,16 +25,24 @@ export class ArchiveCipherUtilitiesService { private accountService: AccountService, ) {} - /** Archive a cipher, with confirmation dialog and password reprompt checks. */ - async archiveCipher(cipher: CipherView) { - const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); - if (!repromptPassed) { - return; + /** Archive a cipher, with confirmation dialog and password reprompt checks. + * + * @param cipher The cipher to archive + * @param skipReprompt Whether to skip the password reprompt check + * @returns The archived CipherData on success, or undefined on failure or cancellation + */ + async archiveCipher(cipher: CipherView, skipReprompt = false) { + if (!skipReprompt) { + const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); + if (!repromptPassed) { + return; + } } const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "archiveItem" }, - content: { key: "archiveItemConfirmDesc" }, + content: { key: "archiveItemDialogContent" }, + acceptButtonText: { key: "archiveVerb" }, type: "info", }); @@ -43,38 +51,54 @@ export class ArchiveCipherUtilitiesService { } const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - await this.cipherArchiveService - .archiveWithServer(cipher.id as CipherId, userId) - .then(() => { - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("itemWasSentToArchive"), - }); - }) - .catch(() => { - this.toastService.showToast({ - variant: "error", - message: this.i18nService.t("errorOccurred"), - }); + try { + const cipherResponse = await this.cipherArchiveService.archiveWithServer( + cipher.id as CipherId, + userId, + ); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemWasSentToArchive"), }); + return cipherResponse; + } catch { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + return; + } } - /** Unarchives a cipher */ - async unarchiveCipher(cipher: CipherView) { + /** Unarchives a cipher + * @param cipher The cipher to unarchive + * @returns The unarchived cipher on success, or undefined on failure + */ + async unarchiveCipher(cipher: CipherView, skipReprompt = false) { + if (!skipReprompt) { + const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); + if (!repromptPassed) { + return; + } + } + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - await this.cipherArchiveService - .unarchiveWithServer(cipher.id as CipherId, userId) - .then(() => { - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("itemWasUnarchived"), - }); - }) - .catch(() => { - this.toastService.showToast({ - variant: "error", - message: this.i18nService.t("errorOccurred"), - }); + try { + const cipherResponse = await this.cipherArchiveService.unarchiveWithServer( + cipher.id as CipherId, + userId, + ); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemWasUnarchived"), }); + return cipherResponse; + } catch { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + return; + } } } diff --git a/libs/vault/src/services/default-vault-items-transfer.service.spec.ts b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts index c0afa950c41..b7d24681e64 100644 --- a/libs/vault/src/services/default-vault-items-transfer.service.spec.ts +++ b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts @@ -2,11 +2,12 @@ import { mock, MockProxy } from "jest-mock-extended"; import { firstValueFrom, of, Subject } from "rxjs"; // eslint-disable-next-line no-restricted-imports -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionService, OrganizationUserApiService } from "@bitwarden/admin-console/common"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { EventType } from "@bitwarden/common/enums"; @@ -15,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"; @@ -41,6 +43,8 @@ describe("DefaultVaultItemsTransferService", () => { let mockToastService: MockProxy<ToastService>; let mockEventCollectionService: MockProxy<EventCollectionService>; let mockConfigService: MockProxy<ConfigService>; + let mockOrganizationUserApiService: MockProxy<OrganizationUserApiService>; + let mockSyncService: MockProxy<SyncService>; const userId = "user-id" as UserId; const organizationId = "org-id" as OrganizationId; @@ -76,6 +80,8 @@ describe("DefaultVaultItemsTransferService", () => { mockToastService = mock<ToastService>(); mockEventCollectionService = mock<EventCollectionService>(); mockConfigService = mock<ConfigService>(); + mockOrganizationUserApiService = mock<OrganizationUserApiService>(); + mockSyncService = mock<SyncService>(); mockI18nService.t.mockImplementation((key) => key); transferInProgressValues = []; @@ -91,6 +97,8 @@ describe("DefaultVaultItemsTransferService", () => { mockToastService, mockEventCollectionService, mockConfigService, + mockOrganizationUserApiService, + mockSyncService, ); }); @@ -553,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 () => { @@ -634,6 +644,12 @@ describe("DefaultVaultItemsTransferService", () => { await service.enforceOrganizationDataOwnership(userId); + expect(mockOrganizationUserApiService.revokeSelf).toHaveBeenCalledWith(organizationId); + expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "success", + message: "leftOrganization", + }); expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); }); @@ -922,4 +938,142 @@ describe("DefaultVaultItemsTransferService", () => { expect(transferInProgressValues).toEqual([false, true, false]); }); }); + + describe("enforcementInFlight", () => { + const policy = { + organizationId: organizationId, + revisionDate: new Date("2024-01-01"), + } as Policy; + const organization = { + id: organizationId, + name: "Test Org", + } as Organization; + const personalCiphers = [{ id: "cipher-1" } as CipherView]; + const defaultCollection = { + id: collectionId, + organizationId: organizationId, + isDefaultCollection: true, + } as CollectionView; + + beforeEach(() => { + mockConfigService.getFeatureFlag.mockResolvedValue(true); + mockPolicyService.policiesByType$.mockReturnValue(of([policy])); + mockOrganizationService.organizations$.mockReturnValue(of([organization])); + mockCipherService.cipherViews$.mockReturnValue(of(personalCiphers)); + mockCollectionService.defaultUserCollection$.mockReturnValue(of(defaultCollection)); + mockSyncService.fullSync.mockResolvedValue(true); + mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined); + }); + + it("prevents re-entry when enforcement is already in flight", async () => { + // Create a dialog that resolves after a delay + const delayedSubject = new Subject<any>(); + const delayedDialog = { + closed: delayedSubject.asObservable(), + close: jest.fn(), + } as unknown as DialogRef<any>; + + mockDialogService.open.mockReturnValue(delayedDialog); + + // Start first call (won't complete immediately) + const firstCall = service.enforceOrganizationDataOwnership(userId); + + // Flush microtasks to allow first call to set enforcementInFlight + await Promise.resolve(); + + // Second call should return immediately without opening dialog + await service.enforceOrganizationDataOwnership(userId); + + // Verify re-entry was prevented - only the first call should proceed + expect(mockDialogService.open).toHaveBeenCalledTimes(1); + expect(mockPolicyService.policiesByType$).toHaveBeenCalledTimes(1); + + // Clean up - resolve the first call's dialog + delayedSubject.next(TransferItemsDialogResult.Declined); + delayedSubject.complete(); + + // Mock the leave dialog + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(LeaveConfirmationDialogResult.Confirmed), + ); + + await firstCall; + }); + + it("allows subsequent calls after user declines and leaves", async () => { + // First call: user declines and confirms leaving + mockDialogService.open + .mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined)) + .mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed)); + + await service.enforceOrganizationDataOwnership(userId); + + // Reset mocks for second call + mockDialogService.open.mockClear(); + + // Second call: user accepts transfer + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(TransferItemsDialogResult.Accepted), + ); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.enforceOrganizationDataOwnership(userId); + + // Second call should proceed (dialog opened again) + expect(mockDialogService.open).toHaveBeenCalledTimes(1); + }); + + it("allows subsequent calls after successful transfer", async () => { + // First call: user accepts transfer + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(TransferItemsDialogResult.Accepted), + ); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.enforceOrganizationDataOwnership(userId); + + // Reset mocks for second call + mockDialogService.open.mockClear(); + mockCipherService.shareManyWithServer.mockClear(); + + // Second call should be allowed (though no migration needed after first transfer) + // Set up scenario where migration is needed again + mockCipherService.cipherViews$.mockReturnValue(of([{ id: "cipher-2" } as CipherView])); + + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(TransferItemsDialogResult.Accepted), + ); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.enforceOrganizationDataOwnership(userId); + + // Second call should proceed (dialog opened again) + expect(mockDialogService.open).toHaveBeenCalledTimes(1); + }); + + it("allows subsequent calls after transfer fails with error", async () => { + // First call: transfer fails + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(TransferItemsDialogResult.Accepted), + ); + mockCipherService.shareManyWithServer.mockRejectedValue(new Error("Transfer failed")); + + await service.enforceOrganizationDataOwnership(userId); + + // Reset mocks for second call + mockDialogService.open.mockClear(); + mockCipherService.shareManyWithServer.mockClear(); + + // Second call: user accepts transfer successfully + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(TransferItemsDialogResult.Accepted), + ); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.enforceOrganizationDataOwnership(userId); + + // Second call should proceed (dialog opened again) + expect(mockDialogService.open).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/libs/vault/src/services/default-vault-items-transfer.service.ts b/libs/vault/src/services/default-vault-items-transfer.service.ts index c184b2c902e..8b1c24c8ca2 100644 --- a/libs/vault/src/services/default-vault-items-transfer.service.ts +++ b/libs/vault/src/services/default-vault-items-transfer.service.ts @@ -10,7 +10,7 @@ import { } from "rxjs"; // eslint-disable-next-line no-restricted-imports -import { CollectionService } from "@bitwarden/admin-console/common"; +import { CollectionService, OrganizationUserApiService } from "@bitwarden/admin-console/common"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -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"; @@ -53,12 +54,20 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi private toastService: ToastService, private eventCollectionService: EventCollectionService, private configService: ConfigService, + private organizationUserApiService: OrganizationUserApiService, + private syncService: SyncService, ) {} private _transferInProgressSubject = new BehaviorSubject(false); transferInProgress$ = this._transferInProgressSubject.asObservable(); + /** + * Only a single enforcement should be allowed to run at a time to prevent multiple dialogs + * or multiple simultaneous transfers. + */ + private enforcementInFlight: boolean = false; + private enforcingOrganization$(userId: UserId): Observable<Organization | undefined> { return this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId).pipe( map( @@ -139,7 +148,7 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi FeatureFlag.MigrateMyVaultToMyItems, ); - if (!featureEnabled) { + if (!featureEnabled || this.enforcementInFlight) { return; } @@ -157,12 +166,18 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi return; } + this.enforcementInFlight = true; + const userAcceptedTransfer = await this.promptUserForTransfer( migrationInfo.enforcingOrganization.name, ); if (!userAcceptedTransfer) { - // TODO: Revoke user from organization if they decline migration and show toast PM-29465 + await this.organizationUserApiService.revokeSelf(migrationInfo.enforcingOrganization.id); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("leftOrganization"), + }); await this.eventCollectionService.collect( EventType.Organization_ItemOrganization_Declined, @@ -170,6 +185,9 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi undefined, migrationInfo.enforcingOrganization.id, ); + // Sync to reflect organization removal + await this.syncService.fullSync(true); + this.enforcementInFlight = false; return; } @@ -199,6 +217,8 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi variant: "error", message: this.i18nService.t("errorOccurred"), }); + } finally { + this.enforcementInFlight = false; } } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service.ts b/libs/vault/src/services/routed-vault-filter-bridge.service.ts similarity index 92% rename from apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service.ts rename to libs/vault/src/services/routed-vault-filter-bridge.service.ts index 936dfb0e675..25c75f464f0 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service.ts +++ b/libs/vault/src/services/routed-vault-filter-bridge.service.ts @@ -4,22 +4,21 @@ import { Injectable } from "@angular/core"; import { Router } from "@angular/router"; import { combineLatest, map, Observable } from "rxjs"; -import { Unassigned } from "@bitwarden/admin-console/common"; +import { Unassigned } from "@bitwarden/common/admin-console/models/collections"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; - -import { RoutedVaultFilterBridge } from "../shared/models/routed-vault-filter-bridge.model"; -import { RoutedVaultFilterModel, All } from "../shared/models/routed-vault-filter.model"; -import { VaultFilter } from "../shared/models/vault-filter.model"; import { + VaultFilterServiceAbstraction as VaultFilterService, + RoutedVaultFilterService, + RoutedVaultFilterBridge, + RoutedVaultFilterModel, + All, + VaultFilter, CipherTypeFilter, CollectionFilter, FolderFilter, OrganizationFilter, -} from "../shared/models/vault-filter.type"; - -import { VaultFilterService } from "./abstractions/vault-filter.service"; -import { RoutedVaultFilterService } from "./routed-vault-filter.service"; +} from "@bitwarden/vault"; /** * This file is part of a layer that is used to temporary bridge between URL filtering and the old state-in-code method. @@ -146,7 +145,7 @@ function createLegacyFilterForEndUser( ); } - if (filter.folderId !== undefined && filter.folderId === Unassigned) { + if (filter.folderId !== undefined && (filter.folderId === Unassigned || filter.folderId === "")) { legacyFilter.selectedFolderNode = ServiceUtils.getTreeNodeObject(folderTree, null); } else if (filter.folderId !== undefined && filter.folderId !== Unassigned) { legacyFilter.selectedFolderNode = ServiceUtils.getTreeNodeObject(folderTree, filter.folderId); diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts b/libs/vault/src/services/routed-vault-filter.service.ts similarity index 86% rename from apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts rename to libs/vault/src/services/routed-vault-filter.service.ts index bc9da5e1692..9005d507da7 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts +++ b/libs/vault/src/services/routed-vault-filter.service.ts @@ -1,13 +1,19 @@ -import { Injectable, OnDestroy } from "@angular/core"; +import { Injectable, OnDestroy, inject } from "@angular/core"; import { ActivatedRoute, NavigationExtras } from "@angular/router"; import { combineLatest, map, Observable, Subject, takeUntil } from "rxjs"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { SafeInjectionToken } from "@bitwarden/ui-common"; import { isRoutedVaultFilterItemType, RoutedVaultFilterModel, -} from "../shared/models/routed-vault-filter.model"; +} from "../models/routed-vault-filter.model"; + +/** + * Injection token for the base route path used in vault filter navigation. + */ +export const VAULT_FILTER_BASE_ROUTE = new SafeInjectionToken<string>("VaultFilterBaseRoute"); /** * This service is an abstraction layer on top of ActivatedRoute that @@ -19,6 +25,7 @@ import { @Injectable() export class RoutedVaultFilterService implements OnDestroy { private onDestroy = new Subject<void>(); + private baseRoute: string = inject(VAULT_FILTER_BASE_ROUTE, { optional: true }) ?? ""; /** * Filter values extracted from the URL. @@ -64,7 +71,7 @@ export class RoutedVaultFilterService implements OnDestroy { * @returns route that can be used with Router or RouterLink */ createRoute(filter: RoutedVaultFilterModel): [commands: any[], extras?: NavigationExtras] { - const commands: string[] = []; + const commands: string[] = this.baseRoute ? [this.baseRoute] : []; const extras: NavigationExtras = { queryParams: { collectionId: filter.collectionId ?? null, diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts b/libs/vault/src/services/vault-filter.service.spec.ts similarity index 93% rename from apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts rename to libs/vault/src/services/vault-filter.service.spec.ts index c05459250c0..537e9c9f542 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts +++ b/libs/vault/src/services/vault-filter.service.spec.ts @@ -7,16 +7,17 @@ import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider import { mock, MockProxy } from "jest-mock-extended"; import { firstValueFrom, of, ReplaySubject } from "rxjs"; -import { - CollectionService, - CollectionType, - CollectionTypes, - CollectionView, -} from "@bitwarden/admin-console/common"; +// eslint-disable-next-line no-restricted-imports +import { CollectionService } from "@bitwarden/admin-console/common"; import * as vaultFilterSvc from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { + CollectionView, + CollectionType, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -31,7 +32,7 @@ import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/ import { VaultFilterService } from "./vault-filter.service"; jest.mock("@bitwarden/angular/vault/vault-filter/services/vault-filter.service", () => ({ - sortDefaultCollections: jest.fn(() => []), + sortDefaultCollections: jest.fn((): CollectionView[] => []), })); describe("vault filter service", () => { @@ -96,7 +97,6 @@ describe("vault filter service", () => { stateProvider, collectionService, accountService, - configService, ); collapsedGroupingsState = stateProvider.singleUser.getFake(mockUserId, COLLAPSED_GROUPINGS); organizations.next([]); @@ -127,7 +127,10 @@ describe("vault filter service", () => { describe("organizations", () => { beforeEach(() => { - const storedOrgs = [createOrganization("1", "org1"), createOrganization("2", "org2")]; + const storedOrgs = [ + createOrganization("1" as OrganizationId, "org1"), + createOrganization("2" as OrganizationId, "org2"), + ]; organizations.next(storedOrgs); organizationDataOwnershipPolicy.next(false); singleOrgPolicy.next(false); @@ -175,7 +178,9 @@ describe("vault filter service", () => { describe("filtered folders with organization", () => { beforeEach(() => { // Org must be updated before folderService else the subscription uses the null org default value - vaultFilterService.setOrganizationFilter(createOrganization("org test id", "Test Org")); + vaultFilterService.setOrganizationFilter( + createOrganization("org test id" as OrganizationId, "Test Org"), + ); }); it("returns folders filtered by current organization", async () => { const storedCiphers = [ @@ -190,8 +195,8 @@ describe("vault filter service", () => { ]; folderViews.next(storedFolders); - await expect(firstValueFrom(vaultFilterService.filteredFolders$)).resolves.toEqual([ - createFolderView("folder test id", "test"), + await expect(firstValueFrom(vaultFilterService.filteredFolders$)).resolves.toMatchObject([ + { id: "folder test id", name: "test" }, ]); }); @@ -225,7 +230,9 @@ describe("vault filter service", () => { describe("collections", () => { describe("filtered collections", () => { it("returns collections filtered by current organization", async () => { - vaultFilterService.setOrganizationFilter(createOrganization("org test id", "Test Org")); + vaultFilterService.setOrganizationFilter( + createOrganization("org test id" as OrganizationId, "Test Org"), + ); const storedCollections = [ createCollectionView("1", "collection 1", "org test id"), @@ -316,8 +323,8 @@ describe("vault filter service", () => { it("calls sortDefaultCollections with the correct args", async () => { const storedOrgs = [ - createOrganization("id-defaultOrg1", "org1"), - createOrganization("id-defaultOrg2", "org2"), + createOrganization("id-defaultOrg1" as OrganizationId, "org1"), + createOrganization("id-defaultOrg2" as OrganizationId, "org2"), ]; organizations.next(storedOrgs); @@ -353,7 +360,7 @@ describe("vault filter service", () => { }); }); - function createOrganization(id: string, name: string) { + function createOrganization(id: OrganizationId, name: string) { const org = new Organization(); org.id = id; org.name = name; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts b/libs/vault/src/services/vault-filter.service.ts similarity index 94% rename from apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts rename to libs/vault/src/services/vault-filter.service.ts index aad42506777..5dbab72e1d3 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts +++ b/libs/vault/src/services/vault-filter.service.ts @@ -12,18 +12,21 @@ import { switchMap, } from "rxjs"; -import { - CollectionService, - CollectionTypes, - CollectionView, -} from "@bitwarden/admin-console/common"; +// eslint-disable-next-line no-restricted-imports +import { CollectionService } from "@bitwarden/admin-console/common"; import { sortDefaultCollections } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { + CollectionView, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { cloneCollection } from "@bitwarden/common/admin-console/utils/collection-utils"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { 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"; @@ -36,16 +39,13 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/collapsed-groupings.state"; import { CipherListView } from "@bitwarden/sdk-internal"; -import { cloneCollection } from "@bitwarden/web-vault/app/admin-console/organizations/collections"; - import { + VaultFilterServiceAbstraction, CipherTypeFilter, CollectionFilter, FolderFilter, OrganizationFilter, -} from "../shared/models/vault-filter.type"; - -import { VaultFilterService as VaultFilterServiceAbstraction } from "./abstractions/vault-filter.service"; +} from "@bitwarden/vault"; const NestingDelimiter = "/"; @@ -185,7 +185,14 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { const orgNodes: TreeNode<OrganizationFilter>[] = []; 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<OrganizationFilter>(orgCopy, headNode, orgCopy.name); orgNodes.push(node); }); @@ -283,9 +290,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { // Otherwise, show only folders that have ciphers from the selected org and the "no folder" folder const orgCiphers = ciphers.filter((c) => c.organizationId == org?.id); - return storedFolders.filter( - (f) => orgCiphers.some((oc) => oc.folderId == f.id) || f.id == null, - ); + return storedFolders.filter((f) => orgCiphers.some((oc) => oc.folderId == f.id) || !f.id); } protected buildFolderTree(folders?: FolderView[]): TreeNode<FolderFilter> { diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs new file mode 100644 index 00000000000..5af87152fcc --- /dev/null +++ b/lint-staged.config.mjs @@ -0,0 +1,20 @@ +export default { + "*": "prettier --cache --ignore-unknown --write", + "*.ts": "eslint --cache --cache-strategy content --fix", + "apps/desktop/desktop_native/**/*.rs": (stagedFiles) => { + const relativeFiles = stagedFiles.map((f) => + f.replace(/^.*apps\/desktop\/desktop_native\//, ""), + ); + return [ + `sh -c 'cd apps/desktop/desktop_native && cargo +nightly fmt -- ${relativeFiles.join(" ")}'`, + `sh -c 'cd apps/desktop/desktop_native && cargo clippy --all-features --all-targets --tests -- -D warnings'`, + ]; + }, + "apps/desktop/desktop_native/**/Cargo.toml": () => { + return [ + `sh -c 'cd apps/desktop/desktop_native && cargo sort --workspace --check'`, + `sh -c 'cd apps/desktop/desktop_native && cargo +nightly udeps --workspace --all-features --all-targets'`, + `sh -c 'cd apps/desktop/desktop_native && cargo deny --log-level error --all-features check all'`, + ]; + }, +}; diff --git a/package-lock.json b/package-lock.json index ca69b52b890..f558d18e207 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,31 +14,31 @@ "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.450", - "@bitwarden/sdk-internal": "0.2.0-main.450", + "@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.522", + "@bitwarden/sdk-internal": "0.2.0-main.522", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", - "@koa/router": "14.0.0", + "@koa/router": "15.2.0", "@microsoft/signalr": "8.0.7", "@microsoft/signalr-protocol-msgpack": "8.0.7", "@ng-select/ng-select": "20.7.0", "big-integer": "1.6.52", "braintree-web-drop-in": "1.46.0", "buffer": "6.0.3", - "bufferutil": "4.0.9", + "bufferutil": "4.1.0", "chalk": "4.1.2", "commander": "14.0.0", - "core-js": "3.47.0", + "core-js": "3.48.0", "form-data": "4.0.4", "https-proxy-agent": "7.0.6", "inquirer": "8.2.6", @@ -52,10 +52,10 @@ "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", + "open": "8.4.2", "papaparse": "5.5.3", "proper-lockfile": "4.1.2", "qrcode-parser": "2.1.3", @@ -63,7 +63,7 @@ "rxjs": "7.8.1", "semver": "7.7.3", "tabbable": "6.3.0", - "tldts": "7.0.19", + "tldts": "7.0.22", "ts-node": "10.9.2", "utf-8-validate": "6.0.5", "vite-tsconfig-paths": "5.1.4", @@ -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", @@ -104,13 +104,12 @@ "@types/jsdom": "21.1.7", "@types/koa": "3.0.1", "@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 +126,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", @@ -135,7 +134,7 @@ "electron-builder": "26.0.12", "electron-log": "5.4.3", "electron-reload": "2.0.0-alpha.1", - "electron-store": "8.2.0", + "electron-store": "11.0.2", "electron-updater": "6.6.4", "eslint": "9.26.0", "eslint-config-prettier": "10.1.2", @@ -160,7 +159,7 @@ "path-browserify": "1.0.1", "postcss": "8.5.6", "postcss-loader": "8.2.0", - "prettier": "3.7.3", + "prettier": "3.8.1", "prettier-plugin-tailwindcss": "0.7.1", "process": "0.11.10", "remark-gfm": "4.0.1", @@ -180,7 +179,7 @@ "url": "0.11.4", "util": "0.12.5", "wait-on": "9.0.3", - "webpack": "5.103.0", + "webpack": "5.104.1", "webpack-cli": "6.0.1", "webpack-dev-server": "5.2.2", "webpack-node-externals": "3.0.0" @@ -192,20 +191,20 @@ }, "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", - "@koa/router": "14.0.0", + "@koa/router": "15.2.0", "big-integer": "1.6.52", "browser-hrtime": "1.1.8", "chalk": "4.1.2", "commander": "14.0.0", - "core-js": "3.47.0", + "core-js": "3.48.0", "form-data": "4.0.4", "https-proxy-agent": "7.0.6", "inquirer": "8.2.6", @@ -217,68 +216,23 @@ "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", + "open": "8.4.2", "papaparse": "5.5.3", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", "semver": "7.7.3", - "tldts": "7.0.19", + "tldts": "7.0.22", "zxcvbn": "4.4.2" }, "bin": { "bw": "build/bw.js" } }, - "apps/cli/node_modules/define-lazy-prop": { - "version": "2.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "apps/cli/node_modules/is-docker": { - "version": "2.2.1", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "apps/cli/node_modules/is-wsl": { - "version": "2.2.0", - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "apps/cli/node_modules/open": { - "version": "8.4.2", - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2025.12.1", + "version": "2026.2.0", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -491,7 +445,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2025.12.2" + "version": "2026.2.0" }, "libs/admin-console": { "name": "@bitwarden/admin-console", @@ -2208,9 +2162,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" @@ -2219,7 +2173,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": { @@ -2632,9 +2586,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" @@ -2643,14 +2597,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" @@ -2660,9 +2614,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": { @@ -2683,7 +2637,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": { @@ -2869,9 +2823,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" @@ -2880,7 +2834,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" }, @@ -2894,9 +2848,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" @@ -2905,16 +2859,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" @@ -2923,9 +2877,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": { @@ -2934,9 +2888,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" @@ -2945,16 +2899,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" @@ -2963,9 +2917,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" } }, @@ -4987,9 +4941,9 @@ "link": true }, "node_modules/@bitwarden/commercial-sdk-internal": { - "version": "0.2.0-main.450", - "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.450.tgz", - "integrity": "sha512-WCihR6ykpIfaqJBHl4Wou4xDB8mp+5UPi94eEKYUdkx/9/19YyX33SX9H56zEriOuOMCD8l2fymhzAFjAAB++g==", + "version": "0.2.0-main.522", + "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.522.tgz", + "integrity": "sha512-2wAbg30cGlDhSj14LaK2/ISuT91XPVeNgL/PU+eoxLhAehGKjAXdvZN3PSwFaAuaMbEFzlESvqC1pzzO4p/1zw==", "license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT", "dependencies": { "type-fest": "^4.41.0" @@ -5092,9 +5046,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.450", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.450.tgz", - "integrity": "sha512-XRhrBN0uoo66ONx7dYo9glhe9N451+VhwtC/oh3wo3j3qYxbPwf9yE98szlQ52u3iUExLisiYJY7sQNzhZrbZw==", + "version": "0.2.0-main.522", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.522.tgz", + "integrity": "sha512-E+YqqX/FvGF0vGx6sNJfYaMj88C+rVo51fQPMSHoOePdryFcKQSJX706Glv86OMLMXE7Ln5Lua8LJRftlF/EFQ==", "license": "GPL-3.0", "dependencies": { "type-fest": "^4.41.0" @@ -8757,18 +8711,46 @@ } }, "node_modules/@koa/router": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@koa/router/-/router-14.0.0.tgz", - "integrity": "sha512-LBSu5K0qAaaQcXX/0WIB9PGDevyCxxpnc1uq13vV/CgObaVxuis5hKl3Eboq/8gcb6ebnkAStW9NB/Em2eYyFA==", + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-15.2.0.tgz", + "integrity": "sha512-7YUhq4W83cybfNa4E7JqJpWzoCTSvbnFltkvRaUaUX1ybFzlUoLNY1SqT8XmIAO6nGbFrev+FvJHw4mL+4WhuQ==", "license": "MIT", "dependencies": { - "debug": "^4.4.1", - "http-errors": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", "koa-compose": "^4.1.0", - "path-to-regexp": "^8.2.0" + "path-to-regexp": "^8.3.0" }, "engines": { "node": ">= 20" + }, + "peerDependencies": { + "koa": "^2.0.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "koa": { + "optional": false + } + } + }, + "node_modules/@koa/router/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/@leichtgewicht/ip-codec": { @@ -15746,16 +15728,6 @@ "@types/koa": "*" } }, - "node_modules/@types/koa__router": { - "version": "12.0.4", - "resolved": "https://registry.npmjs.org/@types/koa__router/-/koa__router-12.0.4.tgz", - "integrity": "sha512-Y7YBbSmfXZpa/m5UGGzb7XadJIRBRnwNY9cdAojZGp65Cpe5MAP3mOZE7e3bImt8dfKS4UFcR16SLH8L/z7PBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/koa": "*" - } - }, "node_modules/@types/koa-bodyparser": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/@types/koa-bodyparser/-/koa-bodyparser-4.3.7.tgz", @@ -15777,9 +15749,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": { @@ -15842,62 +15814,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": { @@ -18376,13 +18309,14 @@ } }, "node_modules/atomically": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", - "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.0.tgz", + "integrity": "sha512-+gDffFXRW6sl/HCwbta7zK4uNqbPjv4YJEAdz7Vu+FLQHe77eZ4bvbJGi4hE0QPeJlMYMA3piXEr1UL3dAwx7Q==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10.12.0" + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" } }, "node_modules/autoprefixer": { @@ -18950,63 +18884,6 @@ "node": ">=12.0.0" } }, - "node_modules/better-opn/node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/better-opn/node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/better-opn/node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/better-opn/node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -19330,9 +19207,9 @@ "license": "MIT" }, "node_modules/bufferutil": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", - "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -19414,6 +19291,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, "license": "MIT", "dependencies": { "run-applescript": "^7.0.0" @@ -20605,19 +20483,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", @@ -20630,6 +20507,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", @@ -20647,46 +20534,40 @@ } }, "node_modules/conf": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/conf/-/conf-10.2.0.tgz", - "integrity": "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==", + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/conf/-/conf-15.0.2.tgz", + "integrity": "sha512-JBSrutapCafTrddF9dH3lc7+T2tBycGF4uPkI4Js+g4vLLEhG6RZcFi3aJd5zntdf5tQxAejJt8dihkoQ/eSJw==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^8.6.3", - "ajv-formats": "^2.1.1", - "atomically": "^1.7.0", - "debounce-fn": "^4.0.0", - "dot-prop": "^6.0.1", - "env-paths": "^2.2.1", - "json-schema-typed": "^7.0.3", - "onetime": "^5.1.2", - "pkg-up": "^3.1.0", - "semver": "^7.3.5" + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "atomically": "^2.0.3", + "debounce-fn": "^6.0.0", + "dot-prop": "^10.0.0", + "env-paths": "^3.0.0", + "json-schema-typed": "^8.0.1", + "semver": "^7.7.2", + "uint8array-extras": "^1.5.0" }, "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/conf/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "node_modules/conf/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "dev": true, "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/config-file-ts": { @@ -20925,9 +20806,9 @@ } }, "node_modules/core-js": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", - "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", "hasInstallScript": true, "license": "MIT", "funding": { @@ -21509,25 +21390,25 @@ } }, "node_modules/debounce-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", - "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", + "integrity": "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==", "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^3.0.0" + "mimic-function": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -21675,6 +21556,7 @@ "version": "5.4.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", + "dev": true, "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", @@ -21691,6 +21573,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -21759,6 +21642,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -22313,16 +22197,32 @@ } }, "node_modules/dot-prop": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", - "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.1.0.tgz", + "integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==", "dev": true, "license": "MIT", "dependencies": { - "is-obj": "^2.0.0" + "type-fest": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop/node_modules/type-fest": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.2.0.tgz", + "integrity": "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -22607,14 +22507,33 @@ } }, "node_modules/electron-store": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/electron-store/-/electron-store-8.2.0.tgz", - "integrity": "sha512-ukLL5Bevdil6oieAOXz3CMy+OgaItMiVBg701MNlG6W5RaC0AHN7rvlqTCmeb6O7jP0Qa1KKYTE0xV0xbhF4Hw==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/electron-store/-/electron-store-11.0.2.tgz", + "integrity": "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ==", "dev": true, "license": "MIT", "dependencies": { - "conf": "^10.2.0", - "type-fest": "^2.17.0" + "conf": "^15.0.2", + "type-fest": "^5.0.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/electron-store/node_modules/type-fest": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.2.0.tgz", + "integrity": "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -26840,6 +26759,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, "license": "MIT", "bin": { "is-docker": "cli.js" @@ -26928,22 +26848,11 @@ "node": ">=0.10.0" } }, - "node_modules/is-in-ssh": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", - "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, "license": "MIT", "dependencies": { "is-docker": "^3.0.0" @@ -27040,16 +26949,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -27283,6 +27182,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, "license": "MIT", "dependencies": { "is-inside-container": "^1.0.0" @@ -29463,9 +29363,9 @@ "license": "MIT" }, "node_modules/json-schema-typed": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", - "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "dev": true, "license": "BSD-2-Clause" }, @@ -32058,16 +31958,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", - "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -32452,9 +32342,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, @@ -32815,9 +32705,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" @@ -33980,16 +33870,6 @@ } } }, - "node_modules/nx/node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/nx/node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -34013,35 +33893,6 @@ "node": ">= 4" } }, - "node_modules/nx/node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/nx/node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/nx/node_modules/jsonc-parser": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", @@ -34049,24 +33900,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nx/node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/nx/node_modules/ora": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/ora/-/ora-5.3.0.tgz", @@ -34641,41 +34474,58 @@ } }, "node_modules/open": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", - "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", "license": "MIT", "dependencies": { - "default-browser": "^5.4.0", - "define-lazy-prop": "^3.0.0", - "is-in-ssh": "^1.0.0", - "is-inside-container": "^1.0.0", - "powershell-utils": "^0.1.0", - "wsl-utils": "^0.3.0" + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" }, "engines": { - "node": ">=20" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/open/node_modules/wsl-utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.0.tgz", - "integrity": "sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==", + "node_modules/open/node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0", - "powershell-utils": "^0.1.0" + "engines": { + "node": ">=8" + } + }, + "node_modules/open/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" }, "engines": { - "node": ">=20" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/opencollective-postinstall": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", @@ -34728,9 +34578,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 @@ -35545,12 +35395,13 @@ } }, "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "license": "MIT", - "engines": { - "node": ">=16" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/path-type": { @@ -35785,85 +35636,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-up/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-up/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/playwright": { "version": "1.53.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.1.tgz", @@ -36743,18 +36515,6 @@ "node": "^12.20.0 || >=14" } }, - "node_modules/powershell-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", - "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -36793,9 +36553,9 @@ } }, "node_modules/prettier": { - "version": "3.7.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.3.tgz", - "integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -38156,6 +37916,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -40294,6 +40055,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "dev": true, + "license": "MIT" + }, "node_modules/style-loader": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", @@ -40607,6 +40385,19 @@ "npm": ">= 8" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwindcss": { "version": "3.4.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", @@ -40970,9 +40761,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -41224,21 +41015,21 @@ } }, "node_modules/tldts": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", - "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "version": "7.0.22", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.22.tgz", + "integrity": "sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw==", "license": "MIT", "dependencies": { - "tldts-core": "^7.0.19" + "tldts-core": "^7.0.22" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", - "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", "license": "MIT" }, "node_modules/tmp": { @@ -42407,6 +42198,19 @@ "node": ">=0.8.0" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -43381,9 +43185,9 @@ } }, "node_modules/webpack": { - "version": "5.103.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.103.0.tgz", - "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", + "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", "dependencies": { @@ -43395,10 +43199,10 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.17.4", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", @@ -43409,7 +43213,7 @@ "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.11", + "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, @@ -44201,6 +44005,13 @@ "dev": true, "license": "MIT" }, + "node_modules/webpack/node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -44314,6 +44125,13 @@ "node": ">=18" } }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 1cfddb16c42..bc1553c4622 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", @@ -71,13 +71,12 @@ "@types/jsdom": "21.1.7", "@types/koa": "3.0.1", "@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 +93,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", @@ -102,7 +101,7 @@ "electron-builder": "26.0.12", "electron-log": "5.4.3", "electron-reload": "2.0.0-alpha.1", - "electron-store": "8.2.0", + "electron-store": "11.0.2", "electron-updater": "6.6.4", "eslint": "9.26.0", "eslint-config-prettier": "10.1.2", @@ -127,7 +126,7 @@ "path-browserify": "1.0.1", "postcss": "8.5.6", "postcss-loader": "8.2.0", - "prettier": "3.7.3", + "prettier": "3.8.1", "prettier-plugin-tailwindcss": "0.7.1", "process": "0.11.10", "remark-gfm": "4.0.1", @@ -147,37 +146,37 @@ "url": "0.11.4", "util": "0.12.5", "wait-on": "9.0.3", - "webpack": "5.103.0", + "webpack": "5.104.1", "webpack-cli": "6.0.1", "webpack-dev-server": "5.2.2", "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.450", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.450", + "@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.522", + "@bitwarden/sdk-internal": "0.2.0-main.522", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", - "@koa/router": "14.0.0", + "@koa/router": "15.2.0", "@microsoft/signalr": "8.0.7", "@microsoft/signalr-protocol-msgpack": "8.0.7", "@ng-select/ng-select": "20.7.0", "big-integer": "1.6.52", "braintree-web-drop-in": "1.46.0", "buffer": "6.0.3", - "bufferutil": "4.0.9", + "bufferutil": "4.1.0", "chalk": "4.1.2", "commander": "14.0.0", - "core-js": "3.47.0", + "core-js": "3.48.0", "form-data": "4.0.4", "https-proxy-agent": "7.0.6", "inquirer": "8.2.6", @@ -191,10 +190,10 @@ "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", + "open": "8.4.2", "papaparse": "5.5.3", "proper-lockfile": "4.1.2", "qrcode-parser": "2.1.3", @@ -202,7 +201,7 @@ "rxjs": "7.8.1", "semver": "7.7.3", "tabbable": "6.3.0", - "tldts": "7.0.19", + "tldts": "7.0.22", "ts-node": "10.9.2", "utf-8-validate": "6.0.5", "vite-tsconfig-paths": "5.1.4", @@ -221,10 +220,6 @@ "react-dom": "18.3.1", "@types/react": "18.3.27" }, - "lint-staged": { - "*": "prettier --cache --ignore-unknown --write", - "*.ts": "eslint --cache --cache-strategy content --fix" - }, "engines": { "node": ">=22.12.0", "npm": "~10"