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 d1266a174e4..39e5b3f6003 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..ef2c91f0a7d 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -152,7 +152,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -260,7 +260,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -392,7 +392,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -565,7 +565,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Upload Sources - uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 + uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index d0abe8e12e7..75820c54977 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -130,7 +130,7 @@ jobs: } >> "$GITHUB_ENV" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -326,7 +326,7 @@ jobs: choco install nasm --no-progress - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 6b652149d8d..6818064a808 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@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: '3.14.2' @@ -1032,14 +1032,14 @@ jobs: - name: Cache Build id: build-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Cache Safari id: safari-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -1185,7 +1185,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -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@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: '3.14.2' @@ -1272,14 +1272,14 @@ jobs: - name: Get Build Cache id: build-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Setup Safari Cache id: safari-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -1409,7 +1409,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -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@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: '3.14.2' @@ -1547,14 +1547,14 @@ jobs: - name: Get Build Cache id: build-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Setup Safari Cache id: safari-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -1692,7 +1692,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -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@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.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 @@ -2086,15 +2090,16 @@ jobs: _CPU_ARCH: ${{ matrix.os == 'ubuntu-22.04' && 'amd64' || 'arm64' }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Download snap artifact uses: bitwarden/gh-actions/download-artifacts@main with: + run_id: ${{ github.run_id }} path: apps/desktop/artifacts/linux/snap artifacts: bitwarden_${{ env._PACKAGE_VERSION }}_${{ env._CPU_ARCH }}.snap @@ -2130,15 +2135,16 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Download dmg artifact uses: bitwarden/gh-actions/download-artifacts@main with: + run_id: ${{ github.run_id }} path: apps/desktop/artifacts/macos/dmg artifacts: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg @@ -2174,15 +2180,16 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Download portable artifact uses: bitwarden/gh-actions/download-artifacts@main with: + run_id: ${{ github.run_id }} path: apps/desktop/artifacts/windows/portable artifacts: Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 24a8df084a2..71a2c62ec1a 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -63,6 +63,11 @@ jobs: node_version: ${{ steps.retrieve-node-version.outputs.node_version }} has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} steps: + - name: Log inputs to job summary + uses: bitwarden/ios/.github/actions/log-inputs@main + with: + inputs: "${{ toJson(inputs) }}" + - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: @@ -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@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3 with: image: ${{ steps.image-name.outputs.name }} fail-build: false @@ -390,7 +408,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Upload Sources - uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 + uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index c7d80b82baa..b1dc3165c3e 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -58,14 +58,14 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: ${{ steps.retrieve-node-version.outputs.node_version }} if: steps.get-changed-files-for-chromatic.outputs.storyFiles == 'true' - name: Cache NPM id: npm-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: "~/.npm" key: ${{ runner.os }}-npm-chromatic-${{ hashFiles('**/package-lock.json') }} diff --git a/.github/workflows/crowdin-pull.yml b/.github/workflows/crowdin-pull.yml index e99034c499a..a707fef0889 100644 --- a/.github/workflows/crowdin-pull.yml +++ b/.github/workflows/crowdin-pull.yml @@ -49,7 +49,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} diff --git a/.github/workflows/lint-crowdin-config.yml b/.github/workflows/lint-crowdin-config.yml index dff253a8da2..61e2b3631e6 100644 --- a/.github/workflows/lint-crowdin-config.yml +++ b/.github/workflows/lint-crowdin-config.yml @@ -45,7 +45,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Lint ${{ matrix.app.name }} config - uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 + uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_PROJECT_ID: ${{ matrix.app.project_id }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8327093441c..7862c14c186 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -64,7 +64,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -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@542cebaaed782771e619bd5609d97659d109c492 # v2.66.7 with: tool: cargo-deny@0.18.6 diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 3a7431c07f0..e468ead4f1e 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -26,7 +26,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index ef287b0de08..5f6ee83e41f 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -216,7 +216,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: ${{ steps.retrieve-node-version.outputs.node_version }} registry-url: "https://registry.npmjs.org/" diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index f013abbbb3b..c5db7ea9295 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -331,7 +331,7 @@ jobs: run: wget "https://github.com/bitwarden/clients/releases/download/${_RELEASE_TAG}/macos-build-number.json" - name: Setup Ruby and Install Fastlane - uses: ruby/setup-ruby@d5126b9b3579e429dd52e51e68624dda2e05be25 # v1.267.0 + uses: ruby/setup-ruby@708024e6c902387ab41de36e1669e43b5ee7085e # v1.283.0 with: ruby-version: '3.4.7' bundler-cache: false diff --git a/.github/workflows/publish-web.yml b/.github/workflows/publish-web.yml index be0087800f7..c45e249d083 100644 --- a/.github/workflows/publish-web.yml +++ b/.github/workflows/publish-web.yml @@ -182,7 +182,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 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 46008206299..9f2428f2890 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": "استخدام تسجيل الدخول الأحادي" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "لا" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "الموقع" }, @@ -1539,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": "نظافة كلمة المرور، صحة الحساب، وتقارير تسريبات البيانات للحفاظ على سلامة خزانتك." }, @@ -2030,6 +2054,9 @@ "email": { "message": "البريد الإلكتروني" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "الهاتف" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "تم حذف العنصر بشكل دائم" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "استعادة العنصر" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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": "صلاحية تاريخ الانتهاء المقدّم غير صحيح." }, @@ -3349,6 +3355,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": "خطأ فك التشفير" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index fa28a709056..6414cdd39f9 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" }, @@ -582,8 +585,14 @@ "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." @@ -981,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ə" }, @@ -1539,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ı." }, @@ -2030,6 +2054,9 @@ "email": { "message": "E-poçt" }, + "emails": { + "message": "E-poçtlar" + }, "phone": { "message": "Telefon" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Element birdəfəlik silindi" }, + "archivedItemRestored": { + "message": "Arxivlənmiş element bərpa edildi" + }, "restoreItem": { "message": "Elementi bərpa et" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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ı" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Qoşmanı endir" + }, "downloadBitwarden": { "message": "Bitwarden-i endir" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Ekstra enli" }, + "narrow": { + "message": "Dar" + }, "sshKeyWrongPassword": { "message": "Daxil etdiyiniz parol yanlışdır." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 53ce24563e0..6bce3fdd891 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": "Выкарыстаць аднаразовы ўваход" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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": "Гігіена пароляў, здароўе ўліковага запісу і справаздачы аб уцечках даных для забеспячэння бяспекі вашага сховішча." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Электронная пошта" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Тэлефон" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Элемент выдалены назаўсёды" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Аднавіць элемент" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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": "Азначаная дата завяршэння тэрміну дзеяння з'яўляецца няправільнай." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index 751c7fe12df..35fe16b2542 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": "Използване на еднократна идентификация" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Архивиране на елемента" }, - "archiveItemConfirmDesc": { - "message": "Архивираните елементи са изключени от общите резултати при търсене и от предложенията за автоматично попълване. Наистина ли искате да архивирате този елемент?" + "archiveItemDialogContent": { + "message": "След като бъде архивиран, този елемент няма да се показва в резултатите при търсене, нито в предложенията за автоматично попълване." + }, + "archived": { + "message": "Архивирано" + }, + "unarchiveAndSave": { + "message": "Разархивиране и запазване" }, "upgradeToUseArchive": { "message": "За да се възползвате от архивирането, трябва да ползвате платен абонамент." @@ -981,6 +990,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Всеки с връзката" + }, + "anyOneWithPassword": { + "message": "Всеки с парола, зададена от Вас" + }, "location": { "message": "Местоположение" }, @@ -1539,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Частно двустепенно удостоверяване чрез YubiKey и Duo." }, + "premiumSubscriptionEnded": { + "message": "Вашият абонамент за платения план е приключил" + }, + "archivePremiumRestart": { + "message": "Ако искате отново да получите достъп до архива си, трябва да подновите платения си абонамент. Ако редактирате данните за архивиран елемент преди подновяването, той ще бъде върнат в трезора." + }, + "restartPremium": { + "message": "Подновяване на платения абонамент" + }, "ppremiumSignUpReports": { "message": "Проверки в списъците с публикувани пароли, проверка на регистрациите и доклади за пробивите в сигурността, което спомага трезорът ви да е допълнително защитен." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Електронна поща" }, + "emails": { + "message": "Е-пощи" + }, "phone": { "message": "Телефон" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Записът е изтрит окончателно" }, + "archivedItemRestored": { + "message": "Архивираният елемент е възстановен" + }, "restoreItem": { "message": "Възстановяване на запис" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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": "Неправилна дата на валидност." }, @@ -3349,6 +3355,12 @@ "error": { "message": "Грешка" }, + "prfUnlockFailed": { + "message": "Отключването със секретен ключ не беше успешно. Опитайте отново или използвайте друг начин за отключване." + }, + "noPrfCredentialsAvailable": { + "message": "Няма секретни ключове с включено PRF, налични за отключване. Първо се впишете със секретен ключ." + }, "decryptionError": { "message": "Грешка при дешифриране" }, @@ -4724,6 +4736,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Още настройки" + }, "moreOptionsTitle": { "message": "Още опции – $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Сваляне на прикачения файл" + }, "downloadBitwarden": { "message": "Сваляне на Битуорден" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Скриване на откритото съвпадение $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Показване на откриването на съвпадения" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Скриване на откриването на съвпадения" }, "autoFillOnPageLoad": { "message": "Автоматично попълване при зареждане на страницата?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Много широко" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Въведената парола е неправилна." }, @@ -6085,7 +6103,35 @@ "whyAmISeeingThis": { "message": "Защо виждам това?" }, + "items": { + "message": "Елементи" + }, + "searchResults": { + "message": "Резултати от търсенето" + }, "resizeSideNavigation": { "message": "Преоразмеряване на страничната навигация" + }, + "whoCanView": { + "message": "Кой може да преглежда" + }, + "specificPeople": { + "message": "Определени хора" + }, + "emailVerificationDesc": { + "message": "След като споделите тази връзка към Изпращане, хората ще трябва да потвърдят е-пощата си чрез код, за да могат да видят това Изпращане." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Можете да въведете повече е-пощи, като ги разделите със запетая." + }, + "emailPlaceholder": { + "message": "потребител@bitwarden.com , потребител@acme.com" + }, + "emailProtected": { + "message": "Е-пощата е защитена" + }, + "sendPasswordHelperText": { + "message": "Хората ще трябва да въведат паролата, за да видят това Изпращане", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index d690cf29878..4f6402fa8ea 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" @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "না" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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": "আপনার ভল্টটি সুরক্ষিত রাখতে পাসওয়ার্ড স্বাস্থ্যকরন, অ্যাকাউন্ট স্বাস্থ্য এবং ডেটা লঙ্ঘনের প্রতিবেদন।" }, @@ -2030,6 +2054,9 @@ "email": { "message": "ই-মেইল" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ফোন" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "বস্তুটি স্থায়ীভাবে মুছে ফেলা হয়েছে" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "বস্তু পুনরুদ্ধার" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index 75ab751df5f..36aa422ff0d 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 53cb057c188..409a11ff253 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Ubicació" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Correu electrònic" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telèfon" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Element suprimit definitivament" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restaura l'element" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra ample" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 6905ba5f922..206566edcd5 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í" }, @@ -582,8 +585,14 @@ "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í." @@ -981,6 +990,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Kdokoli s odkazem" + }, + "anyOneWithPassword": { + "message": "Kdokoli s heslem od Vás" + }, "location": { "message": "Umístění" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "E-maily" + }, "phone": { "message": "Telefon" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Položka byla trvale smazána" }, + "archivedItemRestored": { + "message": "Archivovaná položka byla obnovena" + }, "restoreItem": { "message": "Obnovit položku" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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é." }, @@ -3137,7 +3143,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." @@ -3349,6 +3355,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í" }, @@ -4724,6 +4736,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.", @@ -4815,7 +4830,7 @@ "message": "Konzole správce" }, "admin": { - "message": "Administrátor" + "message": "Správce" }, "automaticUserConfirmation": { "message": "Automatické potvrzení uživatele" @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Stáhnout přílohu" + }, "downloadBitwarden": { "message": "Stáhnout Bitwarden" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra široké" }, + "narrow": { + "message": "Úzké" + }, "sshKeyWrongPassword": { "message": "Zadané heslo není správné." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 2e8f20f51ac..dc5ed2d6c7d 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "Na" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lleoliad" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Ebost" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Ffôn" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Eitem wedi'i dileu'n barhaol" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Adfer yr eitem" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Llydan iawn" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 6d06071480b..72a009b55bc 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "Nej" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Element slettet permanent" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Gendan element" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Ekstra bred" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index c2b5b1d1ef2..ddebf64adf4 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" }, @@ -582,8 +585,14 @@ "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." @@ -981,6 +990,12 @@ "no": { "message": "Nein" }, + "noAuth": { + "message": "Alle mit dem Link" + }, + "anyOneWithPassword": { + "message": "Alle mit einem von dir festgelegtem Passwort" + }, "location": { "message": "Standort" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "E-Mail" }, + "emails": { + "message": "E-Mails" + }, "phone": { "message": "Telefon" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Eintrag dauerhaft gelöscht" }, + "archivedItemRestored": { + "message": "Archivierter Eintrag wiederhergestellt" + }, "restoreItem": { "message": "Eintrag wiederherstellen" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Anhang herunterladen" + }, "downloadBitwarden": { "message": "Bitwarden herunterladen" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra breit" }, + "narrow": { + "message": "Schmal" + }, "sshKeyWrongPassword": { "message": "Dein eingegebenes Passwort ist falsch." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index 2cf5650bafd..04e2d2568f7 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,25 +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": "Item was unarchived" + "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": "Επεξεργασία" @@ -598,13 +607,13 @@ "message": "Προβολή" }, "viewAll": { - "message": "View all" + "message": "Προβολή όλων" }, "showAll": { - "message": "Show all" + "message": "Εμφάνιση όλων" }, "viewLess": { - "message": "View less" + "message": "Προβολή λιγότερων" }, "viewLogin": { "message": "Προβολή σύνδεσης" @@ -752,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", @@ -809,10 +818,10 @@ "message": "Κατά το Κλείδωμα Συστήματος" }, "onIdle": { - "message": "On system idle" + "message": "Κατά την αδράνεια του συστήματος" }, "onSleep": { - "message": "On system sleep" + "message": "Κατά την αναμονή του συστήματος" }, "onRestart": { "message": "Κατά την Επανεκκίνηση του Browser" @@ -981,6 +990,12 @@ "no": { "message": "Όχι" }, + "noAuth": { + "message": "Οποιοσδήποτε/οποιαδήποτε έχει το σύνδεσμο" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Τοποθεσία" }, @@ -1053,10 +1068,10 @@ "message": "Το αντικείμενο αποθηκεύτηκε" }, "savedWebsite": { - "message": "Saved website" + "message": "Αποθηκευμένος ιστοχώρος" }, "savedWebsites": { - "message": "Saved websites ( $COUNT$ )", + "message": "Αποθηκευμένοι ιστοχώροι ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -1184,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": { @@ -1253,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": "Ζητήστε να ενημερώσετε την υπάρχουσα σύνδεση" @@ -1329,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": { @@ -1423,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)" @@ -1474,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": "Αρχείο" @@ -1492,7 +1507,7 @@ "message": "Επιλέξτε αρχείο" }, "itemsTransferred": { - "message": "Items transferred" + "message": "Μεταβιβασθέντα στοιχεία" }, "maxFileSize": { "message": "Το μέγιστο μέγεθος αρχείου είναι 500 MB." @@ -1501,7 +1516,7 @@ "message": "Μη διαθέσιμη λειτουργία" }, "legacyEncryptionUnsupported": { - "message": "Legacy encryption is no longer supported. Please contact support to recover your account." + "message": "Η παλαιού τύπου κρυπτογράφηση δεν υποστηρίζεται πλέον. Επικοινωνήστε με την υποστήριξη για να ανακτήσετε το λογαριασμό σας." }, "premiumMembership": { "message": "Συνδρομή Premium" @@ -1525,7 +1540,7 @@ "message": "1 GB κρυπτογραφημένο αποθηκευτικό χώρο για συνημμένα αρχεία." }, "premiumSignUpStorageV2": { - "message": "$SIZE$ encrypted storage for file attachments.", + "message": "$SIZE$ κρυπτογραφημένου αποθηκευτικού χώρου για συνημμένα αρχεία.", "placeholders": { "size": { "content": "$1", @@ -1539,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Πρόσθετες επιλογές σύνδεσης δύο βημάτων, όπως το YubiKey και το Duo." }, + "premiumSubscriptionEnded": { + "message": "Η Premium συνδρομή σας τελείωσε." + }, + "archivePremiumRestart": { + "message": "Για να ξανα-αποκτήσετε πρόσβαση στην αρχειοθήκη σας, επανεκκινήστε τη Premium συνδρομή σας. Αν επεξεργαστείτε λεπτομέρειες ενός αρχειοθετημένου στοιχείου πριν την επανεκκίνηση, αυτό θα μεταφερθεί πίσω στο θησαυροφυλάκιο σας." + }, + "restartPremium": { + "message": "Επανεκκίνηση Premium" + }, "ppremiumSignUpReports": { "message": "Ασφάλεια κωδικών, υγεία λογαριασμού και αναφορές παραβίασης δεδομένων για να διατηρήσετε ασφαλές το vault σας." }, @@ -1612,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 του υπολογιστή σας. Αν έχει κουμπί, πατήστε το." @@ -1631,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": "Μη διαθέσιμη σύνδεση" @@ -1658,7 +1682,7 @@ "message": "Επιλογές σύνδεσης δύο βημάτων" }, "selectTwoStepLoginMethod": { - "message": "Select two-step login method" + "message": "Επιλογή μεθόδου δύο παραγόντων για σύνδεση" }, "recoveryCodeTitle": { "message": "Κωδικός ανάκτησης" @@ -1709,7 +1733,7 @@ "message": "Πρέπει να προσθέσετε είτε το βασικό URL του διακομιστή ή τουλάχιστον ένα προσαρμοσμένο περιβάλλον." }, "selfHostedEnvMustUseHttps": { - "message": "URLs must use HTTPS." + "message": "Οι διευθύνσεις URL πρέπει να χρησιμοποιούν HTTPS." }, "customEnvironment": { "message": "Προσαρμοσμένο περιβάλλον" @@ -1750,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", @@ -1765,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." @@ -1774,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": "Εμφάνιση ταυτοτήτων ως προτάσεις" @@ -2030,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Τηλέφωνο" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Το αντικείμενο διαγράφηκε οριστικά" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Επαναφορά αντικειμένου" }, @@ -2618,7 +2648,7 @@ "message": "Ξεκινήστε την εφαρμογή Bitwarden Επιφάνεια εργασίας" }, "startDesktopDesc": { - "message": "Η εφαρμογή Bitwarden Desktop πρέπει να ξεκινήσει για να μπορεί να χρησιμοποιηθεί αυτή η λειτουργία." + "message": "Η εφαρμογή Bitwarden Desktop πρέπει να εκκινηθεί για να χρησιμοποιηθεί αυτή η λειτουργία." }, "errorEnableBiometricTitle": { "message": "Αδυναμία ενεργοποίησης βιομετρικών στοιχείων" @@ -3005,10 +3035,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." @@ -3068,29 +3094,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": "Η ημερομηνία λήξης που δόθηκε δεν είναι έγκυρη." }, @@ -3349,6 +3355,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": "Σφάλμα αποκρυπτογράφησης" }, @@ -3657,13 +3669,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": "Μια ειδοποίηση στάλθηκε στη συσκευή σας" @@ -3678,7 +3690,7 @@ "message": "Η σύνδεση ξεκίνησε" }, "logInRequestSent": { - "message": "Request sent" + "message": "Το αίτημα στάλθηκε" }, "loginRequestApprovedForEmailOnDevice": { "message": "Login request approved for $EMAIL$ on $DEVICE$", @@ -4724,6 +4736,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Περισσότερες επιλογές - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Λήψη του Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Απόκρυψη ανιχνεύσεων αντιστοίχισης $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Αυτόματη συμπλήρωση κατά τη φόρτωση της σελίδας;" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Εξαιρετικά φαρδύ" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index d3a393ecc37..c6d9d325e00 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4583,11 +4595,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" }, @@ -4595,7 +4607,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" }, @@ -4724,6 +4736,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.", @@ -4782,7 +4797,7 @@ } } }, - "copyFieldCipherName": { + "copyFieldCipherName": { "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { @@ -4814,7 +4829,7 @@ "adminConsole": { "message": "Admin Console" }, - "admin" :{ + "admin": { "message": "Admin" }, "automaticUserConfirmation": { @@ -4823,7 +4838,7 @@ "automaticUserConfirmationHint": { "message": "Automatically confirm pending users while this device is unlocked" }, - "autoConfirmOnboardingCallout":{ + "autoConfirmOnboardingCallout": { "message": "Save time with automatic user confirmation" }, "autoConfirmWarning": { @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -5760,7 +5778,7 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitleV2":{ + "phishingPageTitleV2": { "message": "Phishing attempt detected" }, "phishingPageSummary": { @@ -5780,7 +5798,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": { @@ -5948,7 +5966,7 @@ "cardNumberLabel": { "message": "Card number" }, - "removeMasterPasswordForOrgUserKeyConnector":{ + "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, "continueWithLogIn": { @@ -5966,10 +5984,10 @@ "verifyYourOrganization": { "message": "Verify your organization to log in" }, - "organizationVerified":{ + "organizationVerified": { "message": "Organization verified" }, - "domainVerified":{ + "domainVerified": { "message": "Domain verified" }, "leaveOrganizationContent": { @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 1940070310e..ab3b511a009 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index fcc7725f3fc..3c19d7c8af0 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Permanently deleted item" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index 3ee3d31d56b..b6d1f5f793b 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,45 +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": "Item was unarchived" + "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" @@ -598,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." @@ -981,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" }, @@ -1056,7 +1071,7 @@ "message": "Saved website" }, "savedWebsites": { - "message": "Saved websites ( $COUNT$ )", + "message": "Sitios web guardados ( $COUNT$ )", "placeholders": { "count": { "content": "$1", @@ -1257,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" @@ -1329,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": { @@ -1423,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)" @@ -1474,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" @@ -1492,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." @@ -1525,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", @@ -1539,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." }, @@ -1634,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..." @@ -1709,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" @@ -1765,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." @@ -1977,7 +2001,7 @@ "message": "Código de seguridad" }, "cardNumber": { - "message": "card number" + "message": "número de tarjeta" }, "ex": { "message": "ej." @@ -2030,6 +2054,9 @@ "email": { "message": "Correo electrónico" }, + "emails": { + "message": "Correos electrónicos" + }, "phone": { "message": "Teléfono" }, @@ -2082,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": { @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Elemento eliminado de forma permanente" }, + "archivedItemRestored": { + "message": "Elemento archivado restaurado" + }, "restoreItem": { "message": "Restaurar elemento" }, @@ -2752,7 +2782,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", @@ -2761,7 +2791,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", @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -3700,7 +3712,7 @@ "message": "Dispositivo" }, "loginStatus": { - "message": "Login status" + "message": "Estado del inicio de sesión" }, "masterPasswordChanged": { "message": "Contraseña maestra guardada" @@ -3872,7 +3884,7 @@ "message": "Login request has already expired." }, "justNow": { - "message": "Just now" + "message": "Justo ahora" }, "requestedXMinutesAgo": { "message": "Requested $MINUTES$ minutes ago", @@ -4596,7 +4608,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": { @@ -4724,6 +4736,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.", @@ -4815,7 +4830,7 @@ "message": "Consola de administrador" }, "admin": { - "message": "Admin" + "message": "Administrador" }, "automaticUserConfirmation": { "message": "Automatic user confirmation" @@ -4845,7 +4860,7 @@ "message": "Turned on automatic confirmation" }, "availableNow": { - "message": "Available now" + "message": "Disponible ahora" }, "accountSecurity": { "message": "Seguridad de la cuenta" @@ -4854,7 +4869,7 @@ "message": "Phishing Blocker" }, "enablePhishingDetection": { - "message": "Phishing detection" + "message": "Detección de phishing" }, "enablePhishingDetectionDesc": { "message": "Display warning before accessing suspected phishing sites" @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Descargar Adjunto" + }, "downloadBitwarden": { "message": "Descargar Bitwarden" }, @@ -5110,14 +5128,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?" @@ -5353,10 +5368,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" @@ -5416,7 +5431,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" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extraancho" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "La contraseña introducida es incorrecta." }, @@ -5704,10 +5722,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" @@ -5764,27 +5782,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", @@ -5793,19 +5811,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." }, @@ -5868,7 +5886,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." @@ -5881,13 +5899,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", @@ -5900,83 +5918,83 @@ "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" }, "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" @@ -5991,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).", @@ -6029,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", @@ -6059,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", @@ -6077,15 +6095,43 @@ } }, "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index e39f87b6b86..34ac5f523ca 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "Ei" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefoninumber" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Kirje on jäädavalt kustutatud" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Taasta kirje" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index 81905e2ee20..cd2cbb910ef 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "Ez" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Emaila" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefonoa" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Elementua betirako ezabatua" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Berreskuratu elementua" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 057db9f0290..ea95452d409 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": "استفاده از ورود تک مرحله‌ای" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "خیر" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "موقعیت" }, @@ -1539,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": "گزارش‌های بهداشت کلمه عبور، سلامت حساب کاربری و نقض داده‌ها برای ایمن نگهداشتن گاوصندوق شما." }, @@ -2030,6 +2054,9 @@ "email": { "message": "ایمیل" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "تلفن" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "مورد برای همیشه حذف شد" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "بازیابی مورد" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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": "تاریخ انقضاء ارائه شده معتبر نیست." }, @@ -3349,6 +3355,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": "خطای رمزگشایی" }, @@ -4724,6 +4736,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "گزینه‌های بیشتر - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "بارگیری Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "مخفی کردن شناسایی تطابق $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "پر کردن خودکار هنگام بارگذاری صفحه؟" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "خیلی عریض" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "کلمه عبور وارد شده اشتباه است." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 62785164c08..630c6e91ff2 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" }, @@ -582,8 +585,14 @@ "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." @@ -981,6 +990,12 @@ "no": { "message": "En" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Sijainti" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Sähköposti" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Puhelinnumero" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Kohde poistettiin pysyvästi" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Palauta kohde" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Lataa Bitwarden" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Erittäin leveä" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Syöttämäsi salasana on virheellinen." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index b485c7f26f5..eb6687e810c 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "Hindi" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Mag-email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telepono" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanenteng tinanggal" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Ibalik ang item" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 238a5ca5e68..9de933d34df 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" }, @@ -582,8 +585,14 @@ "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." @@ -981,6 +990,12 @@ "no": { "message": "Non" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Emplacement" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Courriel" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Téléphone" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Élément définitivement supprimé" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restaurer l'élément" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Télécharger Bitwarden" }, @@ -5110,14 +5128,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 ?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Très large" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Le mot de passe saisi est incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 1544e6b822c..15ab24b16d7 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "Non" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Correo electrónico" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Teléfono" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Entrada eliminada permanente" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restaurar entrada" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Moi ancho" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index 6f8b6f235a7..ab0fbfe9562 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": "סנכרון אחרון:" @@ -574,7 +577,7 @@ "message": "הפריט נשלח לארכיון" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "הפריט שוחזר מהארכיב" }, "itemUnarchived": { "message": "הפריט הוסר מהארכיון" @@ -582,14 +585,20 @@ "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": "ערוך" @@ -601,7 +610,7 @@ "message": "הצג הכל" }, "showAll": { - "message": "Show all" + "message": "הצגת הכל" }, "viewLess": { "message": "הצג פחות" @@ -981,6 +990,12 @@ "no": { "message": "לא" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "מיקום" }, @@ -1329,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": { @@ -1423,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)" @@ -1474,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": "קובץ" @@ -1492,7 +1507,7 @@ "message": "בחר קובץ" }, "itemsTransferred": { - "message": "Items transferred" + "message": "הפריטים הועברו" }, "maxFileSize": { "message": "גודל הקובץ המרבי הוא 500MB." @@ -1525,7 +1540,7 @@ "message": "1 ג'יגה של מקום אחסון עבור קבצים מצורפים." }, "premiumSignUpStorageV2": { - "message": "$SIZE$ encrypted storage for file attachments.", + "message": "$SIZE$ של אחסון מוצפן עבור קבצים מצורפים.", "placeholders": { "size": { "content": "$1", @@ -1539,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "אפשרויות כניסה דו־שלבית קנייניות כגון YubiKey ו־Duo." }, + "premiumSubscriptionEnded": { + "message": "מנוי הפרמיום שלך הסתיים" + }, + "archivePremiumRestart": { + "message": "לשחזור הגישה לארכיב שלך יש לחדש את מנוי הפרמיום שלך. אם תבצעו עריכת פרטים של פריט בארכיב לפני חידוש המנוי, הפריט ישוחזר אל הכספת שלכם." + }, + "restartPremium": { + "message": "חידוש מנוי הפרמיום" + }, "ppremiumSignUpReports": { "message": "היגיינת סיסמאות, מצב בריאות החשבון, ודיווחים מעודכנים על פרצות חדשות בכדי לשמור על הכספת שלך בטוחה." }, @@ -1932,7 +1956,7 @@ "message": "שנת תפוגה" }, "monthly": { - "message": "month" + "message": "חודש" }, "expiration": { "message": "תוקף" @@ -2030,6 +2054,9 @@ "email": { "message": "אימייל" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "טלפון" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "הפריט נמחק לצמיתות" }, + "archivedItemRestored": { + "message": "פריט שוחזר מהארכיב" + }, "restoreItem": { "message": "שחזר פריט" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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": "תאריך התפוגה שסופק אינו חוקי." }, @@ -3349,6 +3355,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": "שגיאת פענוח" }, @@ -4724,6 +4736,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "עוד אפשרויות - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "הורד את Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "הסתר זיהוי התאמה $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "למלא אוטומטית בעת טעינת עמוד?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "רחב במיוחד" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "הסיסמה שהזנת שגויה." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 81202f05b58..b07e6bcb5c9 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": "संस्करण" @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "नहीं" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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": "अपनी वॉल्ट को सुरक्षित रखने के लिए पासवर्ड स्वच्छता, खाता स्वास्थ्य और डेटा उल्लंघन रिपोर्ट।" }, @@ -2030,6 +2054,9 @@ "email": { "message": "ईमेल" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "फोन" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "स्थायी रूप से आइटम हटाएं" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "आइटम बहाल करें" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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": "प्रदान की गई समाप्ति तिथि मान्य नहीं है।" }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "अटैचमेंट डाउनलोड करें" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index 536529d1995..d5f7f21ddb0 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:" @@ -574,7 +577,7 @@ "message": "Stavka poslana u arhivu" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "Stavka vraćena iz arhive" }, "itemUnarchived": { "message": "Stavka vraćena iz arhive" @@ -582,14 +585,20 @@ "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" @@ -601,7 +610,7 @@ "message": "Vidi sve" }, "showAll": { - "message": "Show all" + "message": "Prikaži sve" }, "viewLess": { "message": "Vidi manje" @@ -981,6 +990,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokacija" }, @@ -1329,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": { @@ -1423,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)" @@ -1480,7 +1495,7 @@ "message": "This file is using an outdated encryption method." }, "attachmentUpdated": { - "message": "Attachment updated" + "message": "Privitak ažuriran" }, "file": { "message": "Datoteka" @@ -1525,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", @@ -1539,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." }, @@ -1932,7 +1956,7 @@ "message": "Godina isteka" }, "monthly": { - "message": "month" + "message": "mjesečno" }, "expiration": { "message": "Istek" @@ -2030,6 +2054,9 @@ "email": { "message": "E-pošta" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Stavka trajno izbrisana" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Vrati stavku" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4845,7 +4860,7 @@ "message": "Turned on automatic confirmation" }, "availableNow": { - "message": "Available now" + "message": "Dostupno sada" }, "accountSecurity": { "message": "Sigurnost računa" @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Preuzmi Bitwarden" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Ekstra široko" }, + "narrow": { + "message": "Usko" + }, "sshKeyWrongPassword": { "message": "Unesena lozinka nije ispravna." }, @@ -5704,10 +5722,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" @@ -6077,15 +6095,43 @@ } }, "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index 049f2f776f1..36a929a8712 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" }, @@ -582,8 +585,14 @@ "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." @@ -981,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" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Email címek" + }, "phone": { "message": "Telefonszám" }, @@ -2458,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" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Melléklet letöltése" + }, "downloadBitwarden": { "message": "Bitwarden letöltése" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra széles" }, + "narrow": { + "message": "Keskeny" + }, "sshKeyWrongPassword": { "message": "A megadott jelszó helytelen." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 1a628e4e765..098879ce6fc 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Masuk dengan kunci sandi" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Gunakan masuk tunggal" }, @@ -225,7 +228,7 @@ "message": "Salin Nama Kolom Pilihan" }, "noMatchingLogins": { - "message": "Tidak ada info masuk yang cocok." + "message": "Tidak ada log masuk yang cocok" }, "noCards": { "message": "Tanpa kartu" @@ -437,7 +440,7 @@ "message": "Sinkronisasi" }, "syncNow": { - "message": "Sync now" + "message": "Selaraskan sekarang" }, "lastSync": { "message": "Sinkronisasi Terakhir:" @@ -551,27 +554,27 @@ "message": "Atur ulang pencarian" }, "archiveNoun": { - "message": "Archive", + "message": "Arsip", "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "Arsip", "description": "Verb" }, "unArchive": { "message": "Unarchive" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Butir dalam arsip" }, "noItemsInArchive": { - "message": "No items in archive" + "message": "Tidak ada butir dalam arsip" }, "noItemsInArchiveDesc": { - "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + "message": "Butir yang diarsipkan akan muncul di sini dan akan dikecualikan dari hasil pencarian umum dan saran isi otomatis." }, "itemWasSentToArchive": { - "message": "Item was sent to archive" + "message": "Butir dikirim ke arsip" }, "itemWasUnarchived": { "message": "Item was unarchived" @@ -580,10 +583,16 @@ "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." @@ -981,6 +990,12 @@ "no": { "message": "Tidak" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokasi" }, @@ -1471,7 +1486,7 @@ "message": "Tidak ada lampiran." }, "attachmentSaved": { - "message": "Lampiran telah disimpan." + "message": "Lampiran disimpan" }, "fixEncryption": { "message": "Fix encryption" @@ -1489,7 +1504,7 @@ "message": "Berkas untuk dibagikan" }, "selectFile": { - "message": "Pilih berkas." + "message": "Pilih berkas" }, "itemsTransferred": { "message": "Items transferred" @@ -1539,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." }, @@ -1628,7 +1652,7 @@ "message": "Buka dalam tab baru" }, "webAuthnAuthenticate": { - "message": "Autentikasi dengan WebAuthn." + "message": "Autentikasikan WebAuthn" }, "readSecurityKey": { "message": "Baca kunci keamanan" @@ -1737,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", @@ -1837,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." @@ -1867,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" @@ -1932,7 +1956,7 @@ "message": "Tahun Kedaluwarsa" }, "monthly": { - "message": "month" + "message": "bulan" }, "expiration": { "message": "Masa Berlaku" @@ -2030,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telepon" }, @@ -2082,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": { @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Hapus Item Secara Permanen" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Pulihkan Item" }, @@ -2483,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?" @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3189,7 +3195,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", @@ -3281,7 +3287,7 @@ "message": "Hapus Kata Sandi Utama" }, "removedMasterPassword": { - "message": "Sandi utama dihapus." + "message": "Sandi utama dihapus" }, "leaveOrganizationConfirmation": { "message": "Apakah Anda yakin ingin meninggalkan organisasi ini?" @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Unduh Bitwarden" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Ekstra lebar" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Kata sandi yang Anda masukkan tidak benar." }, @@ -5979,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).", @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index 3f662938cd7..9cd4efec3ee 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" }, @@ -582,8 +585,14 @@ "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." @@ -981,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Chiunque abbia il link" + }, + "anyOneWithPassword": { + "message": "Chiunque abbia una password impostata da te" + }, "location": { "message": "Luogo" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Indirizzi email" + }, "phone": { "message": "Telefono" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Elemento eliminato definitivamente" }, + "archivedItemRestored": { + "message": "Elemento estratto dall'archivio" + }, "restoreItem": { "message": "Ripristina elemento" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Scarica allegato" + }, "downloadBitwarden": { "message": "Scarica Bitwarden" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Molto larga" }, + "narrow": { + "message": "Stretto" + }, "sshKeyWrongPassword": { "message": "La parola d'accesso inserita non è corretta." }, @@ -6085,7 +6103,35 @@ "whyAmISeeingThis": { "message": "Perché vedo questo avviso?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "resizeSideNavigation": { "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index b114ac090c0..8de6fb53c1c 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": "シングルサインオンを使用する" }, @@ -582,8 +585,14 @@ "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": "アーカイブを使用するにはプレミアムメンバーシップが必要です。" @@ -981,6 +990,12 @@ "no": { "message": "いいえ" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "場所" }, @@ -1539,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": "保管庫を安全に保つための、パスワードやアカウントの健全性、データ侵害に関するレポート" }, @@ -2030,6 +2054,9 @@ "email": { "message": "メールアドレス" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "電話番号" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "完全に削除されたアイテム" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "アイテムをリストア" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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": "入力された有効期限は正しくありません。" }, @@ -3349,6 +3355,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": "復号エラー" }, @@ -4724,6 +4736,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "その他のオプション - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Bitwarden をダウンロード" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "一致検出 $WEBSITE$を非表示", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "ページ読み込み時に自動入力する" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "エクストラワイド" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "入力されたパスワードが間違っています。" }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index feb90164977..49e1eb3cabd 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "არა" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "ელ-ფოსტა" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ტელეფონი" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index e0bce7c9224..c6d9d325e00 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 46f73f568cb..36007446e97 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "ಇಲ್ಲ" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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": "ನಿಮ್ಮ ವಾಲ್ಟ್ ಅನ್ನು ಸುರಕ್ಷಿತವಾಗಿರಿಸಲು ಪಾಸ್ವರ್ಡ್ ನೈರ್ಮಲ್ಯ, ಖಾತೆ ಆರೋಗ್ಯ ಮತ್ತು ಡೇಟಾ ಉಲ್ಲಂಘನೆ ವರದಿಗಳು." }, @@ -2030,6 +2054,9 @@ "email": { "message": "ಇಮೇಲ್" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ಫೋನ್‌" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "ಶಾಶ್ವತವಾಗಿ ಅಳಿಸಲಾದ ಐಟಂ" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "ಐಟಂ ಅನ್ನು ಮರುಸ್ಥಾಪಿಸಿ" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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": "ಒದಗಿಸಿದ ಮುಕ್ತಾಯ ದಿನಾಂಕವು ಮಾನ್ಯವಾಗಿಲ್ಲ." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index 10cf245290d..b0afb6d12b3 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": "마지막 동기화:" @@ -574,7 +577,7 @@ "message": "항목이 보관함으로 이동되었습니다" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "항목이 보관 해제되었습니다" }, "itemUnarchived": { "message": "항목 보관 해제됨" @@ -582,14 +585,20 @@ "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,13 +607,13 @@ "message": "보기" }, "viewAll": { - "message": "View all" + "message": "모두 보기" }, "showAll": { - "message": "Show all" + "message": "모두 보기" }, "viewLess": { - "message": "View less" + "message": "접기" }, "viewLogin": { "message": "로그인 보기" @@ -715,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": "보관함이 잠겨 있습니다. 마스터 비밀번호를 입력하여 계속하세요." @@ -752,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", @@ -809,10 +818,10 @@ "message": "시스템 잠금 시" }, "onIdle": { - "message": "On system idle" + "message": "시스템이 유휴 상태일 때" }, "onSleep": { - "message": "On system sleep" + "message": "시스템이 절전 모드로 전환될 때" }, "onRestart": { "message": "브라우저 재시작 시" @@ -943,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": "등록 재시작" @@ -981,8 +990,14 @@ "no": { "message": "아니오" }, + "noAuth": { + "message": "링크가 있는 모든 사용자" + }, + "anyOneWithPassword": { + "message": "내가 설정한 비밀번호를 아는 모든 사용자" + }, "location": { - "message": "Location" + "message": "위치" }, "unexpectedError": { "message": "예기치 못한 오류가 발생했습니다." @@ -1053,10 +1068,10 @@ "message": "항목 편집함" }, "savedWebsite": { - "message": "Saved website" + "message": "저장된 웹사이트" }, "savedWebsites": { - "message": "Saved websites ( $COUNT$ )", + "message": "저장된 웹사이트 ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -1146,7 +1161,7 @@ "message": "예, 지금 저장하겠습니다." }, "notificationViewAria": { - "message": "View $ITEMNAME$, opens in new window", + "message": "$ITEMNAME$ 보기, 새 창에서 열림", "placeholders": { "itemName": { "content": "$1" @@ -1155,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": { @@ -1176,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": { @@ -1196,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" @@ -1233,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" @@ -1245,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": { @@ -1539,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": "보관함을 안전하게 유지하기 위한 암호 위생, 계정 상태, 데이터 유출 보고서" }, @@ -2030,6 +2054,9 @@ "email": { "message": "이메일" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "전화번호" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "영구적으로 삭제된 항목" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "항목 복원" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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": "제공된 만료 날짜가 유효하지 않습니다." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "다른 옵션 - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "$WEBSITE$ 일치 인식 숨기기", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "페이지 로드 시 자동 완성을 할까요?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "매우 넓게" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index f93790244cf..5489c489de3 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ą" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "El. paštas" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefonas" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Ištrintas visam laikui" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Atkurti elementą" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index 596b4dfeaa0..603d7e85eaa 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" }, @@ -582,8 +585,14 @@ "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." @@ -981,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" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "E-pasts" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Tālrunis" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Vienums ir neatgriezeniski izdzēsts" }, + "archivedItemRestored": { + "message": "Arhīva vienums atjaunots" + }, "restoreItem": { "message": "Atjaunot vienumu" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Lejupielādēt pielikumu" + }, "downloadBitwarden": { "message": "Lejupielādē Bitwarden" }, @@ -5110,14 +5128,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ī?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Ļoti plats" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Ievadītā parole ir nepareiza." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index cc286de0c01..740d9077351 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "തെറ്റ്" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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": "നിങ്ങളുടെ വാൾട് സൂക്ഷിക്കുന്നതിന്. പാസ്‌വേഡ് ശുചിത്വം, അക്കൗണ്ട് ആരോഗ്യം, ഡാറ്റ ലംഘന റിപ്പോർട്ടുകൾ." }, @@ -2030,6 +2054,9 @@ "email": { "message": "ഇമെയിൽ" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ഫോൺ" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "ശാശ്വതമായി ഇല്ലാതാക്കിയ ഇനം" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "ഇനം വീണ്ടെടുക്കുക " }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index 546c03c8bfb..ae2ef47131f 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index e0bce7c9224..c6d9d325e00 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index d5015c3a87d..877be294778 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "Nei" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Sted" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Slettet objektet permanent" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Gjenopprett objekt" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Last ned Bitwarden" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Ekstra bred" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Passordet du skrev inn er feil." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index e0bce7c9224..c6d9d325e00 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index ecb9fc7a297..895d6592b93 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Inloggen met passkey" }, + "unlockWithPasskey": { + "message": "Ontgrendelen met passkey" + }, "useSingleSignOn": { "message": "Single sign-on gebruiken" }, @@ -582,8 +585,14 @@ "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": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "Je hebt een Premium-abonnement nodig om te kunnen archiveren." @@ -981,6 +990,12 @@ "no": { "message": "Nee" }, + "noAuth": { + "message": "Iedereen met de link" + }, + "anyOneWithPassword": { + "message": "Iedereen met een door jou ingesteld wachtwoord" + }, "location": { "message": "Locatie" }, @@ -1539,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Eigen opties voor tweestapsaanmelding zoals YubiKey en Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "archivePremiumRestart": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Wachtwoordhygiëne, gezondheid van je account en datalekken om je kluis veilig te houden." }, @@ -2030,6 +2054,9 @@ "email": { "message": "E-mailadres" }, + "emails": { + "message": "E-mails" + }, "phone": { "message": "Telefoonnummer" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Definitief verwijderd item" }, + "archivedItemRestored": { + "message": "Gearchiveerd item hersteld" + }, "restoreItem": { "message": "Item herstellen" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Bijlage downloaden" + }, "downloadBitwarden": { "message": "Bitwarden downloaden" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra breed" }, + "narrow": { + "message": "Smal" + }, "sshKeyWrongPassword": { "message": "Het wachtwoord dat je hebt ingevoerd is onjuist." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index e0bce7c9224..c6d9d325e00 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index e0bce7c9224..c6d9d325e00 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 72912399978..fa1c2956e9f 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" }, @@ -574,7 +577,7 @@ "message": "Element został przeniesiony do archiwum" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "Element został usunięty z archiwum" }, "itemUnarchived": { "message": "Element został usunięty z archiwum" @@ -582,8 +585,14 @@ "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." @@ -981,6 +990,12 @@ "no": { "message": "Nie" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokalizacja" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Adres e-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Numer telefonu" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Element został trwale usunięty" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Przywróć element" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4815,7 +4830,7 @@ "message": "Konsola administratora" }, "admin": { - "message": "Admin" + "message": "Administrator" }, "automaticUserConfirmation": { "message": "Automatic user confirmation" @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Pobierz załącznik" + }, "downloadBitwarden": { "message": "Pobierz Bitwarden" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Bardzo szeroka" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Hasło jest nieprawidłowe." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 5534c2b6ed7..70de48fc293 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" }, @@ -574,7 +577,7 @@ "message": "O item foi enviado para o arquivo" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "O item foi desarquivado" }, "itemUnarchived": { "message": "O item foi desarquivado" @@ -582,8 +585,14 @@ "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." @@ -981,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" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefone" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item apagado para sempre" }, + "archivedItemRestored": { + "message": "Item arquivado restaurado" + }, "restoreItem": { "message": "Restaurar item" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4833,19 +4848,19 @@ "message": "Saiba mais sobre os riscos" }, "autoConfirmSetup": { - "message": "Automatically confirm new users" + "message": "Confirmar automaticamente usuários novos" }, "autoConfirmSetupDesc": { - "message": "New users will be automatically confirmed while this device is unlocked." + "message": "Usuários novos serão confirmados automaticamente quando este dispositivo for desbloqueado." }, "autoConfirmSetupHint": { - "message": "What are the potential security risks?" + "message": "Quais são os possíveis problemas de segurança?" }, "autoConfirmEnabled": { - "message": "Turned on automatic confirmation" + "message": "Ativou a confirmação automática" }, "availableNow": { - "message": "Available now" + "message": "Disponível agora" }, "accountSecurity": { "message": "Segurança da conta" @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Baixar anexo" + }, "downloadBitwarden": { "message": "Baixar o Bitwarden" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra larga" }, + "narrow": { + "message": "Estreita" + }, "sshKeyWrongPassword": { "message": "A senha que você digitou está incorreta." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 8643ac017f6..351ac934091 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" }, @@ -582,8 +585,14 @@ "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." @@ -981,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" }, @@ -1065,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" @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "E-mails" + }, "phone": { "message": "Telefone" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item eliminado permanentemente" }, + "archivedItemRestored": { + "message": "Item arquivado restaurado" + }, "restoreItem": { "message": "Restaurar item" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4040,7 +4052,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", @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Transferir anexo" + }, "downloadBitwarden": { "message": "Descarregar o Bitwarden" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Muito ampla" }, + "narrow": { + "message": "Estreito" + }, "sshKeyWrongPassword": { "message": "A palavra-passe que introduziu está incorreta." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 5cbab8b51b0..ebd8063cc4f 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ă" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "Nu" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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ță." }, @@ -2030,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Articol șters permanent" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restabilire articol" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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ă." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 0ca930ed9f3..a669d338fce 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": "Использовать единый вход" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Архивировать элемент" }, - "archiveItemConfirmDesc": { - "message": "Архивированные элементы исключены из общих результатов поиска и предложений автозаполнения. Вы уверены, что хотите архивировать этот элемент?" + "archiveItemDialogContent": { + "message": "После архивации этот элемент будет исключен из результатов поиска и предложений по автозаполнению." + }, + "archived": { + "message": "Архивирован" + }, + "unarchiveAndSave": { + "message": "Разархивировать и сохранить" }, "upgradeToUseArchive": { "message": "Для использования архива требуется премиум-статус." @@ -981,6 +990,12 @@ "no": { "message": "Нет" }, + "noAuth": { + "message": "Любой, у кого есть ссылка" + }, + "anyOneWithPassword": { + "message": "Любой, у кого есть установленный вами пароль" + }, "location": { "message": "Местоположение" }, @@ -1539,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Проприетарные варианты двухэтапной аутентификации, такие как YubiKey или Duo." }, + "premiumSubscriptionEnded": { + "message": "Ваша подписка Премиум закончилась" + }, + "archivePremiumRestart": { + "message": "Чтобы восстановить доступ к своему архиву, подключите подписку Премиум повторно. Если вы измените сведения об архивированном элементе перед переподключением, он будет перемещен обратно в ваше хранилище." + }, + "restartPremium": { + "message": "Переподключить Премиум" + }, "ppremiumSignUpReports": { "message": "Гигиена паролей, здоровье аккаунта и отчеты об утечках данных для обеспечения безопасности вашего хранилища." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Телефон" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Элемент удален навсегда" }, + "archivedItemRestored": { + "message": "Архивированный элемент восстановлен" + }, "restoreItem": { "message": "Восстановить элемент" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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": "Срок истечения указан некорректно." }, @@ -3349,6 +3355,12 @@ "error": { "message": "Ошибка" }, + "prfUnlockFailed": { + "message": "Не удалось разблокировать с помощью passkey. Пожалуйста, повторите попытку или используйте другой метод разблокировки." + }, + "noPrfCredentialsAvailable": { + "message": "Для разблокировки недоступны passkeys с поддержкой PRF. Пожалуйста, сначала авторизуйтесь, используя passkey." + }, "decryptionError": { "message": "Ошибка расшифровки" }, @@ -4724,6 +4736,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Больше опций" + }, "moreOptionsTitle": { "message": "Больше опций - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4833,19 +4848,19 @@ "message": "Узнайте о рисках" }, "autoConfirmSetup": { - "message": "Automatically confirm new users" + "message": "Автоматически подтверждать новых пользователей" }, "autoConfirmSetupDesc": { - "message": "New users will be automatically confirmed while this device is unlocked." + "message": "Новые пользователи будут автоматически подтверждены при разблокировке устройства." }, "autoConfirmSetupHint": { - "message": "What are the potential security risks?" + "message": "Каковы потенциальные риски для безопасности?" }, "autoConfirmEnabled": { - "message": "Turned on automatic confirmation" + "message": "Включено автоматическое подтверждение" }, "availableNow": { - "message": "Available now" + "message": "Уже доступно" }, "accountSecurity": { "message": "Безопасность аккаунта" @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Скачать вложение" + }, "downloadBitwarden": { "message": "Скачать Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Скрыть обнаружение совпадений $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Показать обнаружение совпадений" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Скрыть обнаружение совпадений" }, "autoFillOnPageLoad": { "message": "Автозаполнение при загрузке страницы?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Очень широкое" }, + "narrow": { + "message": "Узкий" + }, "sshKeyWrongPassword": { "message": "Введенный пароль неверен." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index bed0cd73187..3f721641b6a 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "නැත" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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 නය වාර්තා කරයි." }, @@ -2030,6 +2054,9 @@ "email": { "message": "ඊ-තැපැල්" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "දුරකථන" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "ස්ථිරව මකා දැමූ අයිතමය" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "අයිතමය යළි පිහිටුවන්න" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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": "ලබා දී ඇති කල් ඉකුත්වන දිනය වලංගු නොවේ." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 5f5a1113fef..1528079565a 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" }, @@ -582,8 +585,14 @@ "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." @@ -981,6 +990,12 @@ "no": { "message": "Nie" }, + "noAuth": { + "message": "Ktokoľvek s odkazom" + }, + "anyOneWithPassword": { + "message": "Ktokoľvek s heslom od vás" + }, "location": { "message": "Poloha" }, @@ -1539,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čí." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "E-maily" + }, "phone": { "message": "Telefón" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Položka bola natrvalo odstránená" }, + "archivedItemRestored": { + "message": "Archivovaná položka bola obnovená" + }, "restoreItem": { "message": "Obnoviť položku" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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ý." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Stiahnuť prílohu" + }, "downloadBitwarden": { "message": "Stiahnuť Bitwarden" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra široké" }, + "narrow": { + "message": "Úzke" + }, "sshKeyWrongPassword": { "message": "Zadané heslo je nesprávne." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index bc5ed382df5..e95822d96ea 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "E-pošta" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Element trajno izbrisan" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Obnovi element" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4917,7 +4932,7 @@ "message": "Organization is deactivated" }, "owner": { - "message": "Owner" + "message": "Lastnik" }, "selfOwnershipLabel": { "message": "You", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 48ef707f942..5eef711ea1e 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": "Употребити једнократну пријаву" }, @@ -582,8 +585,14 @@ "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": "Премијум чланство је неопходно за употребу Архиве." @@ -981,6 +990,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Локација" }, @@ -1539,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": "Извештаји о хигијени лозинки, здравственом стању налога и кршењу података да бисте заштитили сеф." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Имејл" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Телефон" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Трајно избрисана ставка" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Врати ставку" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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": "Наведени датум истека није исправан." }, @@ -3349,6 +3355,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": "Грешка при декрипцији" }, @@ -4724,6 +4736,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Више опција - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Преузети Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Сакриј откривање подударања $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Ауто-попуњавање при учитавању странице?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Врло широко" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Лозинка коју сте унели није тачна." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index e208186b408..756b19a81c0 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" }, @@ -582,8 +585,14 @@ "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." @@ -981,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" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "E-post" + }, "phone": { "message": "Telefon" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Raderade objekt permanent" }, + "archivedItemRestored": { + "message": "Arkiverat objekt återställt" + }, "restoreItem": { "message": "Återställ objekt" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Ladda ned bilaga" + }, "downloadBitwarden": { "message": "Ladda ner Bitwarden" }, @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra bred" }, + "narrow": { + "message": "Smal" + }, "sshKeyWrongPassword": { "message": "Lösenordet du har angett är felaktigt." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index 3b1c93584b4..a872eb9fe53 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": "ஒற்றை உள்நுழைவைப் பயன்படுத்தவும்" }, @@ -582,8 +585,14 @@ "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": "காப்பகத்தைப் பயன்படுத்த பிரீமியம் உறுப்பினர் தேவை." @@ -981,6 +990,12 @@ "no": { "message": "இல்லை" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "இருப்பிடம்" }, @@ -1539,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": "உங்கள் வால்ட்டைப் பாதுகாப்பாக வைத்திருக்க கடவுச்சொல் சுகாதாரம், கணக்கின் ஆரோக்கியம் மற்றும் டேட்டா மீறல் அறிக்கைகள்." }, @@ -2030,6 +2054,9 @@ "email": { "message": "மின்னஞ்சல்" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "தொலைபேசி" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "பொருள் நிரந்தரமாக நீக்கப்பட்டது" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "பொருளை மீட்டமை" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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": "வழங்கப்பட்ட காலாவதி தேதி செல்லாது." }, @@ -3349,6 +3355,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": "குறியாக்கம் நீக்கப் பிழை" }, @@ -4724,6 +4736,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "மேலும் விருப்பத்தேர்வுகள் - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Bitwarden-ஐப் பதிவிறக்கு" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "பொருத்தமான கண்டறிதலை மறை $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "பக்கத்தை ஏற்றும்போது தானாக நிரப்ப வேண்டுமா?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "அதிக அகலமானது" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "நீங்கள் உள்ளிடப்பட்ட கடவுச்சொல் தவறானது." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index e0bce7c9224..c6d9d325e00 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" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." + }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -981,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 320d94d746a..19fc6e41ec8 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" }, @@ -582,8 +585,14 @@ "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": "ต้องเป็นสมาชิกพรีเมียมจึงจะใช้งานฟีเจอร์จัดเก็บถาวรได้" @@ -981,6 +990,12 @@ "no": { "message": "ไม่" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "ตำแหน่งที่ตั้ง" }, @@ -1539,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": "รายงานความปลอดภัยของรหัสผ่าน สุขภาพบัญชี และข้อมูลรั่วไหล เพื่อรักษาตู้นิรภัยให้ปลอดภัย" }, @@ -2030,6 +2054,9 @@ "email": { "message": "อีเมล" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "โทรศัพท์" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "ลบรายการถาวรแล้ว" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "กู้คืนรายการ" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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": "วันที่หมดอายุที่ระบุไม่ถูกต้อง" }, @@ -3349,6 +3355,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": "ข้อผิดพลาดในการถอดรหัส" }, @@ -4724,6 +4736,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "ตัวเลือกเพิ่มเติม - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "ดาวน์โหลด Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "ซ่อนการตรวจสอบการจับคู่ $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "ป้อนอัตโนมัติเมื่อโหลดหน้าเว็บหรือไม่" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "กว้างพิเศษ" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "รหัสผ่านที่คุณป้อนไม่ถูกต้อง" }, @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index 35844d74041..92b09280cba 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" }, @@ -582,8 +585,14 @@ "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." @@ -981,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" }, @@ -1539,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ı." }, @@ -2030,6 +2054,9 @@ "email": { "message": "E-posta" }, + "emails": { + "message": "E-postalar" + }, "phone": { "message": "Telefon" }, @@ -2458,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" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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." }, @@ -3349,6 +3355,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" }, @@ -4724,6 +4736,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.", @@ -4818,7 +4833,7 @@ "message": "Yönetici" }, "automaticUserConfirmation": { - "message": "Automatic user confirmation" + "message": "Otomatik kullanıcı onayı" }, "automaticUserConfirmationHint": { "message": "Automatically confirm pending users while this device is unlocked" @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Ek dosyayı indir" + }, "downloadBitwarden": { "message": "Bitwarden’ı indirin" }, @@ -5110,14 +5128,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" @@ -5255,7 +5270,7 @@ "message": "Web sitesi URI'sini yeniden sıralayın. Kayıtı yukarı veya aşağı taşımak için ok tuşunu kullanın." }, "reorderFieldUp": { - "message": "$LABEL$ yukarı taşındı, konum: $LENGTH$'in $INDEX$'i", + "message": "$LABEL$ yukarı taşındı. Konum: $LENGTH$/$INDEX$", "placeholders": { "label": { "content": "$1", @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Ekstra geniş" }, + "narrow": { + "message": "Dar" + }, "sshKeyWrongPassword": { "message": "Girdiğiniz parola yanlış." }, @@ -5707,7 +5725,7 @@ "message": "Vulnerable password." }, "changeNow": { - "message": "Change now" + "message": "Şimdi değiştir" }, "missingWebsite": { "message": "Web sitesi eksik" @@ -5784,7 +5802,7 @@ "message": "Oltalama tespiti hakkında daha fazla bilgi edinin" }, "protectedBy": { - "message": "$PRODUCT$ tarafından korunuyor", + "message": "$PRODUCT$ ile korunuyor", "placeholders": { "product": { "content": "$1", @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 9b5b04dd7ec..9f6b376cbc1 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": "Використати єдиний вхід" }, @@ -574,7 +577,7 @@ "message": "Запис архівовано" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "Запис розархівовано" }, "itemUnarchived": { "message": "Запис розархівовано" @@ -582,14 +585,20 @@ "archiveItem": { "message": "Архівувати запис" }, - "archiveItemConfirmDesc": { - "message": "Архівовані записи виключаються з результатів звичайного пошуку та пропозицій автозаповнення. Ви дійсно хочете архівувати цей запис?" + "archiveItemDialogContent": { + "message": "Після архівації цей запис буде виключено з результатів пошуку і пропозицій автозаповнення." + }, + "archived": { + "message": "Архівовано" + }, + "unarchiveAndSave": { + "message": "Розархівувати й зберегти" }, "upgradeToUseArchive": { "message": "Для використання архіву необхідна передплата Premium." }, "itemRestored": { - "message": "Item has been restored" + "message": "Запис відновлено" }, "edit": { "message": "Змінити" @@ -981,6 +990,12 @@ "no": { "message": "Ні" }, + "noAuth": { + "message": "Будь-хто з посиланням" + }, + "anyOneWithPassword": { + "message": "Будь-хто зі встановленим вами паролем" + }, "location": { "message": "Розташування" }, @@ -1329,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": { @@ -1539,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "Додаткові можливості двоетапної авторизації, як-от YubiKey та Duo." }, + "premiumSubscriptionEnded": { + "message": "Ваша передплата Premium завершилась" + }, + "archivePremiumRestart": { + "message": "Щоб відновити доступ до архіву, поновіть передплату Premium. Якщо ви редагуєте архівований запис перед поновленням, його буде повернуто назад у ваше сховище." + }, + "restartPremium": { + "message": "Поновити Premium" + }, "ppremiumSignUpReports": { "message": "Гігієна паролів, здоров'я облікового запису, а також звіти про вразливості даних, щоб зберігати ваше сховище в безпеці." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Е-пошта" }, + "emails": { + "message": "Е-пошти" + }, "phone": { "message": "Телефон" }, @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "Запис остаточно видалено" }, + "archivedItemRestored": { + "message": "Архівований запис відновлено" + }, "restoreItem": { "message": "Відновити запис" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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": "Вказано недійсний термін дії." }, @@ -3349,6 +3355,12 @@ "error": { "message": "Помилка" }, + "prfUnlockFailed": { + "message": "Не вдалося розблокувати за допомогою ключа доступу. Повторіть спробу або скористайтеся іншим способом розблокування." + }, + "noPrfCredentialsAvailable": { + "message": "Немає ключів доступу з підтримкою PRF, доступних для розблокування. Спочатку увійдіть з ключем доступу." + }, "decryptionError": { "message": "Помилка розшифрування" }, @@ -4724,6 +4736,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Більше опцій" + }, "moreOptionsTitle": { "message": "Інші можливості – $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4815,37 +4830,37 @@ "message": "консолі адміністратора," }, "admin": { - "message": "Admin" + "message": "Адміністратор" }, "automaticUserConfirmation": { - "message": "Automatic user confirmation" + "message": "Автоматичне підтвердження користувачів" }, "automaticUserConfirmationHint": { - "message": "Automatically confirm pending users while this device is unlocked" + "message": "Автоматично підтверджувати користувачів, які перебувають у черзі, поки цей пристрій розблокований" }, "autoConfirmOnboardingCallout": { - "message": "Save time with automatic user confirmation" + "message": "Заощаджуйте час завдяки автоматичному підтвердженню користувачів" }, "autoConfirmWarning": { - "message": "This could impact your organization’s data security. " + "message": "Це може вплинути на безпеку даних вашої організації. " }, "autoConfirmWarningLink": { - "message": "Learn about the risks" + "message": "Дізнатися про ризики" }, "autoConfirmSetup": { - "message": "Automatically confirm new users" + "message": "Автоматично підтверджувати нових користувачів" }, "autoConfirmSetupDesc": { - "message": "New users will be automatically confirmed while this device is unlocked." + "message": "Нові користувачі будуть автоматично підтверджені, якщо пристрій розблоковано." }, "autoConfirmSetupHint": { - "message": "What are the potential security risks?" + "message": "Які потенційні ризики безпеки?" }, "autoConfirmEnabled": { - "message": "Turned on automatic confirmation" + "message": "Автоматичне підтвердження увімкнено" }, "availableNow": { - "message": "Available now" + "message": "Доступно зараз" }, "accountSecurity": { "message": "Безпека облікового запису" @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Завантажити вкладення" + }, "downloadBitwarden": { "message": "Завантажити Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "Приховати виявлення збігів $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Показати виявлення збігів" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Приховати виявлення збігів" }, "autoFillOnPageLoad": { "message": "Автоматично заповнювати під час завантаження сторінки?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Дуже широке" }, + "narrow": { + "message": "Вузький" + }, "sshKeyWrongPassword": { "message": "Ви ввели неправильний пароль." }, @@ -5704,10 +5722,10 @@ "message": "Цей запис ризикований, і не має адреси вебсайту. Додайте адресу вебсайту і змініть пароль для вдосконалення безпеки." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Вразливий пароль." }, "changeNow": { - "message": "Change now" + "message": "Змінити зараз" }, "missingWebsite": { "message": "Немає вебсайту" @@ -6085,7 +6103,35 @@ "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" + }, + "emailProtected": { + "message": "Е-пошту захищено" + }, + "sendPasswordHelperText": { + "message": "Особам необхідно ввести пароль для перегляду цього відправлення", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index 83e8af63982..ad03e96537a 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:" @@ -574,7 +577,7 @@ "message": "Mục đã được chuyển vào kho lưu trữ" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "Mục đã được bỏ lưu trữ" }, "itemUnarchived": { "message": "Mục đã được bỏ lưu trữ" @@ -582,14 +585,20 @@ "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" @@ -981,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í" }, @@ -1329,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": { @@ -1539,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." }, @@ -2030,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Số điện thoại" }, @@ -2458,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" }, @@ -3005,10 +3035,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." @@ -3068,29 +3094,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ệ." }, @@ -3349,6 +3355,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ã" }, @@ -4724,6 +4736,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.", @@ -4815,49 +4830,49 @@ "message": "Bảng điều khiển dành cho quản trị viên" }, "admin": { - "message": "Admin" + "message": "Quản trị" }, "automaticUserConfirmation": { - "message": "Automatic user confirmation" + "message": "Tự động xác nhận người dùng" }, "automaticUserConfirmationHint": { - "message": "Automatically confirm pending users while this device is unlocked" + "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": "Save time with automatic user confirmation" + "message": "Tiết kiệm thời gian với xác nhận người dùng tự động" }, "autoConfirmWarning": { - "message": "This could impact your organization’s data security. " + "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": "Learn about the risks" + "message": "Tìm hiểu về các rủi ro" }, "autoConfirmSetup": { - "message": "Automatically confirm new users" + "message": "Tự động xác nhận người dùng mới" }, "autoConfirmSetupDesc": { - "message": "New users will be automatically confirmed while this device is unlocked." + "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": "What are the potential security risks?" + "message": "Những rủi ro bảo mật tiềm ẩn là gì?" }, "autoConfirmEnabled": { - "message": "Turned on automatic confirmation" + "message": "Đã bật xác nhận tự động" }, "availableNow": { - "message": "Available now" + "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" @@ -4971,11 +4986,14 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Tải xuống Bitwarden" }, "downloadBitwardenOnAllDevices": { - "message": "Tải xuống Bitwarden trên tất 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" @@ -5110,14 +5128,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?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "Rộng hơn" }, + "narrow": { + "message": "Hẹp" + }, "sshKeyWrongPassword": { "message": "Mật khẩu bạn đã nhập không đúng." }, @@ -5704,10 +5722,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" @@ -5949,43 +5967,43 @@ "message": "Số thẻ" }, "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" @@ -6085,7 +6103,35 @@ "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" + }, + "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." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 756b3a995c3..d9b78ca0d50 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": "使用单点登录" }, @@ -582,8 +585,14 @@ "archiveItem": { "message": "归档项目" }, - "archiveItemConfirmDesc": { - "message": "已归档的项目将被排除在一般搜索结果和自动填充建议之外。确定要归档此项目吗?" + "archiveItemDialogContent": { + "message": "归档后,此项目将被排除在一般搜索结果和自动填充建议之外。" + }, + "archived": { + "message": "已归档" + }, + "unarchiveAndSave": { + "message": "取消归档并保存" }, "upgradeToUseArchive": { "message": "需要高级会员才能使用归档。" @@ -981,6 +990,12 @@ "no": { "message": "否" }, + "noAuth": { + "message": "拥有此链接的任何人" + }, + "anyOneWithPassword": { + "message": "拥有您设置的密码的任何人" + }, "location": { "message": "位置" }, @@ -1539,6 +1554,15 @@ "premiumSignUpTwoStepOptions": { "message": "专有的两步登录选项,如 YubiKey 和 Duo。" }, + "premiumSubscriptionEnded": { + "message": "您的高级版订阅已结束" + }, + "archivePremiumRestart": { + "message": "要重新获取归档内容的访问权限,请重启您的高级版订阅。如果您在重启前编辑了某个已归档项目的详细信息,它将被移回您的密码库中。" + }, + "restartPremium": { + "message": "重启高级版" + }, "ppremiumSignUpReports": { "message": "密码健康、账户体检以及数据泄露报告,保障您的密码库安全。" }, @@ -1828,7 +1852,7 @@ "message": "网页加载时如果检测到登录表单,则执行自动填充。" }, "experimentalFeature": { - "message": "不完整或不信任的网站可以利用页面加载时的自动填充功能。" + "message": "被攻破或不受信任的网站可能会利用页面加载时的自动填充功能。" }, "learnMoreAboutAutofillOnPageLoadLinkText": { "message": "进一步了解风险" @@ -2030,6 +2054,9 @@ "email": { "message": "电子邮箱" }, + "emails": { + "message": "电子邮箱" + }, "phone": { "message": "电话" }, @@ -2225,7 +2252,7 @@ } }, "passwordSafe": { - "message": "没有在已知的数据泄露中发现此密码,它暂时比较安全。" + "message": "在任何已知的数据泄露中均未发现此密码。它暂时比较安全。" }, "baseDomain": { "message": "基础域名", @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "项目已永久删除" }, + "archivedItemRestored": { + "message": "归档项目已恢复" + }, "restoreItem": { "message": "恢复项目" }, @@ -2815,7 +2845,7 @@ } }, "changeAtRiskPasswordsFaster": { - "message": "尽快更改有风险的密码" + "message": "尽快更改存在风险的密码" }, "changeAtRiskPasswordsFasterDesc": { "message": "更新您的设置,以便您可以快速自动填充密码并生成新的密码" @@ -2884,7 +2914,7 @@ "message": "排除域名更改已保存" }, "limitSendViews": { - "message": "查看次数限制" + "message": "限制查看次数" }, "limitSendViewsHint": { "message": "达到限额后,任何人无法查看此 Send。", @@ -2973,7 +3003,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": { @@ -3005,10 +3035,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." @@ -3033,11 +3059,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": { @@ -3047,11 +3073,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": { @@ -3068,29 +3094,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": "所提供的过期日期无效。" }, @@ -3137,10 +3143,10 @@ "message": "更新主密码" }, "updateMasterPasswordWarning": { - "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,您必须立即更新它。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,您必须立即更新它。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "updateWeakMasterPasswordWarning": { - "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "tdeDisabledMasterPasswordRequired": { "message": "您的组织禁用了信任设备加密。要访问您的密码库,请设置一个主密码。" @@ -3349,6 +3355,12 @@ "error": { "message": "错误" }, + "prfUnlockFailed": { + "message": "使用通行密钥解锁失败。请重试或使用其他解锁方式。" + }, + "noPrfCredentialsAvailable": { + "message": "没有可用于解锁的 PRF 通行密钥。请先使用通行密钥登录。" + }, "decryptionError": { "message": "解密错误" }, @@ -3363,7 +3375,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": { @@ -4724,6 +4736,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "更多选项" + }, "moreOptionsTitle": { "message": "更多选项 - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4839,7 +4854,7 @@ "message": "当此设备已解锁时,新用户将被自动确认。" }, "autoConfirmSetupHint": { - "message": "有哪些潜在的安全风险?" + "message": "潜在的安全风险有哪些?" }, "autoConfirmEnabled": { "message": "启用了自动确认" @@ -4927,7 +4942,7 @@ "message": "无法访问已停用组织中的项目。请联系您的组织所有者寻求帮助。" }, "additionalInformation": { - "message": "更多信息" + "message": "附加信息" }, "itemHistory": { "message": "项目历史记录" @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "下载附件" + }, "downloadBitwarden": { "message": "下载 Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "隐藏匹配检测 $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "显示匹配检测" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "隐藏匹配检测" }, "autoFillOnPageLoad": { "message": "页面加载时自动填充吗?" @@ -5207,7 +5222,7 @@ "message": "如果您想自动勾选表单复选框(例如记住电子邮箱),请使用复选框型字段" }, "linkedHelpText": { - "message": "当您处理特定网站的自动填充问题时,请使用链接型字段" + "message": "当您遇到特定网站的自动填充问题时,请使用链接型字段。" }, "linkedLabelHelpText": { "message": "输入字段的 html id、名称、aria-label 或占位符。" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "超宽" }, + "narrow": { + "message": "窄" + }, "sshKeyWrongPassword": { "message": "您输入的密码不正确。" }, @@ -5698,7 +5716,7 @@ "message": "要使用生物识别解锁,请更新您的桌面应用程序,或在桌面设置中禁用指纹解锁。" }, "changeAtRiskPassword": { - "message": "更改有风险的密码" + "message": "更改存在风险的密码" }, "changeAtRiskPasswordAndAddWebsite": { "message": "此登录存在风险且缺少网站。请添加网站并更改密码以增强安全性。" @@ -6085,7 +6103,35 @@ "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" + }, + "emailProtected": { + "message": "电子邮件受保护" + }, + "sendPasswordHelperText": { + "message": "个人需要输入密码才能查看此 Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index bcbc4db394e..eade6878396 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -6,7 +6,7 @@ "message": "Bitwarden logo" }, "extName": { - "message": "Bitwarden 密碼管理器", + "message": "Bitwarden 密碼管理工具", "description": "Extension name, MUST be less than 40 characters (Safari restriction)" }, "extDesc": { @@ -14,7 +14,7 @@ "description": "Extension description, MUST be less than 112 characters (Safari restriction)" }, "loginOrCreateNewAccount": { - "message": "登入或建立帳戶以存取您的安全密碼庫。" + "message": "登入或建立新帳戶以存取您的安全密碼庫。" }, "inviteAccepted": { "message": "邀請已接受" @@ -26,10 +26,13 @@ "message": "第一次使用 Bitwarden?" }, "logInWithPasskey": { - "message": "使用密碼金鑰登入" + "message": "使用通行金鑰登入" + }, + "unlockWithPasskey": { + "message": "使用通行金鑰解鎖" }, "useSingleSignOn": { - "message": "使用單一登入" + "message": "使用單一登入(SSO)" }, "yourOrganizationRequiresSingleSignOn": { "message": "您的組織需要單一登入。" @@ -38,13 +41,13 @@ "message": "歡迎回來" }, "setAStrongPassword": { - "message": "設定一個強密碼" + "message": "設定一組高強度密碼" }, "finishCreatingYourAccountBySettingAPassword": { "message": "設定密碼以完成建立您的帳號" }, "enterpriseSingleSignOn": { - "message": "企業單一登入" + "message": "企業單一登入(SSO)" }, "cancel": { "message": "取消" @@ -62,10 +65,10 @@ "message": "主密碼" }, "masterPassDesc": { - "message": "主密碼是用於存取密碼庫的密碼。它非常重要,請您不要忘記它。若您忘記了主密碼,沒有任何方法能將其復原。" + "message": "主密碼是用來存取您的密碼庫的重要密碼,請務必妥善記住。一旦忘記,將無法復原。" }, "masterPassHintDesc": { - "message": "主密碼提示可以在您忘記主密碼時幫助您回憶主密碼。" + "message": "主密碼提示可在您忘記時幫助您回想主密碼。" }, "masterPassHintText": { "message": "如果您忘記了密碼,可以傳送密碼提示到您的電子郵件。$CURRENT$ / 最多 $MAXIMUM$ 個字元", @@ -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": "解鎖您的密碼庫" @@ -261,10 +264,10 @@ "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": "避免易混淆的字元", @@ -582,8 +585,14 @@ "archiveItem": { "message": "封存項目" }, - "archiveItemConfirmDesc": { - "message": "封存的項目將不會出現在一般搜尋結果或自動填入建議中。確定要封存此項目嗎?" + "archiveItemDialogContent": { + "message": "封存後,此項目將不會顯示在搜尋結果與自動填入建議中。" + }, + "archived": { + "message": "已封存" + }, + "unarchiveAndSave": { + "message": "取消封存並儲存" }, "upgradeToUseArchive": { "message": "需要進階版會員才能使用封存功能。" @@ -610,7 +619,7 @@ "message": "檢視登入" }, "noItemsInList": { - "message": "沒有可列出的項目。" + "message": "目前沒有可列出的項目。" }, "itemInformation": { "message": "項目資訊" @@ -679,7 +688,7 @@ "message": "網站" }, "toggleVisibility": { - "message": "切換可見性" + "message": "切換顯示狀態" }, "manage": { "message": "管理" @@ -706,10 +715,10 @@ "message": "其它選項" }, "rateExtension": { - "message": "為本套件評分" + "message": "評分此擴充功能" }, "browserNotSupportClipboard": { - "message": "您的瀏覽器不支援剪貼簿簡單複製,請手動複製。" + "message": "您的瀏覽器不支援剪貼簿複製功能,請手動複製。" }, "verifyYourIdentity": { "message": "驗證您的身份" @@ -736,7 +745,7 @@ "message": "解鎖" }, "loggedInAsOn": { - "message": "已在 $HOSTNAME$ 以 $EMAIL$ 身份登入。", + "message": "已在 $HOSTNAME$ 上以 $EMAIL$ 登入。", "placeholders": { "email": { "content": "$1", @@ -749,7 +758,7 @@ } }, "invalidMasterPassword": { - "message": "無效的主密碼" + "message": "主密碼不正確" }, "invalidMasterPasswordConfirmEmailAndHost": { "message": "主密碼無效。請確認你的電子郵件正確,且帳號是於 $HOST$ 建立的。", @@ -806,7 +815,7 @@ "message": "4 小時" }, "onLocked": { - "message": "於系統鎖定時" + "message": "系統鎖定時" }, "onIdle": { "message": "系統閒置時" @@ -815,7 +824,7 @@ "message": "系統睡眠時" }, "onRestart": { - "message": "於瀏覽器重新啟動時" + "message": "瀏覽器重新啟動時" }, "never": { "message": "永不" @@ -839,10 +848,10 @@ "message": "發生錯誤" }, "emailRequired": { - "message": "必須填入電子郵件地址 。" + "message": "必須填入電子郵件地址。" }, "invalidEmail": { - "message": "無效的電子郵件地址。" + "message": "電子郵件地址無效。" }, "masterPasswordRequired": { "message": "必須填入主密碼。" @@ -888,7 +897,7 @@ "message": "驗證已被取消或時間超過。請再試一次。" }, "invalidVerificationCode": { - "message": "無效的驗證碼" + "message": "驗證碼無效" }, "valueCopied": { "message": "$VALUE$ 已複製", @@ -934,7 +943,7 @@ "message": "您已經登出了您的帳號。" }, "loginExpired": { - "message": "您的登入階段已過期。" + "message": "您的登入工作階段已逾時。" }, "logIn": { "message": "登入" @@ -981,6 +990,12 @@ "no": { "message": "否" }, + "noAuth": { + "message": "任何持有連結的人" + }, + "anyOneWithPassword": { + "message": "任何持有您設定之密碼的人" + }, "location": { "message": "位置" }, @@ -994,7 +1009,7 @@ "message": "資料夾已新增" }, "twoStepLoginConfirmation": { - "message": "兩步驟登入需要您從其他裝置(例如安全鑰匙、驗證器程式、SMS、手機或電子郵件)來驗證您的登入,這使您的帳戶更加安全。兩步驟登入可以在 bitwarden.com 網頁版密碼庫啟用。現在要前往嗎?" + "message": "兩步驟登入會要求您透過另一個裝置驗證登入,例如安全金鑰、驗證器應用程式、簡訊、電話或電子郵件,藉此提升帳戶安全性。您可以在 bitwarden.com 的網頁密碼庫中設定此功能。是否要現在前往?" }, "twoStepLoginConfirmationContent": { "message": "在 Bitwarden 網頁應用程式中設定兩步驟登入,讓您的帳號更加安全。" @@ -1015,7 +1030,7 @@ "message": "新手教學" }, "gettingStartedTutorialVideo": { - "message": "觀看我們的新手教學,了解如何充分利用瀏覽器擴充套件。" + "message": "觀看新手教學,快速掌握瀏覽器擴充套件的完整用法。" }, "syncingComplete": { "message": "同步完成" @@ -1024,7 +1039,7 @@ "message": "同步失敗" }, "passwordCopied": { - "message": "已複製密碼" + "message": "密碼已複製" }, "uri": { "message": "URI" @@ -1065,7 +1080,7 @@ } }, "deleteItemConfirmation": { - "message": "確定要刪除此項目嗎?" + "message": "確定要移至垃圾桶嗎?" }, "deletedItem": { "message": "項目已移至垃圾桶" @@ -1092,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": "點選自動填入建議中的項目進行填入" @@ -1136,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": "儲存" @@ -1263,10 +1278,10 @@ "message": "變更你的主密碼以完成帳號復原。" }, "enableChangedPasswordNotification": { - "message": "詢問更新現有的登入資料" + "message": "詢問是否更新現有的登入資料" }, "changedPasswordNotificationDesc": { - "message": "偵測到網站密碼變更時,詢問是否更新登入資料密碼。" + "message": "偵測到網站密碼變更時,詢問是否更新密碼。" }, "changedPasswordNotificationDescAlt": { "message": "當偵測到網站上的變更時,詢問是否更新登入的密碼。適用於所有已登入的帳戶。" @@ -1302,7 +1317,7 @@ "message": "使用右鍵點選來存取密碼產生和網站的符合登入資訊。適用於所有已登入的帳戶。" }, "defaultUriMatchDetection": { - "message": "預設的 URI 一致性偵測", + "message": "預設 URI 比對方式", "description": "Default URI match detection for autofill." }, "defaultUriMatchDetectionDesc": { @@ -1312,7 +1327,7 @@ "message": "主題" }, "themeDesc": { - "message": "變更應用程式的主題色彩。" + "message": "變更應用程式的色彩主題。" }, "themeDescAlt": { "message": "變更應用程式的主題色彩。適用於所有已登入的帳戶。" @@ -1383,16 +1398,16 @@ "message": "確認匯出密碼庫" }, "exportWarningDesc": { - "message": "此次匯出的密碼庫資料為未加密格式。您不應將它存放或經由不安全的方式(例如電子郵件)傳送。用完後請立即將它刪除。" + "message": "此匯出檔包含未加密的密碼庫資料。請勿透過不安全的管道(如電子郵件)儲存或傳送此檔案。使用完畢後,請務必立即刪除。" }, "encExportKeyWarningDesc": { - "message": "將使用您帳戶的加密金鑰來加密匯出的資料,若您更新了帳戶的加密金鑰,請重新匯出,否則將無法解密匯出的檔案。" + "message": "此匯出檔會使用您帳戶的加密金鑰進行加密。若您日後更換了帳戶的加密金鑰,請務必重新匯出,否則將無法解密此檔案。" }, "encExportAccountWarningDesc": { - "message": "每個 Bitwarden 使用者帳戶的帳戶加密金鑰都不相同,因此無法將已加密匯出的檔案匯入至不同帳戶中。" + "message": "帳戶加密金鑰專屬於每個 Bitwarden 使用者帳戶,因此您無法將加密匯出檔匯入至其他帳戶。" }, "exportMasterPassword": { - "message": "輸入您的主密碼以匯出密碼庫資料。" + "message": "請輸入主密碼以匯出密碼庫資料。" }, "shared": { "message": "已共用" @@ -1404,7 +1419,7 @@ "message": "移動至組織 " }, "movedItemToOrg": { - "message": "已將 $ITEMNAME$ 移動至 $ORGNAME$", + "message": "已將 $ITEMNAME$ 移至 $ORGNAME$", "placeholders": { "itemname": { "content": "$1", @@ -1417,7 +1432,7 @@ } }, "moveToOrgDesc": { - "message": "選擇您希望將這個項目移動至哪個組織。項目的擁有權將會轉移至該組織。轉移之後,您將不再是此項目的直接擁有者。" + "message": "選擇要將此項目移至的組織。將項目移至組織後,該項目的所有權將轉移至該組織。完成移動後,您將不再是此項目的直接擁有者。" }, "learnMore": { "message": "深入了解" @@ -1444,10 +1459,10 @@ "message": "以後再說" }, "authenticatorKeyTotp": { - "message": "驗證器金鑰 (TOTP)" + "message": "驗證器金鑰(TOTP)" }, "verificationCodeTotp": { - "message": "驗證碼 (TOTP)" + "message": "驗證碼(TOTP)" }, "copyVerificationCode": { "message": "複製驗證碼" @@ -1495,10 +1510,10 @@ "message": "項目已轉移" }, "maxFileSize": { - "message": "檔案最大為 500MB。" + "message": "檔案大小上限為 500 MB。" }, "featureUnavailable": { - "message": "功能不可用" + "message": "功能無法使用" }, "legacyEncryptionUnsupported": { "message": "不再支援舊版加密。請聯繫支援團隊以恢復您的帳號。" @@ -1510,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$ 加密儲存空間。", @@ -1539,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": "優先客戶支援。" @@ -1585,7 +1609,7 @@ } }, "refreshComplete": { - "message": "狀態更新完成" + "message": "重新整理完成" }, "enableAutoTotpCopy": { "message": "自動複製 TOTP" @@ -1594,7 +1618,7 @@ "message": "若登入資訊已包含驗證器金鑰,在自動填入登入資訊時,也會同步為您複製 TOTP 驗證碼。" }, "enableAutoBiometricsPrompt": { - "message": "啟動時要求生物特徵辨識" + "message": "啟動時要求進行生物辨識" }, "authenticationTimeout": { "message": "驗證逾時" @@ -1622,7 +1646,7 @@ "message": "使用您的復原碼" }, "insertU2f": { - "message": "將您的安全鑰匙插入電腦的 USB 連接埠,然後觸摸其按鈕(如有的話)。" + "message": "請將安全金鑰插入電腦的 USB 連接埠。若金鑰上有按鈕,請輕觸一下。" }, "openInNewTab": { "message": "在新分頁中開啟" @@ -1649,10 +1673,10 @@ "message": "登入無法使用" }, "noTwoStepProviders": { - "message": "此帳戶已設定兩步驟登入,但是本瀏覽器不支援已設定的任一個兩步驟提供程式。" + "message": "此帳戶已設定兩步驟登入,但本瀏覽器不支援任何已設定的兩步驟驗證方式。" }, "noTwoStepProviders2": { - "message": "請使用受支援的瀏覽器(例如 Chrome),及/或新增可以更好地支援跨瀏覽器的提供程式(例如驗證器應用程式)。" + "message": "請使用受支援的網頁瀏覽器(例如 Chrome),或新增跨瀏覽器支援度較佳的驗證方式(例如驗證器應用程式)。" }, "twoStepOptions": { "message": "兩步驟登入選項" @@ -1661,7 +1685,7 @@ "message": "選擇兩步驟登入方法" }, "recoveryCodeTitle": { - "message": "復原代碼" + "message": "復原碼" }, "authenticatorAppTitle": { "message": "驗證器應用程式" @@ -1674,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": "電子郵件" @@ -1700,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。" @@ -1718,7 +1742,7 @@ "message": "伺服器 URL" }, "selfHostBaseUrl": { - "message": "自架伺服器 URL", + "message": "自行部署伺服器 URL", "description": "Label for field requesting a self-hosted integration service URL" }, "apiUrl": { @@ -1852,7 +1876,7 @@ "message": "不要在頁面載入時自動填入" }, "commandOpenPopup": { - "message": "在彈出式視窗中開啟密碼庫" + "message": "開啟密碼庫彈出視窗" }, "commandOpenSidebar": { "message": "在側邊欄中開啟密碼庫" @@ -1867,7 +1891,7 @@ "message": "自動將上次使用的身分資料填入目前網站" }, "commandGeneratePasswordDesc": { - "message": "產生一組新的隨機密碼並將它複製到剪貼簿中。" + "message": "產生新的隨機密碼並複製到剪貼簿" }, "commandLockVaultDesc": { "message": "鎖定密碼庫" @@ -1885,7 +1909,7 @@ "message": "新增自訂欄位" }, "dragToSort": { - "message": "透過拖曳來排序" + "message": "拖曳以排序" }, "dragToReorder": { "message": "拖曳以重新排序" @@ -1907,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": "顯示網站圖示並取得變更密碼網址" @@ -1920,22 +1944,22 @@ "message": "持卡人姓名" }, "number": { - "message": "號碼" + "message": "卡號" }, "brand": { "message": "發卡組織" }, "expirationMonth": { - "message": "逾期月份" + "message": "到期月份" }, "expirationYear": { - "message": "逾期年份" + "message": "到期年份" }, "monthly": { "message": "月" }, "expiration": { - "message": "逾期" + "message": "有效期限" }, "january": { "message": "一月" @@ -1974,7 +1998,7 @@ "message": "十二月" }, "securityCode": { - "message": "安全代碼" + "message": "安全碼" }, "cardNumber": { "message": "信用卡號碼" @@ -1983,7 +2007,7 @@ "message": "例如" }, "title": { - "message": "稱呼" + "message": "稱謂" }, "mr": { "message": "Mr" @@ -2013,23 +2037,26 @@ "message": "全名" }, "identityName": { - "message": "身份名稱" + "message": "身分名稱" }, "company": { "message": "公司" }, "ssn": { - "message": "社會保險號碼" + "message": "社會安全號碼" }, "passportNumber": { "message": "護照號碼" }, "licenseNumber": { - "message": "許可證號碼" + "message": "駕照號碼" }, "email": { "message": "電子郵件" }, + "emails": { + "message": "電子郵件" + }, "phone": { "message": "電話號碼" }, @@ -2037,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": "郵遞區號" @@ -2061,7 +2088,7 @@ "message": "類型" }, "typeLogin": { - "message": "登入" + "message": "登入資料" }, "typeLogins": { "message": "登入資料" @@ -2070,7 +2097,7 @@ "message": "安全筆記" }, "typeCard": { - "message": "支付卡" + "message": "付款卡" }, "typeIdentity": { "message": "身分" @@ -2086,7 +2113,7 @@ "description": "Header for new login item type" }, "newItemHeaderCard": { - "message": "新增支付卡", + "message": "新增付款卡", "description": "Header for new card item type" }, "newItemHeaderIdentity": { @@ -2114,7 +2141,7 @@ "description": "Header for edit login item type" }, "editItemHeaderCard": { - "message": "編輯支付卡", + "message": "編輯付款卡", "description": "Header for edit card item type" }, "editItemHeaderIdentity": { @@ -2142,7 +2169,7 @@ "description": "Header for view login item type" }, "viewItemHeaderCard": { - "message": "檢視支付卡", + "message": "檢視付款卡", "description": "Header for view card item type" }, "viewItemHeaderIdentity": { @@ -2188,13 +2215,13 @@ "message": "我的最愛" }, "popOutNewWindow": { - "message": "彈出至新視窗" + "message": "在新視窗中開啟" }, "refresh": { "message": "重新整理" }, "cards": { - "message": "支付卡" + "message": "付款卡" }, "identities": { "message": "身分" @@ -2213,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", @@ -2225,10 +2252,10 @@ } }, "passwordSafe": { - "message": "任何已知的外洩密碼資料庫中都沒有此密碼,它目前是安全的。" + "message": "在任何已知的資料外洩事件中皆未發現此密碼,應可安全使用。" }, "baseDomain": { - "message": "基底網域", + "message": "基礎網域", "description": "Domain name. Ex. website.com" }, "baseDomainOptionRecommended": { @@ -2254,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": { @@ -2283,7 +2310,7 @@ "message": "所有項目" }, "noPasswordsInList": { - "message": "沒有可列出的密碼。" + "message": "沒有可顯示的密碼。" }, "clearHistory": { "message": "清除歷史紀錄" @@ -2313,16 +2340,16 @@ "description": "ex. Date this password was updated" }, "neverLockWarning": { - "message": "您確定要使用「永不」選項嗎?將鎖定選項設定為「永不」會將密碼庫的加密金鑰儲存在您的裝置上。如果使用此選項,應確保您的裝置是安全的。" + "message": "您確定要使用「永不」選項嗎?將鎖定選項設為「永不」會把密碼庫的加密金鑰儲存在您的裝置上。若您選擇此選項,請務必確保您的裝置受到妥善保護。" }, "noOrganizationsList": { - "message": "您沒有加入任何組織。組織允許您與其他使用者安全地共用項目。" + "message": "您目前未加入任何組織。組織可讓您與其他使用者安全地共用項目。" }, "noCollectionsInList": { "message": "沒有可顯示的集合。" }, "ownership": { - "message": "擁有權" + "message": "所有權" }, "whoOwnsThisItem": { "message": "誰擁有這個項目?" @@ -2340,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": { @@ -2359,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": "密碼產生器" @@ -2458,6 +2485,9 @@ "permanentlyDeletedItem": { "message": "項目已永久刪除" }, + "archivedItemRestored": { + "message": "已還原封存項目" + }, "restoreItem": { "message": "還原項目" }, @@ -2468,7 +2498,7 @@ "message": "已經有帳號了嗎?" }, "vaultTimeoutLogOutConfirmation": { - "message": "選擇登出將會在密碼庫逾時後移除對密碼庫的所有存取權限,若要重新驗證則需連線網路。確定要使用此設定嗎?" + "message": "登出後將移除對密碼庫的所有存取權限,並在逾時後需要進行線上驗證。您確定要使用此設定嗎?" }, "vaultTimeoutLogOutConfirmationTitle": { "message": "逾時動作確認" @@ -2486,7 +2516,7 @@ "message": "項目已自動填入 " }, "insecurePageWarning": { - "message": "警告:這是不安全的 HTTP 頁面,任何您送出的資訊均可能被其他人看見和更改。此登入資訊原先是在安全的 (HTTPS) 頁面儲存的。" + "message": "警告:此為不安全的 HTTP 頁面,您送出的任何資訊都可能被他人查看並修改。此登入資料原本儲存在安全的(HTTPS)頁面上。" }, "insecurePageWarningFillPrompt": { "message": "您仍要填入此登入資訊嗎?" @@ -2513,13 +2543,13 @@ "message": "目前的主密碼" }, "newMasterPass": { - "message": "新的主密碼" + "message": "新主密碼" }, "confirmNewMasterPass": { - "message": "確認新的主密碼" + "message": "確認新主密碼" }, "masterPasswordPolicyInEffect": { - "message": "一個或多個組織原則要求您的主密碼須符合下列條件:" + "message": "一或多個組織的政策要求您的主密碼必須符合以下條件:" }, "policyInEffectMinComplexity": { "message": "最小複雜度為 $SCORE$", @@ -2531,7 +2561,7 @@ } }, "policyInEffectMinLength": { - "message": "最小長度為 $LENGTH$", + "message": "最短長度為 $LENGTH$", "placeholders": { "length": { "content": "$1", @@ -2558,7 +2588,7 @@ } }, "masterPasswordPolicyRequirementsNotMet": { - "message": "您新的主密碼不符合原則要求。" + "message": "您的新主密碼未符合政策要求。" }, "receiveMarketingEmailsV2": { "message": "獲得來自 Bitwarden 的公告、建議及研究資訊電子郵件。" @@ -2576,10 +2606,10 @@ "message": "和" }, "acceptPolicies": { - "message": "選中此選取框,即表示您同意下列條款:" + "message": "勾選此核取方塊即表示您同意以下內容:" }, "acceptPoliciesRequired": { - "message": "尚未接受服務條款與隱私權政策。" + "message": "尚未同意服務條款與隱私權政策。" }, "termsOfService": { "message": "服務條款" @@ -2591,7 +2621,7 @@ "message": "你的新密碼不能與目前的密碼相同。" }, "hintEqualsPassword": { - "message": "密碼提示不能與您的密碼相同。" + "message": "密碼提示不可與密碼相同。" }, "ok": { "message": "確定" @@ -2603,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": "無法啟用生物特徵辨識" @@ -2633,7 +2663,7 @@ "message": "桌面通訊已中斷" }, "nativeMessagingWrongUserDesc": { - "message": "桌面應用程式登入了不同的帳戶。請確保兩個應用程式登入的是同一個帳戶。" + "message": "桌面應用程式目前登入的是不同的帳戶。請確認兩個應用程式皆登入相同的帳戶。" }, "nativeMessagingWrongUserTitle": { "message": "帳戶不相符" @@ -2645,16 +2675,16 @@ "message": "生物辨識解鎖失敗。生物辨識金鑰解鎖密碼庫失敗。請嘗試重新設定生物辨識。" }, "biometricsNotEnabledTitle": { - "message": "生物特徵辨識未設定" + "message": "尚未設定生物辨識" }, "biometricsNotEnabledDesc": { - "message": "需先在桌面應用程式設定中設定生物特徵辨識,才能使用瀏覽器的生物特徵辨識功能。" + "message": "瀏覽器生物辨識功能需要先在設定中完成桌面應用程式的生物辨識設定。" }, "biometricsNotSupportedTitle": { - "message": "不支援生物特徵辨識" + "message": "不支援生物辨識" }, "biometricsNotSupportedDesc": { - "message": "此裝置不支援瀏覽器生物特徵辨識。" + "message": "此裝置不支援瀏覽器生物辨識。" }, "biometricsNotUnlockedTitle": { "message": "使用者已鎖定或登出" @@ -2678,19 +2708,19 @@ "message": "未提供權限" }, "nativeMessaginPermissionErrorDesc": { - "message": "沒有與 Bitwarden 桌面應用程式通訊的權限,我們無法在瀏覽器擴充套件中提供生物特徵辨識功能。請再試一次。" + "message": "若未取得與 Bitwarden 桌面應用程式通訊的權限,無法在瀏覽器擴充套件中提供生物辨識。請再試一次。" }, "nativeMessaginPermissionSidebarTitle": { "message": "權限要求錯誤" }, "nativeMessaginPermissionSidebarDesc": { - "message": "此動作無法在側邊欄中完成,請在彈出式視窗中再試一次。" + "message": "此動作無法在側邊欄中執行,請在快顯視窗或彈出視窗中重試此動作。" }, "personalOwnershipSubmitError": { - "message": "由於某個企業原則,您被限制為儲存項目到您的個人密碼庫。將擁有權變更為組織,並從可用的集合中選擇。" + "message": "由於企業政策限制,您無法將項目儲存至個人密碼庫。請將「所有權」選項變更為組織,並從可用的集合中選擇。" }, "personalOwnershipPolicyInEffect": { - "message": "組織原則正在影響您的擁有權選項。" + "message": "組織政策正在影響您的所有權選項。" }, "personalOwnershipPolicyInEffectImports": { "message": "某個組織原則已禁止您將項目匯入至您的個人密碼庫。" @@ -2699,7 +2729,7 @@ "message": "無法匯入卡片項目類別" }, "restrictCardTypeImportDesc": { - "message": "由於一或多個組織設有政策,您無法匯入支付卡至您的密碼庫。" + "message": "由於一或多個組織設有政策,您無法匯入付款卡至您的密碼庫。" }, "domainsTitle": { "message": "網域", @@ -2715,7 +2745,7 @@ "message": "排除網域" }, "excludedDomainsDesc": { - "message": "Bitwarden 不會要求儲存這些網域的詳細登入資訊。必須重新整理頁面才能使變更生效。" + "message": "Bitwarden 不會在這些網域要求儲存登入資料。您必須重新整理頁面,變更才會生效。" }, "excludedDomainsDescAlt": { "message": "對於所有已登入的帳戶,Bitwarden 不會詢問是否儲存這些網域的登入資訊。您必須重新整理頁面變更才會生效。" @@ -2869,7 +2899,7 @@ } }, "excludedDomainsInvalidDomain": { - "message": "$DOMAIN$ 不是一個有效的網域", + "message": "$DOMAIN$ 不是有效的網域", "placeholders": { "domain": { "content": "$1", @@ -2948,7 +2978,7 @@ "message": "刪除" }, "removedPassword": { - "message": "已移除密碼" + "message": "密碼已移除" }, "deletedSend": { "message": "Send 已刪除", @@ -2988,7 +3018,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 天" @@ -3005,10 +3035,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." @@ -3021,7 +3047,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": { @@ -3068,43 +3094,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": "對查看者隱藏您的電子郵件地址。" @@ -3116,28 +3122,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": "您的主密碼不符合一個或多個組織政策規定。您必須立即更新您的主密碼才能存取密碼庫。進行此動作將登出您目前的工作階段,需要您重新登入。其他裝置上的工作階段可能持續長達一小時。" @@ -3146,10 +3152,10 @@ "message": "您的組織停用了信任裝置加密。若要存取您的密碼庫,請設定主密碼。" }, "resetPasswordPolicyAutoEnroll": { - "message": "自動註冊" + "message": "自動加入" }, "resetPasswordAutoEnrollInviteWarning": { - "message": "此組織有一個可以為您自動註冊密碼重設的企業原則。註冊後將允許組織管理員變更您的主密碼。" + "message": "此組織有一項企業政策,會自動將您加入密碼重設。加入後,組織管理員將能變更您的主密碼。" }, "selectFolder": { "message": "選擇資料夾⋯" @@ -3349,6 +3355,12 @@ "error": { "message": "錯誤" }, + "prfUnlockFailed": { + "message": "使用通行金鑰解鎖失敗。請再試一次或改用其他解鎖方式。" + }, + "noPrfCredentialsAvailable": { + "message": "沒有可用的支援 PRF 的通行金鑰可用於解鎖。請先使用通行金鑰登入。" + }, "decryptionError": { "message": "解密發生錯誤" }, @@ -4724,6 +4736,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "更多選項" + }, "moreOptionsTitle": { "message": "更多選項 - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4815,16 +4830,16 @@ "message": "管理控制台" }, "admin": { - "message": "Admin" + "message": "管理員" }, "automaticUserConfirmation": { "message": "自動使用者確認" }, "automaticUserConfirmationHint": { - "message": "Automatically confirm pending users while this device is unlocked" + "message": "在此裝置解鎖時自動確認待處理的使用者" }, "autoConfirmOnboardingCallout": { - "message": "Save time with automatic user confirmation" + "message": "透過自動使用者確認節省時間" }, "autoConfirmWarning": { "message": "可能影響您的組織資料安全性。" @@ -4836,7 +4851,7 @@ "message": "自動確認新使用者" }, "autoConfirmSetupDesc": { - "message": "New users will be automatically confirmed while this device is unlocked." + "message": "當此裝置處於解鎖狀態時,新的使用者將會自動獲得確認。" }, "autoConfirmSetupHint": { "message": "潛在的安全性風險有哪些?" @@ -4845,7 +4860,7 @@ "message": "開啟自動確認" }, "availableNow": { - "message": "Available now" + "message": "立即可用" }, "accountSecurity": { "message": "帳戶安全性" @@ -4971,6 +4986,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "下載附件" + }, "downloadBitwarden": { "message": "下載 Bitwarden" }, @@ -5110,14 +5128,11 @@ } } }, - "hideMatchDetection": { - "message": "隱藏偵測到的吻合 $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "顯示比對偵測" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "隱藏比對偵測" }, "autoFillOnPageLoad": { "message": "在頁面載入時自動填寫?" @@ -5655,6 +5670,9 @@ "extraWide": { "message": "超寬" }, + "narrow": { + "message": "縮小" + }, "sshKeyWrongPassword": { "message": "您輸入的密碼錯誤。" }, @@ -5946,7 +5964,7 @@ "message": "郵編 / 郵政代碼" }, "cardNumberLabel": { - "message": "支付卡號碼" + "message": "付款卡號碼" }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "您的組織已不再使用主密碼登入 Bitwarden。若要繼續,請驗證組織與網域。" @@ -6085,7 +6103,35 @@ "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" + }, + "emailProtected": { + "message": "電子郵件已受保護" + }, + "sendPasswordHelperText": { + "message": "對方必須輸入密碼才能檢視此 Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } -} +} \ No newline at end of file diff --git a/apps/browser/src/auth/popup/account-switching/account-switcher.component.html b/apps/browser/src/auth/popup/account-switching/account-switcher.component.html index cef2a748d58..0a9e2a1dd9d 100644 --- a/apps/browser/src/auth/popup/account-switching/account-switcher.component.html +++ b/apps/browser/src/auth/popup/account-switching/account-switcher.component.html @@ -7,13 +7,16 @@ - + -
+
- +

{{ "availableAccounts" | i18n }}

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

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

diff --git a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts index e67c826cac6..6714f749d2d 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts @@ -15,7 +15,7 @@ import { FormArray, } from "@angular/forms"; import { RouterModule } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; +import { Observable, Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; @@ -35,7 +35,7 @@ import { TypographyModule, } from "@bitwarden/components"; -import { enableAccountSwitching } from "../../../platform/flags"; +import { AccountSwitcherService } from "../../../auth/popup/account-switching/services/account-switcher.service"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; @@ -74,7 +74,8 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { @ViewChildren("uriInput") uriInputElements: QueryList> = new QueryList(); - accountSwitcherEnabled = false; + readonly accountSwitcherEnabled$: Observable = + this.accountSwitcherService.accountSwitchingEnabled$(); dataIsPristine = true; isLoading = false; excludedDomainsState: string[] = []; @@ -95,9 +96,8 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { private toastService: ToastService, private formBuilder: FormBuilder, private popupRouterCacheService: PopupRouterCacheService, - ) { - this.accountSwitcherEnabled = enableAccountSwitching(); - } + private accountSwitcherService: AccountSwitcherService, + ) {} get domainForms() { return this.domainListForm.get("domains") as FormArray; diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts index 66a692dbe20..58f3ad11166 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts @@ -158,7 +158,7 @@ describe("CollectAutofillContentService", () => { type: "text", value: "", checked: false, - autoCompleteType: "", + autoCompleteType: null, disabled: false, readonly: false, selectInfo: null, @@ -346,7 +346,7 @@ describe("CollectAutofillContentService", () => { type: "text", value: "", checked: false, - autoCompleteType: "", + autoCompleteType: null, disabled: false, readonly: false, selectInfo: null, @@ -379,7 +379,7 @@ describe("CollectAutofillContentService", () => { type: "password", value: "", checked: false, - autoCompleteType: "", + autoCompleteType: null, disabled: false, readonly: false, selectInfo: null, @@ -588,7 +588,7 @@ describe("CollectAutofillContentService", () => { "aria-disabled": false, "aria-haspopup": false, "aria-hidden": false, - autoCompleteType: "", + autoCompleteType: null, checked: false, "data-stripe": null, disabled: false, @@ -621,7 +621,7 @@ describe("CollectAutofillContentService", () => { "aria-disabled": false, "aria-haspopup": false, "aria-hidden": false, - autoCompleteType: "", + autoCompleteType: null, checked: false, "data-stripe": null, disabled: false, @@ -2507,9 +2507,7 @@ describe("CollectAutofillContentService", () => { "class", "tabindex", "title", - "value", "rel", - "tagname", "checked", "disabled", "readonly", diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index 18eb8e2baf8..1d464e1313f 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -1,5 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { AUTOFILL_ATTRIBUTES } from "@bitwarden/common/autofill/constants"; + import AutofillField from "../models/autofill-field"; import AutofillForm from "../models/autofill-form"; import AutofillPageDetails from "../models/autofill-page-details"; @@ -96,7 +98,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 +244,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this._autofillFormElements.set(formElement, { opid: formElement.opid, htmlAction: this.getFormActionAttribute(formElement), - htmlName: this.getPropertyOrAttribute(formElement, "name"), - htmlClass: this.getPropertyOrAttribute(formElement, "class"), - htmlID: this.getPropertyOrAttribute(formElement, "id"), - htmlMethod: this.getPropertyOrAttribute(formElement, "method"), + htmlName: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.NAME), + htmlClass: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.CLASS), + htmlID: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.ID), + htmlMethod: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.METHOD), }); } @@ -258,7 +262,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ * @private */ private getFormActionAttribute(element: ElementWithOpId): string { - return new URL(this.getPropertyOrAttribute(element, "action"), globalThis.location.href).href; + return new URL( + this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.ACTION), + globalThis.location.href, + ).href; } /** @@ -333,7 +340,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ return priorityFormFields; } - const fieldType = this.getPropertyOrAttribute(element, "type")?.toLowerCase(); + const fieldType = this.getPropertyOrAttribute( + element, + AUTOFILL_ATTRIBUTES.TYPE, + )?.toLowerCase(); if (unimportantFieldTypesSet.has(fieldType)) { unimportantFormFields.push(element); continue; @@ -382,11 +392,11 @@ export class CollectAutofillContentService implements CollectAutofillContentServ elementNumber: index, maxLength: this.getAutofillFieldMaxLength(element), viewable: await this.domElementVisibilityService.isElementViewable(element), - htmlID: this.getPropertyOrAttribute(element, "id"), - htmlName: this.getPropertyOrAttribute(element, "name"), - htmlClass: this.getPropertyOrAttribute(element, "class"), - tabindex: this.getPropertyOrAttribute(element, "tabindex"), - title: this.getPropertyOrAttribute(element, "title"), + htmlID: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.ID), + htmlName: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.NAME), + htmlClass: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.CLASS), + tabindex: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.TABINDEX), + title: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.TITLE), tagName: this.getAttributeLowerCase(element, "tagName"), dataSetValues: this.getDataSetValues(element), }; @@ -402,16 +412,16 @@ export class CollectAutofillContentService implements CollectAutofillContentServ } let autofillFieldLabels = {}; - const elementType = this.getAttributeLowerCase(element, "type"); + const elementType = this.getAttributeLowerCase(element, AUTOFILL_ATTRIBUTES.TYPE); if (elementType !== "hidden") { autofillFieldLabels = { "label-tag": this.createAutofillFieldLabelTag(element as FillableFormFieldElement), - "label-data": this.getPropertyOrAttribute(element, "data-label"), - "label-aria": this.getPropertyOrAttribute(element, "aria-label"), + "label-data": this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.DATA_LABEL), + "label-aria": this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.ARIA_LABEL), "label-top": this.createAutofillFieldTopLabel(element), "label-right": this.createAutofillFieldRightLabel(element), "label-left": this.createAutofillFieldLeftLabel(element), - placeholder: this.getPropertyOrAttribute(element, "placeholder"), + placeholder: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.PLACEHOLDER), }; } @@ -419,21 +429,21 @@ export class CollectAutofillContentService implements CollectAutofillContentServ const autofillField = { ...autofillFieldBase, ...autofillFieldLabels, - rel: this.getPropertyOrAttribute(element, "rel"), + rel: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.REL), type: elementType, value: this.getElementValue(element), - checked: this.getAttributeBoolean(element, "checked"), + checked: this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.CHECKED), autoCompleteType: this.getAutoCompleteAttribute(element), - disabled: this.getAttributeBoolean(element, "disabled"), - readonly: this.getAttributeBoolean(element, "readonly"), + disabled: this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.DISABLED), + readonly: this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.READONLY), selectInfo: elementIsSelectElement(element) ? this.getSelectElementOptions(element as HTMLSelectElement) : null, form: fieldFormElement ? this.getPropertyOrAttribute(fieldFormElement, "opid") : null, - "aria-hidden": this.getAttributeBoolean(element, "aria-hidden", true), - "aria-disabled": this.getAttributeBoolean(element, "aria-disabled", true), - "aria-haspopup": this.getAttributeBoolean(element, "aria-haspopup", true), - "data-stripe": this.getPropertyOrAttribute(element, "data-stripe"), + "aria-hidden": this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.ARIA_HIDDEN, true), + "aria-disabled": this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.ARIA_DISABLED, true), + "aria-haspopup": this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.ARIA_HASPOPUP, true), + "data-stripe": this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.DATA_STRIPE), }; this.cacheAutofillFieldElement(index, element, autofillField); @@ -465,9 +475,9 @@ export class CollectAutofillContentService implements CollectAutofillContentServ */ private getAutoCompleteAttribute(element: ElementWithOpId): string { return ( - this.getPropertyOrAttribute(element, "x-autocompletetype") || - this.getPropertyOrAttribute(element, "autocompletetype") || - this.getPropertyOrAttribute(element, "autocomplete") + this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.AUTOCOMPLETE) || + this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.X_AUTOCOMPLETE_TYPE) || + this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.AUTOCOMPLETE_TYPE) ); } @@ -955,6 +965,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this.mutationObserver = new MutationObserver(this.handleMutationObserverMutation); this.mutationObserver.observe(document.documentElement, { attributes: true, + /** Mutations to node attributes NOT on this list will not be observed! */ + attributeFilter: Object.values(AUTOFILL_ATTRIBUTES), childList: true, subtree: true, }); @@ -1072,17 +1084,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 +1331,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ action: () => (dataTarget.htmlAction = this.getFormActionAttribute(element)), name: () => updateAttribute("htmlName"), id: () => updateAttribute("htmlID"), + class: () => updateAttribute("htmlClass"), method: () => updateAttribute("htmlMethod"), }; @@ -1344,29 +1361,49 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this.updateAutofillDataAttribute({ element, attributeName, dataTarget, dataTargetKey }); }; const updateActions: Record = { - maxlength: () => (dataTarget.maxLength = this.getAutofillFieldMaxLength(element)), - id: () => updateAttribute("htmlID"), - name: () => updateAttribute("htmlName"), - class: () => updateAttribute("htmlClass"), - tabindex: () => updateAttribute("tabindex"), - title: () => updateAttribute("tabindex"), - rel: () => updateAttribute("rel"), - tagname: () => (dataTarget.tagName = this.getAttributeLowerCase(element, "tagName")), - type: () => (dataTarget.type = this.getAttributeLowerCase(element, "type")), - value: () => (dataTarget.value = this.getElementValue(element)), - checked: () => (dataTarget.checked = this.getAttributeBoolean(element, "checked")), - disabled: () => (dataTarget.disabled = this.getAttributeBoolean(element, "disabled")), - readonly: () => (dataTarget.readonly = this.getAttributeBoolean(element, "readonly")), - autocomplete: () => (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), - "data-label": () => updateAttribute("label-data"), + "aria-describedby": () => updateAttribute(AUTOFILL_ATTRIBUTES.ARIA_DESCRIBEDBY), "aria-label": () => updateAttribute("label-aria"), + "aria-labelledby": () => updateAttribute(AUTOFILL_ATTRIBUTES.ARIA_LABELLEDBY), "aria-hidden": () => - (dataTarget["aria-hidden"] = this.getAttributeBoolean(element, "aria-hidden", true)), + (dataTarget["aria-hidden"] = this.getAttributeBoolean( + element, + AUTOFILL_ATTRIBUTES.ARIA_HIDDEN, + true, + )), "aria-disabled": () => - (dataTarget["aria-disabled"] = this.getAttributeBoolean(element, "aria-disabled", true)), + (dataTarget["aria-disabled"] = this.getAttributeBoolean( + element, + AUTOFILL_ATTRIBUTES.ARIA_DISABLED, + true, + )), "aria-haspopup": () => - (dataTarget["aria-haspopup"] = this.getAttributeBoolean(element, "aria-haspopup", true)), - "data-stripe": () => updateAttribute("data-stripe"), + (dataTarget["aria-haspopup"] = this.getAttributeBoolean( + element, + AUTOFILL_ATTRIBUTES.ARIA_HASPOPUP, + true, + )), + autocomplete: () => (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), + autocompletetype: () => + (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), + "x-autocompletetype": () => + (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), + class: () => updateAttribute("htmlClass"), + checked: () => + (dataTarget.checked = this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.CHECKED)), + "data-label": () => updateAttribute("label-data"), + "data-stripe": () => updateAttribute(AUTOFILL_ATTRIBUTES.DATA_STRIPE), + disabled: () => + (dataTarget.disabled = this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.DISABLED)), + id: () => updateAttribute("htmlID"), + maxlength: () => (dataTarget.maxLength = this.getAutofillFieldMaxLength(element)), + name: () => updateAttribute("htmlName"), + placeholder: () => updateAttribute(AUTOFILL_ATTRIBUTES.PLACEHOLDER), + readonly: () => + (dataTarget.readonly = this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.READONLY)), + rel: () => updateAttribute(AUTOFILL_ATTRIBUTES.REL), + tabindex: () => updateAttribute(AUTOFILL_ATTRIBUTES.TABINDEX), + title: () => updateAttribute(AUTOFILL_ATTRIBUTES.TITLE), + type: () => (dataTarget.type = this.getAttributeLowerCase(element, AUTOFILL_ATTRIBUTES.TYPE)), }; if (!updateActions[attributeName]) { diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index b9b41943b04..65eb88156ae 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,8 @@ export default class MainBackground { this.keyGenerationService, this.sendStateProvider, this.encryptService, + this.cryptoFunctionService, + this.configService, ); this.sendApiService = new SendApiService( this.apiService, @@ -1510,6 +1495,7 @@ export default class MainBackground { this.accountService, this.billingAccountProfileStateService, this.configService, + this.logService, this.organizationService, this.platformUtilsService, this.stateProvider, @@ -1564,7 +1550,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..88068987dd7 100644 --- a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts +++ b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts @@ -1,6 +1,8 @@ export type PhishingResource = { name?: string; remoteUrl: string; + /** Fallback URL to use if remoteUrl fails (e.g., due to SSL interception/cert issues) */ + fallbackUrl: string; checksumUrl: string; todayUrl: string; /** Matcher used to decide whether a given URL matches an entry from this resource */ @@ -19,6 +21,8 @@ export const PHISHING_RESOURCES: Record { 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 4bc31f8ea60..03759ba14bc 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts @@ -1,14 +1,25 @@ 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"; import { devFlagEnabled, devFlagValue } from "@bitwarden/browser/platform/flags"; @@ -20,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. @@ -32,42 +48,64 @@ 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 switchMap(() => - this._cachedState.state$.pipe( + this._phishingMetaState.state$.pipe( first(), // Only take the first value to avoid an infinite loop when updating the cache below - tap((cachedState) => { - void this._backgroundUpdate(cachedState); + tap((metaState) => { + // Perform any updates in the background + this._backgroundUpdateTrigger$.next(metaState); }), catchError((err: unknown) => { this.logService.error("[PhishingDataService] Background update failed to start.", err); @@ -75,6 +113,7 @@ export class PhishingDataService { }), ), ), + takeUntil(this._destroy$), share(), ); @@ -86,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(); }); @@ -93,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(); } /** @@ -102,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) { @@ -186,61 +246,196 @@ export class PhishingDataService { private getTestWebAddresses() { const flag = devFlagEnabled("testPhishingUrls"); + // Normalize URLs by converting to URL object and back to ensure consistent format (e.g., trailing slashes) + const testWebAddresses: string[] = [ + new URL("http://phishing.testcategory.com").href, + new URL("https://phishing.testcategory.com").href, + new URL("https://phishing.testcategory.com/block").href, + ]; if (!flag) { - return []; + return testWebAddresses; } const webAddresses = devFlagValue("testPhishingUrls") as unknown[]; if (webAddresses && webAddresses instanceof Array) { this.logService.debug( - "[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing web addresses:", + "[PhishingDataService] Dev flag enabled for testing phishing detection. Adding test phishing web addresses:", webAddresses, ); - return webAddresses as string[]; + // Normalize dev flag URLs as well, filtering out invalid ones + const normalizedDevAddresses = (webAddresses as string[]) + .filter((addr) => { + try { + new URL(addr); + return true; + } catch { + this.logService.warning( + `[PhishingDataService] Invalid test URL in dev flag, skipping: ${addr}`, + ); + return false; + } + }) + .map((addr) => new URL(addr).href); + return testWebAddresses.concat(normalizedDevAddresses); } - return []; + return testWebAddresses; } - // Runs the update flow in the background and retries up to 3 times on failure - private async _backgroundUpdate(prev: PhishingData | null): Promise { - this.logService.info(`[PhishingDataService] Update triggered...`); - const phishingData = prev ?? { - webAddresses: [], - timestamp: 0, - checksum: "", - applicationVersion: "", - }; - // Start time for logging performance of update - const startTime = Date.now(); - const maxAttempts = 3; - const delayMs = 5 * 60 * 1000; // 5 minutes + private _getUpdatedMeta(): Observable { + return defer(() => { + const now = Date.now(); - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const next = await this.getNextWebAddresses(phishingData); - if (next) { - await this._cachedState.update(() => next); + return forkJoin({ + applicationVersion: from(this.platformUtilsService.getApplicationVersion()), + remoteChecksum: from(this.fetchPhishingChecksum(this.resourceType)), + }).pipe( + map(({ applicationVersion, remoteChecksum }) => { + return { + checksum: remoteChecksum, + timestamp: now, + applicationVersion, + }; + }), + ); + }); + } - // Performance logging - const elapsed = Date.now() - startTime; - this.logService.info(`[PhishingDataService] cache updated in ${elapsed}ms`); - } - return; - } catch (err) { - this.logService.error( - `[PhishingDataService] Unable to update web addresses. Attempt ${attempt}.`, - err, - ); - if (attempt < maxAttempts) { - await new Promise((res) => setTimeout(res, delayMs)); - } else { - const elapsed = Date.now() - startTime; - this.logService.error( - `[PhishingDataService] Retries unsuccessful after ${elapsed}ms. Unable to update web addresses.`, - err, + // Streams the full phishing data set and saves it to IndexedDB + private _updateFullDataSet() { + const resource = getPhishingResources(this.resourceType); + + if (!resource?.remoteUrl) { + return throwError(() => new Error("Invalid resource URL")); + } + + this.logService.info(`[PhishingDataService] Starting FULL update using ${resource.remoteUrl}`); + return from(this.apiService.nativeFetch(new Request(resource.remoteUrl))).pipe( + switchMap((response) => { + if (!response.ok || !response.body) { + return throwError( + () => + new Error( + `[PhishingDataService] Full fetch failed: ${response.status}, ${response.statusText}`, + ), ); } - } + + return from(this.indexedDbService.saveUrlsFromStream(response.body)); + }), + catchError((err: unknown) => { + this.logService.error( + `[PhishingDataService] Full dataset update failed using primary source ${err}`, + ); + this.logService.warning( + `[PhishingDataService] Falling back to: ${resource.fallbackUrl} (Note: Fallback data may be less up-to-date)`, + ); + // Try fallback URL + return from(this.apiService.nativeFetch(new Request(resource.fallbackUrl))).pipe( + switchMap((fallbackResponse) => { + if (!fallbackResponse.ok || !fallbackResponse.body) { + return throwError( + () => + new Error( + `[PhishingDataService] Fallback fetch failed: ${fallbackResponse.status}, ${fallbackResponse.statusText}`, + ), + ); + } + + return from(this.indexedDbService.saveUrlsFromStream(fallbackResponse.body)); + }), + catchError((fallbackError: unknown) => { + this.logService.error(`[PhishingDataService] Fallback source failed`); + return throwError(() => fallbackError); + }), + ); + }), + ); + } + + 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/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..e04d302ea2c 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html @@ -18,11 +18,11 @@ type="button" role="link" > - + > {{ button.label | i18n }} diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts index 26138d57954..5a40b72daff 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts @@ -3,15 +3,15 @@ import { Component, Input } from "@angular/core"; import { RouterModule } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { IconModule, LinkModule } from "@bitwarden/components"; +import { SvgModule, LinkModule } from "@bitwarden/components"; export type NavButton = { label: string; page: string; - icon: Icon; - iconActive: Icon; + icon: BitSvg; + iconActive: BitSvg; showBerry?: boolean; }; @@ -20,7 +20,7 @@ export type NavButton = { @Component({ selector: "popup-tab-navigation", templateUrl: "popup-tab-navigation.component.html", - imports: [CommonModule, LinkModule, RouterModule, JslibModule, IconModule], + imports: [CommonModule, LinkModule, RouterModule, JslibModule, SvgModule], host: { class: "tw-block tw-size-full tw-flex tw-flex-col", }, diff --git a/apps/browser/src/platform/services/browser-local-storage.service.ts b/apps/browser/src/platform/services/browser-local-storage.service.ts index 30454cf6a77..e34c3ac4904 100644 --- a/apps/browser/src/platform/services/browser-local-storage.service.ts +++ b/apps/browser/src/platform/services/browser-local-storage.service.ts @@ -15,6 +15,22 @@ export default class BrowserLocalStorageService extends AbstractChromeStorageSer return await this.getWithRetries(key, 0); } + /** + * Retrieves all storage keys. + * + * Returns all keys stored in local storage when the browser supports the getKeys API (Chrome 130+). + * Returns an empty array on older browser versions where this feature is unavailable. + * + * @returns Array of storage keys, or empty array if the feature is not supported + */ + async getKeys(): Promise { + // getKeys function is only available since Chrome 130 + if ("getKeys" in this.chromeStorageApi) { + return this.chromeStorageApi.getKeys(); + } + return []; + } + private async getWithRetries(key: string, retryNum: number): Promise { // See: https://github.com/EFForg/privacybadger/pull/2980 const MAX_RETRIES = 5; diff --git a/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts index 947fecb5aac..26b51e2fea7 100644 --- a/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts +++ b/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts @@ -1,20 +1,89 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { Lazy } from "@bitwarden/common/platform/misc/lazy"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { FakeStorageService, makeEncString } from "@bitwarden/common/spec"; +import { FakeStorageService, makeEncString, makeSymmetricCryptoKey } from "@bitwarden/common/spec"; +import { StorageService } from "@bitwarden/storage-core"; -import { LocalBackedSessionStorageService } from "./local-backed-session-storage.service"; +import BrowserLocalStorageService from "./browser-local-storage.service"; +import { + LocalBackedSessionStorageService, + SessionKeyResolveService, +} from "./local-backed-session-storage.service"; + +describe("SessionKeyResolveService", () => { + let storageService: FakeStorageService; + let keyGenerationService: MockProxy; + let sut: SessionKeyResolveService; + + const mockKey = makeSymmetricCryptoKey(); + + beforeEach(() => { + storageService = new FakeStorageService(); + keyGenerationService = mock(); + sut = new SessionKeyResolveService(storageService, keyGenerationService); + }); + + describe("get", () => { + it("returns null when no session key exists", async () => { + const result = await sut.get(); + expect(result).toBeNull(); + }); + + it("returns the session key from storage", async () => { + await storageService.save("session-key", mockKey); + const result = await sut.get(); + expect(result).toEqual(mockKey); + }); + + it("deserializes the session key when storage requires deserialization", async () => { + const mockStorageService = mock(); + Object.defineProperty(mockStorageService, "valuesRequireDeserialization", { + get: () => true, + }); + mockStorageService.get.mockResolvedValue(mockKey.toJSON()); + + const deserializableSut = new SessionKeyResolveService( + mockStorageService, + keyGenerationService, + ); + + const result = await deserializableSut.get(); + + expect(result).toBeInstanceOf(SymmetricCryptoKey); + expect(result?.toJSON()).toEqual(mockKey.toJSON()); + }); + }); + + describe("create", () => { + it("creates a new session key and saves it to storage", async () => { + keyGenerationService.createKeyWithPurpose.mockResolvedValue({ + salt: "salt", + material: new Uint8Array(16) as any, + derivedKey: mockKey, + }); + + const result = await sut.create(); + + expect(keyGenerationService.createKeyWithPurpose).toHaveBeenCalledWith( + 128, + "ephemeral", + "bitwarden-ephemeral", + ); + expect(result).toEqual(mockKey); + expect(await storageService.get("session-key")).toEqual(mockKey.toJSON()); + }); + }); +}); describe("LocalBackedSessionStorage", () => { - const sessionKey = new SymmetricCryptoKey( - Utils.fromUtf8ToArray("00000000000000000000000000000000"), - ); - let localStorage: FakeStorageService; + const sessionKey = makeSymmetricCryptoKey(); + let memoryStorage: MockProxy; + let keyGenerationService: MockProxy; + let localStorage: MockProxy; let encryptService: MockProxy; let platformUtilsService: MockProxy; let logService: MockProxy; @@ -22,14 +91,23 @@ describe("LocalBackedSessionStorage", () => { let sut: LocalBackedSessionStorageService; beforeEach(() => { - localStorage = new FakeStorageService(); + memoryStorage = mock(); + keyGenerationService = mock(); + localStorage = mock(); encryptService = mock(); platformUtilsService = mock(); logService = mock(); + // Default: session key exists + memoryStorage.get.mockResolvedValue(sessionKey); + Object.defineProperty(memoryStorage, "valuesRequireDeserialization", { + get: () => true, + }); + sut = new LocalBackedSessionStorageService( - new Lazy(async () => sessionKey), + memoryStorage, localStorage, + keyGenerationService, encryptService, platformUtilsService, logService, @@ -37,57 +115,79 @@ describe("LocalBackedSessionStorage", () => { }); describe("get", () => { - it("return the cached value when one is cached", async () => { + const encString = makeEncString("encrypted"); + + it("returns the cached value when one is cached", async () => { sut["cache"]["test"] = "cached"; const result = await sut.get("test"); expect(result).toEqual("cached"); }); - it("returns a decrypted value when one is stored in local storage", async () => { - const encrypted = makeEncString("encrypted"); - localStorage.internalStore["session_test"] = encrypted.encryptedString; - encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted")); - const result = await sut.get("test"); - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - (expect(encryptService.decryptString).toHaveBeenCalledWith(encrypted, sessionKey), - expect(result).toEqual("decrypted")); - }); + it("returns null when both cache and storage are null", async () => { + sut["cache"]["test"] = null; + localStorage.get.mockResolvedValue(null); - it("caches the decrypted value when one is stored in local storage", async () => { - const encrypted = makeEncString("encrypted"); - localStorage.internalStore["session_test"] = encrypted.encryptedString; - encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted")); - await sut.get("test"); - expect(sut["cache"]["test"]).toEqual("decrypted"); + const result = await sut.get("test"); + + expect(result).toBeNull(); + expect(localStorage.get).toHaveBeenCalledWith("session_test"); }); it("returns a decrypted value when one is stored in local storage", async () => { - const encrypted = makeEncString("encrypted"); - localStorage.internalStore["session_test"] = encrypted.encryptedString; + localStorage.get.mockResolvedValue(encString.encryptedString); encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted")); + const result = await sut.get("test"); - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - (expect(encryptService.decryptString).toHaveBeenCalledWith(encrypted, sessionKey), - expect(result).toEqual("decrypted")); + + expect(encryptService.decryptString).toHaveBeenCalledWith(encString, sessionKey); + expect(result).toEqual("decrypted"); + expect(sut["cache"]["test"]).toEqual("decrypted"); }); - it("caches the decrypted value when one is stored in local storage", async () => { - const encrypted = makeEncString("encrypted"); - localStorage.internalStore["session_test"] = encrypted.encryptedString; - encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted")); - await sut.get("test"); - expect(sut["cache"]["test"]).toEqual("decrypted"); + it("returns the cached value when cache is populated during storage retrieval", async () => { + localStorage.get.mockImplementation(async () => { + sut["cache"]["test"] = "cached-during-read"; + return encString.encryptedString; + }); + encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted-from-storage")); + + const result = await sut.get("test"); + + expect(result).toEqual("cached-during-read"); + }); + + it("returns the cached value when storage returns null but cache was filled", async () => { + localStorage.get.mockImplementation(async () => { + sut["cache"]["test"] = "cached-during-read"; + return null; + }); + + const result = await sut.get("test"); + + expect(result).toEqual("cached-during-read"); + }); + + it("creates new session key, clears old data, and returns null when session key is missing", async () => { + const newSessionKey = makeSymmetricCryptoKey(); + const clearSpy = jest.spyOn(sut as any, "clear"); + memoryStorage.get.mockResolvedValue(null); + keyGenerationService.createKeyWithPurpose.mockResolvedValue({ + salt: "salt", + material: new Uint8Array(16) as any, + derivedKey: newSessionKey, + }); + localStorage.get.mockResolvedValue(null); + localStorage.getKeys.mockResolvedValue([]); + + const result = await sut.get("test"); + + expect(keyGenerationService.createKeyWithPurpose).toHaveBeenCalled(); + expect(clearSpy).toHaveBeenCalled(); + expect(result).toBeNull(); }); }); describe("has", () => { - it("returns false when the key is not in cache", async () => { - const result = await sut.has("test"); - expect(result).toBe(false); - }); - it("returns true when the key is in cache", async () => { sut["cache"]["test"] = "cached"; const result = await sut.has("test"); @@ -95,21 +195,17 @@ describe("LocalBackedSessionStorage", () => { }); it("returns true when the key is in local storage", async () => { - localStorage.internalStore["session_test"] = makeEncString("encrypted").encryptedString; + const encString = makeEncString("encrypted"); + localStorage.get.mockResolvedValue(encString.encryptedString); encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted")); const result = await sut.has("test"); expect(result).toBe(true); }); - it.each([null, undefined])("returns false when %s is cached", async (nullish) => { - sut["cache"]["test"] = nullish; - await expect(sut.has("test")).resolves.toBe(false); - }); - it.each([null, undefined])( - "returns false when null is stored in local storage", - async (nullish) => { - localStorage.internalStore["session_test"] = nullish; + "returns false when the key does not exist in local storage (%s)", + async (value) => { + localStorage.get.mockResolvedValue(value); await expect(sut.has("test")).resolves.toBe(false); expect(encryptService.decryptString).not.toHaveBeenCalled(); }, @@ -118,6 +214,7 @@ describe("LocalBackedSessionStorage", () => { describe("save", () => { const encString = makeEncString("encrypted"); + beforeEach(() => { encryptService.encryptString.mockResolvedValue(encString); }); @@ -137,29 +234,44 @@ describe("LocalBackedSessionStorage", () => { }); it("removes the key when saving a null value", async () => { - const spy = jest.spyOn(sut, "remove"); + const removeSpy = jest.spyOn(sut, "remove"); await sut.save("test", null); - expect(spy).toHaveBeenCalledWith("test"); + expect(removeSpy).toHaveBeenCalledWith("test"); }); - it("saves the value to cache", async () => { + it("uses the session key when encrypting", async () => { await sut.save("test", "value"); - expect(sut["cache"]["test"]).toEqual("value"); - }); - it("encrypts and saves the value to local storage", async () => { - await sut.save("test", "value"); + expect(memoryStorage.get).toHaveBeenCalledWith("session-key"); expect(encryptService.encryptString).toHaveBeenCalledWith( JSON.stringify("value"), sessionKey, ); - expect(localStorage.internalStore["session_test"]).toEqual(encString.encryptedString); }); it("emits an update", async () => { - const spy = jest.spyOn(sut["updatesSubject"], "next"); + const updateSpy = jest.spyOn(sut["updatesSubject"], "next"); await sut.save("test", "value"); - expect(spy).toHaveBeenCalledWith({ key: "test", updateType: "save" }); + expect(updateSpy).toHaveBeenCalledWith({ key: "test", updateType: "save" }); + }); + + it("creates a new session key when session key is missing before saving", async () => { + const newSessionKey = makeSymmetricCryptoKey(); + memoryStorage.get.mockResolvedValue(null); + keyGenerationService.createKeyWithPurpose.mockResolvedValue({ + salt: "salt", + material: new Uint8Array(16) as any, + derivedKey: newSessionKey, + }); + localStorage.getKeys.mockResolvedValue([]); + + await sut.save("test", "value"); + + expect(keyGenerationService.createKeyWithPurpose).toHaveBeenCalled(); + expect(encryptService.encryptString).toHaveBeenCalledWith( + JSON.stringify("value"), + newSessionKey, + ); }); }); @@ -171,15 +283,50 @@ describe("LocalBackedSessionStorage", () => { }); it("removes the key from local storage", async () => { - localStorage.internalStore["session_test"] = makeEncString("encrypted").encryptedString; await sut.remove("test"); - expect(localStorage.internalStore["session_test"]).toBeUndefined(); + expect(localStorage.remove).toHaveBeenCalledWith("session_test"); }); it("emits an update", async () => { - const spy = jest.spyOn(sut["updatesSubject"], "next"); + const updateSpy = jest.spyOn(sut["updatesSubject"], "next"); await sut.remove("test"); - expect(spy).toHaveBeenCalledWith({ key: "test", updateType: "remove" }); + expect(updateSpy).toHaveBeenCalledWith({ key: "test", updateType: "remove" }); + }); + }); + + describe("sessionStorageKey", () => { + it("prefixes keys with session_ prefix", () => { + expect(sut["sessionStorageKey"]("test")).toBe("session_test"); + }); + }); + + describe("clear", () => { + it("only removes keys with session_ prefix", async () => { + const removeSpy = jest.spyOn(sut, "remove"); + localStorage.getKeys.mockResolvedValue([ + "session_data1", + "session_data2", + "regular_key", + "another_key", + "session_data3", + "my_session_key", + "mysession", + "sessiondata", + "user_session", + ]); + + await sut["clear"](); + + expect(removeSpy).toHaveBeenCalledWith("data1"); + expect(removeSpy).toHaveBeenCalledWith("data2"); + expect(removeSpy).toHaveBeenCalledWith("data3"); + expect(removeSpy).not.toHaveBeenCalledWith("regular_key"); + expect(removeSpy).not.toHaveBeenCalledWith("another_key"); + expect(removeSpy).not.toHaveBeenCalledWith("my_session_key"); + expect(removeSpy).not.toHaveBeenCalledWith("mysession"); + expect(removeSpy).not.toHaveBeenCalledWith("sessiondata"); + expect(removeSpy).not.toHaveBeenCalledWith("user_session"); + expect(removeSpy).toHaveBeenCalledTimes(3); }); }); }); diff --git a/apps/browser/src/platform/services/local-backed-session-storage.service.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.ts index d0613ee644c..63a51d28e35 100644 --- a/apps/browser/src/platform/services/local-backed-session-storage.service.ts +++ b/apps/browser/src/platform/services/local-backed-session-storage.service.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { Subject } from "rxjs"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -12,33 +13,94 @@ import { StorageUpdate, } from "@bitwarden/common/platform/abstractions/storage.service"; import { compareValues } from "@bitwarden/common/platform/misc/compare-values"; -import { Lazy } from "@bitwarden/common/platform/misc/lazy"; import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { StorageService } from "@bitwarden/storage-core"; import { BrowserApi } from "../browser/browser-api"; import { MemoryStoragePortMessage } from "../storage/port-messages"; import { portName } from "../storage/port-name"; +import BrowserLocalStorageService from "./browser-local-storage.service"; + +const SESSION_KEY_PREFIX = "session_"; + +/** + * Manages an ephemeral session key for encrypting session storage items persisted in local storage. + * + * The session key is stored in session storage and automatically cleared when the browser session ends + * (e.g., browser restart, extension reload). When the session key is unavailable, any encrypted items + * in local storage cannot be decrypted and must be cleared to maintain data consistency. + * + * This provides session-scoped security for sensitive data while using persistent local storage as the backing store. + * + * @internal Internal implementation detail. Exported only for testing purposes. + * Do not use this class directly outside of tests. Use LocalBackedSessionStorageService instead. + */ +export class SessionKeyResolveService { + constructor( + private readonly storageService: StorageService, + private readonly keyGenerationService: KeyGenerationService, + ) {} + + /** + * Retrieves the session key from storage. + * + * @return session key or null when not in storage + */ + async get(): Promise { + const key = await this.storageService.get("session-key"); + if (key) { + if (this.storageService.valuesRequireDeserialization) { + return SymmetricCryptoKey.fromJSON(key); + } + return key; + } + return null; + } + + /** + * Creates new session key and adds it to underlying storage. + * + * @return newly created session key + */ + async create(): Promise { + const { derivedKey } = await this.keyGenerationService.createKeyWithPurpose( + 128, + "ephemeral", + "bitwarden-ephemeral", + ); + await this.storageService.save("session-key", derivedKey.toJSON()); + return derivedKey; + } +} + export class LocalBackedSessionStorageService extends AbstractStorageService implements ObservableStorageService { + readonly valuesRequireDeserialization = true; private ports: Set = new Set([]); private cache: Record = {}; private updatesSubject = new Subject(); - readonly valuesRequireDeserialization = true; updates$ = this.updatesSubject.asObservable(); + private readonly sessionKeyResolveService: SessionKeyResolveService; constructor( - private readonly sessionKey: Lazy>, - private readonly localStorage: AbstractStorageService, + private readonly memoryStorage: StorageService, + private readonly localStorage: BrowserLocalStorageService, + private readonly keyGenerationService: KeyGenerationService, private readonly encryptService: EncryptService, private readonly platformUtilsService: PlatformUtilsService, private readonly logService: LogService, ) { super(); + this.sessionKeyResolveService = new SessionKeyResolveService( + this.memoryStorage, + this.keyGenerationService, + ); + BrowserApi.addListener(chrome.runtime.onConnect, (port) => { if (port.name !== portName(chrome.storage.session)) { return; @@ -70,20 +132,20 @@ export class LocalBackedSessionStorageService } async get(key: string, options?: StorageOptions): Promise { - if (this.cache[key] !== undefined) { + if (this.cache[key] != null) { return this.cache[key] as T; } - const value = await this.getLocalSessionValue(await this.sessionKey.get(), key); + const value = await this.getLocalSessionValue(await this.getSessionKey(), key); - if (this.cache[key] === undefined && value !== undefined) { + if (this.cache[key] == null && value != null) { // Cache is still empty and we just got a value from local/session storage, cache it. this.cache[key] = value; return value as T; - } else if (this.cache[key] === undefined && value === undefined) { + } else if (this.cache[key] == null && value == null) { // Cache is still empty and we got nothing from local/session storage, no need to modify cache. return value as T; - } else if (this.cache[key] !== undefined && value !== undefined) { + } else if (this.cache[key] != null && value != null) { // Conflict, somebody wrote to the cache while we were reading from storage // but we also got a value from storage. We assume the cache is more up to date // and use that value. @@ -91,7 +153,7 @@ export class LocalBackedSessionStorageService `Conflict while reading from local session storage, both cache and storage have values. Key: ${key}. Using cached value.`, ); return this.cache[key] as T; - } else if (this.cache[key] !== undefined && value === undefined) { + } else if (this.cache[key] != null && value == null) { // Cache was filled after the local/session storage read completed. We got null // from the storage read, but we have a value from the cache, use that. this.logService.warning( @@ -136,6 +198,44 @@ export class LocalBackedSessionStorageService this.updatesSubject.next({ key, updateType: "remove" }); } + protected broadcastMessage(data: Omit) { + this.ports.forEach((port) => { + this.sendMessageTo(port, data); + }); + } + + private async getSessionKey(): Promise { + const sessionKey = await this.sessionKeyResolveService.get(); + if (sessionKey != null) { + return sessionKey; + } + + // Session key is missing (browser restart/extension reload), so all stored session data + // cannot be decrypted. Clear all items before creating a new session key. + await this.clear(); + + return await this.sessionKeyResolveService.create(); + } + + /** + * Removes all stored session data. + * + * Called when the session key is unavailable (typically after browser restart or extension reload), + * making all encrypted session data unrecoverable. Prevents orphaned encrypted data from accumulating. + */ + private async clear() { + const keys = (await this.localStorage.getKeys()).filter((key) => + key.startsWith(SESSION_KEY_PREFIX), + ); + this.logService.debug( + `[LocalBackedSessionStorageService] Clearing local session storage. Found ${keys}`, + ); + for (const key of keys) { + const keyWithoutPrefix = key.substring(SESSION_KEY_PREFIX.length); + await this.remove(keyWithoutPrefix); + } + } + private async getLocalSessionValue(encKey: SymmetricCryptoKey, key: string): Promise { const local = await this.localStorage.get(this.sessionStorageKey(key)); if (local == null) { @@ -159,10 +259,7 @@ export class LocalBackedSessionStorageService } const valueJson = JSON.stringify(value); - const encValue = await this.encryptService.encryptString( - valueJson, - await this.sessionKey.get(), - ); + const encValue = await this.encryptService.encryptString(valueJson, await this.getSessionKey()); await this.localStorage.save(this.sessionStorageKey(key), encValue.encryptedString); } @@ -197,12 +294,6 @@ export class LocalBackedSessionStorageService }); } - protected broadcastMessage(data: Omit) { - this.ports.forEach((port) => { - this.sendMessageTo(port, data); - }); - } - private sendMessageTo( port: chrome.runtime.Port, data: Omit, @@ -214,7 +305,7 @@ export class LocalBackedSessionStorageService } private sessionStorageKey(key: string) { - return `session_${key}`; + return `${SESSION_KEY_PREFIX}${key}`; } private compareValues(value1: T, value2: T): boolean { diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 1f1d4d25b40..7fb466449f2 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"; @@ -248,7 +248,7 @@ const routes: Routes = [ { path: "attachments", component: AttachmentsV2Component, - canActivate: [authGuard], + 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, }, { @@ -350,13 +350,13 @@ const routes: Routes = [ { 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, }, { 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 dfbfabf8d5e..dc4b935c6c8 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts @@ -11,7 +11,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -110,7 +109,6 @@ describe("SendV2Component", () => { provide: BillingAccountProfileStateService, useValue: { hasPremiumFromAnySource$: of(false) }, }, - { provide: ConfigService, useValue: mock() }, { provide: EnvironmentService, useValue: mock() }, { provide: LogService, useValue: mock() }, { provide: PlatformUtilsService, useValue: mock() }, diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts index f36a475a805..8c1edee79dc 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts @@ -11,8 +11,6 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; @@ -84,30 +82,17 @@ export class SendV2Component implements OnDestroy { protected listState: SendState | null = null; protected sends$ = this.sendItemsService.filteredAndSortedSends$; - private skeletonFeatureFlag$ = this.configService.getFeatureFlag$( - FeatureFlag.VaultLoadingSkeletons, - ); protected sendsLoading$ = this.sendItemsService.loading$.pipe( distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }), ); - /** Spinner Loading State */ - protected showSpinnerLoaders$ = combineLatest([ - this.sendsLoading$, - this.skeletonFeatureFlag$, - ]).pipe(map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled)); - /** Skeleton Loading State */ protected showSkeletonsLoaders$ = combineLatest([ this.sendsLoading$, this.searchService.isSendSearching$, - this.skeletonFeatureFlag$, ]).pipe( - map( - ([loading, cipherSearching, skeletonsEnabled]) => - (loading || cipherSearching) && skeletonsEnabled, - ), + map(([loading, cipherSearching]) => loading || cipherSearching), distinctUntilChanged(), skeletonLoadingDelay(), ); @@ -128,7 +113,6 @@ export class SendV2Component implements OnDestroy { protected sendListFiltersService: SendListFiltersService, private policyService: PolicyService, private accountService: AccountService, - private configService: ConfigService, private searchService: SearchService, ) { combineLatest([ diff --git a/apps/browser/src/tools/popup/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..19f2445b61d 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.html +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.html @@ -110,7 +110,7 @@ -
+

{{ "downloadBitwardenOnAllDevices" | i18n }}

+ @if (config?.originalCipher?.archivedDate) { + + + {{ "archived" | i18n }} + + + } @@ -24,7 +33,7 @@ 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-v2/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-v2/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-v2/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-v2/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.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/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-v2/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/intro-carousel/intro-carousel.component.html b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html index 5f19092d6b0..1980e8aa356 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html @@ -2,7 +2,7 @@
- +

{{ "securityPrioritized" | i18n }}

{{ "securityPrioritizedBody" | i18n }}

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

{{ "quickLogin" | i18n }}

{{ "quickLoginBody" | i18n }}

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

{{ "secureUser" | i18n }}

{{ "secureUserBody" | i18n }}

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

{{ "secureDevices" | i18n }}

{{ "secureDevicesBody" | i18n }}

diff --git a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts index 48c8f5682bc..5ad44c2f545 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts @@ -3,7 +3,7 @@ import { Router } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ItemTypes, LoginCards, NoCredentialsIcon, DevicesIcon } from "@bitwarden/assets/svg"; -import { ButtonModule, DialogModule, IconModule, TypographyModule } from "@bitwarden/components"; +import { ButtonModule, DialogModule, SvgModule, TypographyModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { VaultCarouselModule } from "@bitwarden/vault"; @@ -17,7 +17,7 @@ import { IntroCarouselService } from "../../../services/intro-carousel.service"; imports: [ VaultCarouselModule, ButtonModule, - IconModule, + SvgModule, DialogModule, TypographyModule, JslibModule, diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html index 04b59d0ee0e..be67869d3df 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -3,7 +3,7 @@ type="button" bitIconButton="bwi-ellipsis-v" size="small" - [label]="'moreOptionsLabel' | i18n: cipher.name" + [label]="'moreOptionsLabelNoPlaceholder' | i18n" [bitMenuTriggerFor]="moreOptions" > diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts index 6728249b788..b999d8db35a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts @@ -405,4 +405,42 @@ describe("ItemMoreOptionsComponent", () => { }); }); }); + + describe("canAssignCollections$", () => { + it("emits true when user has organizations and editable collections", (done) => { + jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(true)); + jest + .spyOn(component["collectionService"], "decryptedCollections$") + .mockReturnValue(of([{ id: "col-1", readOnly: false }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it("emits false when user has no organizations", (done) => { + jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(false)); + jest + .spyOn(component["collectionService"], "decryptedCollections$") + .mockReturnValue(of([{ id: "col-1", readOnly: false }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + + it("emits false when all collections are read-only", (done) => { + jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(true)); + jest + .spyOn(component["collectionService"], "decryptedCollections$") + .mockReturnValue(of([{ id: "col-1", readOnly: true }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index ce797d9755e..d7de51ad20f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -277,8 +277,7 @@ export class ItemMoreOptionsComponent { this.accountService.activeAccount$.pipe(map((a) => a?.id)), )) as UserId; - const encryptedCipher = await this.cipherService.encrypt(cipher, activeUserId); - await this.cipherService.updateWithServer(encryptedCipher); + await this.cipherService.updateWithServer(cipher, activeUserId); this.toastService.showToast({ variant: "success", message: this.i18nService.t( @@ -376,7 +375,8 @@ export class ItemMoreOptionsComponent { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "archiveItem" }, - content: { key: "archiveItemConfirmDesc" }, + content: { key: "archiveItemDialogContent" }, + acceptButtonText: { key: "archiveVerb" }, type: "info", }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts index 37c4804e600..ca73a7332ee 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts @@ -4,7 +4,6 @@ import { FormsModule } from "@angular/forms"; import { BehaviorSubject } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service"; import { SearchModule } from "@bitwarden/components"; @@ -20,7 +19,6 @@ describe("VaultV2SearchComponent", () => { const searchText$ = new BehaviorSubject(""); const loading$ = new BehaviorSubject(false); - const featureFlag$ = new BehaviorSubject(true); const applyFilter = jest.fn(); const createComponent = () => { @@ -31,7 +29,6 @@ describe("VaultV2SearchComponent", () => { beforeEach(async () => { applyFilter.mockClear(); - featureFlag$.next(true); await TestBed.configureTestingModule({ imports: [VaultV2SearchComponent, CommonModule, SearchModule, JslibModule, FormsModule], @@ -49,12 +46,6 @@ describe("VaultV2SearchComponent", () => { loading$, }, }, - { - provide: ConfigService, - useValue: { - getFeatureFlag$: jest.fn(() => featureFlag$), - }, - }, { provide: I18nService, useValue: { t: (key: string) => key } }, ], }).compileComponents(); @@ -70,91 +61,55 @@ describe("VaultV2SearchComponent", () => { }); describe("debouncing behavior", () => { - describe("when feature flag is enabled", () => { - beforeEach(() => { - featureFlag$.next(true); - createComponent(); - }); - - it("debounces search text changes when not loading", fakeAsync(() => { - loading$.next(false); - - component.searchText = "test"; - component.onSearchTextChanged(); - - expect(applyFilter).not.toHaveBeenCalled(); - - tick(SearchTextDebounceInterval); - - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); - - it("should not debounce search text changes when loading", fakeAsync(() => { - loading$.next(true); - - component.searchText = "test"; - component.onSearchTextChanged(); - - tick(0); - - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); - - it("cancels previous debounce when new text is entered", fakeAsync(() => { - loading$.next(false); - - component.searchText = "test"; - component.onSearchTextChanged(); - - tick(SearchTextDebounceInterval / 2); - - component.searchText = "test2"; - component.onSearchTextChanged(); - - tick(SearchTextDebounceInterval / 2); - - expect(applyFilter).not.toHaveBeenCalled(); - - tick(SearchTextDebounceInterval / 2); - - expect(applyFilter).toHaveBeenCalledWith("test2"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); + beforeEach(() => { + createComponent(); }); - describe("when feature flag is disabled", () => { - beforeEach(() => { - featureFlag$.next(false); - createComponent(); - }); + it("debounces search text changes when not loading", fakeAsync(() => { + loading$.next(false); - it("debounces search text changes", fakeAsync(() => { - component.searchText = "test"; - component.onSearchTextChanged(); + component.searchText = "test"; + component.onSearchTextChanged(); - expect(applyFilter).not.toHaveBeenCalled(); + expect(applyFilter).not.toHaveBeenCalled(); - tick(SearchTextDebounceInterval); + tick(SearchTextDebounceInterval); - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); + expect(applyFilter).toHaveBeenCalledWith("test"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); - it("ignores loading state and always debounces", fakeAsync(() => { - loading$.next(true); + it("should not debounce search text changes when loading", fakeAsync(() => { + loading$.next(true); - component.searchText = "test"; - component.onSearchTextChanged(); + component.searchText = "test"; + component.onSearchTextChanged(); - expect(applyFilter).not.toHaveBeenCalled(); + tick(0); - tick(SearchTextDebounceInterval); + expect(applyFilter).toHaveBeenCalledWith("test"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); - }); + it("cancels previous debounce when new text is entered", fakeAsync(() => { + loading$.next(false); + + component.searchText = "test"; + component.onSearchTextChanged(); + + tick(SearchTextDebounceInterval / 2); + + component.searchText = "test2"; + component.onSearchTextChanged(); + + tick(SearchTextDebounceInterval / 2); + + expect(applyFilter).not.toHaveBeenCalled(); + + tick(SearchTextDebounceInterval / 2); + + expect(applyFilter).toHaveBeenCalledWith("test2"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts index 154cd49c5a3..3419bd30ea0 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts @@ -7,17 +7,13 @@ import { Subscription, combineLatest, debounce, - debounceTime, distinctUntilChanged, filter, map, - switchMap, timer, } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service"; import { SearchModule } from "@bitwarden/components"; @@ -40,7 +36,6 @@ export class VaultV2SearchComponent { constructor( private vaultPopupItemsService: VaultPopupItemsService, private vaultPopupLoadingService: VaultPopupLoadingService, - private configService: ConfigService, private ngZone: NgZone, ) { this.subscribeToLatestSearchText(); @@ -63,31 +58,19 @@ export class VaultV2SearchComponent { } subscribeToApplyFilter(): void { - this.configService - .getFeatureFlag$(FeatureFlag.VaultLoadingSkeletons) + combineLatest([this.searchText$, this.loading$]) .pipe( - switchMap((enabled) => { - if (!enabled) { - return this.searchText$.pipe( - debounceTime(SearchTextDebounceInterval), - distinctUntilChanged(), - ); - } - - return combineLatest([this.searchText$, this.loading$]).pipe( - debounce(([_, isLoading]) => { - // If loading apply immediately to avoid stale searches. - // After loading completes, debounce to avoid excessive searches. - const delayTime = isLoading ? 0 : SearchTextDebounceInterval; - return timer(delayTime); - }), - distinctUntilChanged( - ([prevText, prevLoading], [newText, newLoading]) => - prevText === newText && prevLoading === newLoading, - ), - map(([text, _]) => text), - ); + debounce(([_, isLoading]) => { + // If loading apply immediately to avoid stale searches. + // After loading completes, debounce to avoid excessive searches. + const delayTime = isLoading ? 0 : SearchTextDebounceInterval; + return timer(delayTime); }), + distinctUntilChanged( + ([prevText, prevLoading], [newText, newLoading]) => + prevText === newText && prevLoading === newLoading, + ), + map(([text, _]) => text), takeUntilDestroyed(), ) .subscribe((text) => { diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html index 6382b5fee0e..f0a6b0d6000 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html @@ -1,4 +1,4 @@ - + @@ -8,31 +8,28 @@ - -
- - - @if (skeletonFeatureFlag$ | async) { - - + @if (vaultState === VaultStateEnum.Empty) { + +
+ + {{ "yourVaultIsEmpty" | i18n }} + +

+ {{ "emptyVaultDescription" | i18n }} +

+
+ + {{ "newLogin" | i18n }} + +
+
- } @else { - }
- - - - - - - - - @if (skeletonFeatureFlag$ | async) { - - + @if (vaultState === null) { + + @if (!(loading$ | async)) { + + @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-v2/vault-v2.component.spec.ts index e6dffdaff08..a956b2fe68b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts @@ -1,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,6 @@ import { BehaviorSubject, Observable, Subject, of } from "rxjs"; import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; import { NudgeType, NudgesService } from "@bitwarden/angular/vault"; -import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; import { AutoConfirmExtensionSetupDialogComponent, AutomaticUserConfirmationService, @@ -45,6 +44,7 @@ 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"; @@ -175,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([])), @@ -222,12 +228,6 @@ describe("VaultV2Component", () => { hasPremiumFromAnySource$: (_: string) => hasPremiumFromAnySource$, }; - const vaultProfileSvc = { - getProfileCreationDate: jest - .fn() - .mockResolvedValue(new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)), // 8 days ago - }; - const configSvc = { getFeatureFlag$: jest.fn().mockImplementation((_flag: string) => of(false)), }; @@ -244,21 +244,19 @@ describe("VaultV2Component", () => { await TestBed.configureTestingModule({ imports: [VaultV2Component, 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) }, @@ -376,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(VaultV2Component); + 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", () => { @@ -416,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(() => { @@ -465,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(); @@ -477,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); @@ -562,10 +555,6 @@ describe("VaultV2Component", () => { itemsSvc.cipherCount$.next(10); hasPremiumFromAnySource$.next(false); - vaultProfileSvc.getProfileCreationDate = jest - .fn() - .mockResolvedValue(new Date(Date.now() - 3 * 24 * 60 * 60 * 1000)); // 3 days ago - (nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => { return of(type === NudgeType.PremiumUpgrade); }); @@ -610,6 +599,127 @@ describe("VaultV2Component", () => { 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(VaultV2Component); + 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(VaultV2Component); + 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(VaultV2Component); + 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(VaultV2Component); + 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(VaultV2Component); + 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(); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index 761b366bcd2..a5a74eb8ab8 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -1,30 +1,28 @@ 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, - withLatestFrom, tap, - BehaviorSubject, + withLatestFrom, } from "rxjs"; import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component"; -import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; import { DeactivatedOrg, NoResults, VaultOpen } from "@bitwarden/assets/svg"; import { AutoConfirmExtensionSetupDialogComponent, @@ -47,6 +45,7 @@ import { ButtonModule, DialogService, NoItemsModule, + ScrollLayoutService, ToastService, TypographyModule, } from "@bitwarden/components"; @@ -57,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"; @@ -119,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 VaultV2Component implements OnInit, OnDestroy { NudgeType = NudgeType; cipherType = CipherType; private activeUserId$ = this.accountService.activeAccount$.pipe(getUserId); @@ -161,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$; @@ -180,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 }), ); @@ -219,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(), @@ -276,7 +261,6 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { private router: Router, private autoConfirmService: AutomaticUserConfirmationService, private toastService: ToastService, - private vaultProfileService: VaultProfileService, private billingAccountService: BillingAccountProfileStateService, private liveAnnouncer: LiveAnnouncer, private i18nService: I18nService, @@ -308,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)); @@ -379,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-v2/view-v2/view-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html index d2a4aaab3f0..8ac6de75997 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html @@ -1,6 +1,13 @@ - + + @if (cipher?.isArchived) { + + {{ "archived" | i18n }} + + } + + @if (cipher) { @@ -19,7 +26,7 @@ } - @if ((archiveFlagEnabled$ | async) && cipher.isArchived) { + @if ((archiveFlagEnabled$ | async) && cipher.isArchived && !cipher.isDeleted) { + + } + @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..a34609bd8f8 100644 --- a/apps/browser/src/vault/popup/settings/archive.component.ts +++ b/apps/browser/src/vault/popup/settings/archive.component.ts @@ -1,9 +1,13 @@ import { CommonModule } from "@angular/common"; import { Component, inject } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; -import { firstValueFrom, map, Observable, startWith, switchMap } from "rxjs"; +import { combineLatest, firstValueFrom, map, Observable, startWith, switchMap } from "rxjs"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -25,16 +29,20 @@ import { SectionHeaderComponent, ToastService, TypographyModule, + CardComponent, + ButtonComponent, } from "@bitwarden/components"; import { CanDeleteCipherDirective, DecryptionFailureDialogComponent, + OrgIconDirective, PasswordRepromptService, } from "@bitwarden/vault"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; +import { ROUTES_AFTER_EDIT_DELETION } from "../components/vault-v2/add-edit/add-edit-v2.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -55,6 +63,9 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co SectionComponent, SectionHeaderComponent, TypographyModule, + OrgIconDirective, + CardComponent, + ButtonComponent, ], }) export class ArchiveComponent { @@ -67,13 +78,38 @@ export class ArchiveComponent { private i18nService = inject(I18nService); private cipherArchiveService = inject(CipherArchiveService); private passwordRepromptService = inject(PasswordRepromptService); + private organizationService = inject(OrganizationService); + private collectionService = inject(CollectionService); private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); + private readonly orgMap = toSignal( + this.userId$.pipe( + switchMap((userId) => + this.organizationService.organizations$(userId).pipe( + map((orgs) => { + const map = new Map(); + for (const org of orgs) { + map.set(org.id, org); + } + return map; + }), + ), + ), + ), + ); + + private readonly collections = toSignal( + this.userId$.pipe(switchMap((userId) => this.collectionService.decryptedCollections$(userId))), + ); + protected archivedCiphers$ = this.userId$.pipe( switchMap((userId) => this.cipherArchiveService.archivedCiphers$(userId)), ); + protected userCanArchive$ = this.userId$.pipe( + switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)), + ); protected CipherViewLikeUtils = CipherViewLikeUtils; protected loading$ = this.archivedCiphers$.pipe( @@ -81,13 +117,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/trash-list-items-container/trash-list-items-container.component.ts b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts index bad6011b2d8..edebdab062f 100644 --- a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts @@ -115,15 +115,22 @@ export class TrashListItemsContainerComponent { } async restore(cipher: PopupCipherViewLike) { + let toastMessage; try { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); await this.cipherService.restoreWithServer(cipher.id as string, activeUserId); + if (cipher.archivedDate) { + toastMessage = this.i18nService.t("archivedItemRestored"); + } else { + toastMessage = this.i18nService.t("restoredItem"); + } + await this.router.navigate(["/trash"]); this.toastService.showToast({ variant: "success", title: null, - message: this.i18nService.t("restoredItem"), + message: toastMessage, }); } catch (e) { this.logService.error(e); diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html index d5b94df5008..c84188af863 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html @@ -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-v2.component.spec.ts index 15ddb7507fd..554570de7f9 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts @@ -195,10 +195,10 @@ describe("VaultSettingsV2Component", () => { expect(component["userHasArchivedItems"]()).toBe(false); }); - it("hides premium badge when user has archived items", () => { + it("shows premium badge when user has archived items but cannot archive", () => { setArchiveState(false, [{ id: "cipher1" } as CipherView]); - expect(component["premiumBadgeComponent"]()).toBeUndefined(); + expect(component["premiumBadgeComponent"]()).toBeTruthy(); expect(component["userHasArchivedItems"]()).toBe(true); }); }); diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts index c1d90d678cb..c35345bd8ab 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.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"; @@ -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..79653ec970f 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,7 +81,7 @@ "lowdb": "1.0.0", "lunr": "2.3.9", "multer": "2.0.2", - "node-fetch": "2.6.12", + "node-fetch": "2.7.0", "node-forge": "1.3.2", "open": "11.0.0", "papaparse": "5.5.3", 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 7252dd32afc..1bf0511bdb0 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..848a0e08b29 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -147,11 +147,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 +256,7 @@ export class ServiceContainer { twoFactorApiService: TwoFactorApiService; hibpApiService: HibpApiService; environmentService: EnvironmentService; + cipherSdkService: CipherSdkService; cipherService: CipherService; folderService: InternalFolderService; organizationUserApiService: OrganizationUserApiService; @@ -439,7 +442,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 +502,7 @@ export class ServiceContainer { this.accountService, this.stateProvider, this.kdfConfigService, + this.accountCryptographicStateService, ); const pinStateService = new PinStateService(this.stateProvider); @@ -605,6 +615,8 @@ export class ServiceContainer { this.keyGenerationService, this.sendStateProvider, this.encryptService, + this.cryptoFunctionService, + this.configService, ); this.cipherFileUploadService = new CipherFileUploadService( @@ -635,10 +647,6 @@ export class ServiceContainer { this.accountService, ); - this.accountCryptographicStateService = new DefaultAccountCryptographicStateService( - this.stateProvider, - ); - const sdkClientFactory = flagEnabled("sdk") ? new DefaultSdkClientFactory() : new NoopSdkClientFactory(); @@ -794,6 +802,8 @@ export class ServiceContainer { this.logService, ); + this.cipherSdkService = new DefaultCipherSdkService(this.sdkService, this.logService); + this.cipherService = new CipherService( this.keyService, this.domainSettingsService, @@ -809,6 +819,7 @@ export class ServiceContainer { this.logService, this.cipherEncryptionService, this.messagingService, + this.cipherSdkService, ); this.cipherArchiveService = new DefaultCipherArchiveService( @@ -1058,7 +1069,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 91e579c26c1..ad4ff9c4e18 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -11,6 +11,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; 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"; @@ -18,7 +19,6 @@ 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 2c6d41d66ac..0709a33b88f 100644 --- a/apps/cli/src/tools/send/commands/edit.command.ts +++ b/apps/cli/src/tools/send/commands/edit.command.ts @@ -7,6 +7,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; 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"; @@ -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/models/send.response.ts b/apps/cli/src/tools/send/models/send.response.ts index b7655226be0..c8182cbfaf8 100644 --- a/apps/cli/src/tools/send/models/send.response.ts +++ b/apps/cli/src/tools/send/models/send.response.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { Utils } from "@bitwarden/common/platform/misc/utils"; 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 869d77a379c..a84b6c15ead 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -6,6 +6,7 @@ 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/types/send-type"; @@ -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; @@ -199,7 +208,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 +224,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 +244,7 @@ export class SendProgram extends BaseProgram { }); } - private editCommand(): Command { + private editCommand(emailAuthEnabled: any): Command { return new Command("edit") .argument( "[encodedJson]", @@ -243,6 +260,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 +284,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 +351,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/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/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 24c280d90aa..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" @@ -1994,10 +2116,11 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.6" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" dependencies = [ + "byteorder", "lazy_static", "libm", "num-integer", @@ -2018,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" @@ -2257,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", @@ -2267,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]] @@ -2316,7 +2445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.9.0", ] [[package]] @@ -2442,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" @@ -2623,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" @@ -2652,9 +2807,9 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.10" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" dependencies = [ "const-oid", "digest", @@ -2767,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" @@ -2913,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", @@ -2928,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", @@ -2950,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", @@ -3010,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" @@ -3083,6 +3295,9 @@ dependencies = [ "bcrypt-pbkdf", "ed25519-dalek", "num-bigint-dig", + "p256", + "p384", + "p521", "rand_core 0.6.4", "rsa", "sec1", @@ -3094,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" @@ -3231,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" @@ -3298,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", @@ -3328,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", ] @@ -3350,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", @@ -3361,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", @@ -3372,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", @@ -3405,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 aecad6cb1d2..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" @@ -50,7 +51,7 @@ oo7 = "=0.5.0" pin-project = "=1.1.10" pkcs8 = "=0.10.2" rand = "=0.9.2" -rsa = "=0.9.10" +rsa = "=0.9.6" russh-cryptovec = "=0.7.3" scopeguard = "=1.2.0" secmem-proc = "=0.3.7" @@ -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/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.json b/apps/desktop/electron-builder.json index 83bd2921551..481d12f02b4 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -85,7 +85,8 @@ "signIgnore": [ "MacOS/desktop_proxy", "MacOS/desktop_proxy.inherit", - "Contents/Plugins/autofill-extension.appex" + "Contents/Plugins/autofill-extension.appex", + "Frameworks/Electron Framework.framework/(Electron Framework|Libraries|Resources|Versions/Current)/.*" ], "target": ["dmg", "zip"] }, diff --git a/apps/desktop/macos/autofill-extension/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 ad20e7c0e69..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,16 +18,16 @@ "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": "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", @@ -46,7 +46,7 @@ "pack:mac:with-extension": "npm run clean:dist && npm run build:macos-extension:mac && electron-builder --mac --universal -p never", "pack:mac:arm64": "npm run clean:dist && electron-builder --mac --arm64 -p never", "pack:mac:mas": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never", - "pack:mac:masdev": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never", + "pack:mac:masdev": "npm run clean:dist && electron-builder --mac mas-dev --universal -p never -c.mac.identity=null -c.mas.identity=$CSC_NAME -c.mas.provisioningProfile=bitwarden_desktop_developer_id.provisionprofile -c.mas.entitlements=resources/entitlements.mas.autofill-enabled.plist", "pack:local:mac": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never -c.mac.provisioningProfile=\"\" -c.mas.provisioningProfile=\"\"", "pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never", "pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never", diff --git a/apps/desktop/resources/entitlements.mas.autofill-enabled.plist b/apps/desktop/resources/entitlements.mas.autofill-enabled.plist new file mode 100644 index 00000000000..f25780e5c12 --- /dev/null +++ b/apps/desktop/resources/entitlements.mas.autofill-enabled.plist @@ -0,0 +1,42 @@ + + + + + com.apple.application-identifier + LTZ2PFU5D6.com.bitwarden.desktop + com.apple.developer.team-identifier + LTZ2PFU5D6 + com.apple.security.app-sandbox + + com.apple.security.application-groups + + LTZ2PFU5D6.com.bitwarden.desktop + + com.apple.security.cs.allow-jit + + com.apple.security.device.usb + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.temporary-exception.files.home-relative-path.read-write + + /Library/Application Support/Mozilla/NativeMessagingHosts/ + /Library/Application Support/Google/Chrome/NativeMessagingHosts/ + /Library/Application Support/Google/Chrome Beta/NativeMessagingHosts/ + /Library/Application Support/Google/Chrome Dev/NativeMessagingHosts/ + /Library/Application Support/Google/Chrome Canary/NativeMessagingHosts/ + /Library/Application Support/Chromium/NativeMessagingHosts/ + /Library/Application Support/Microsoft Edge/NativeMessagingHosts/ + /Library/Application Support/Microsoft Edge Beta/NativeMessagingHosts/ + /Library/Application Support/Microsoft Edge Dev/NativeMessagingHosts/ + /Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/ + /Library/Application Support/Vivaldi/NativeMessagingHosts/ + /Library/Application Support/Zen/NativeMessagingHosts/ + /Library/Application Support/net.imput.helium + + com.apple.developer.authentication-services.autofill-credential-provider + + + diff --git a/apps/desktop/resources/entitlements.mas.plist b/apps/desktop/resources/entitlements.mas.plist index 226e9827e37..9760af69e8b 100644 --- a/apps/desktop/resources/entitlements.mas.plist +++ b/apps/desktop/resources/entitlements.mas.plist @@ -34,7 +34,7 @@ /Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/ /Library/Application Support/Vivaldi/NativeMessagingHosts/ /Library/Application Support/Zen/NativeMessagingHosts/ - /Library/Application Support/net.imput.helium + /Library/Application Support/net.imput.helium/NativeMessagingHosts/ diff --git a/apps/desktop/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 34378ee092b..091a9ce951e 100644 --- a/apps/desktop/scripts/after-pack.js +++ b/apps/desktop/scripts/after-pack.js @@ -45,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/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/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-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()) { -
diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index aa238922eea..fe4b7f1f96f 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -25,13 +25,7 @@ import { tap, } from "rxjs/operators"; -import { - CollectionData, - CollectionDetailsResponse, - CollectionService, - CollectionView, - Unassigned, -} from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { NoResults, @@ -39,7 +33,7 @@ import { EmptyTrash, FavoritesIcon, ItemTypes, - Icon, + BitSvg, } from "@bitwarden/assets/svg"; import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -50,7 +44,17 @@ import { } 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 { + CollectionDetailsResponse, + CollectionView, + Unassigned, + CollectionData, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { + getNestedCollectionTree, + getFlatCollectionTree, +} from "@bitwarden/common/admin-console/utils"; 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"; @@ -97,6 +101,15 @@ import { DecryptionFailureDialogComponent, DefaultCipherFormConfigService, PasswordRepromptService, + VaultFilterServiceAbstraction as VaultFilterService, + RoutedVaultFilterBridgeService, + RoutedVaultFilterService, + createFilterFunction, + All, + RoutedVaultFilterModel, + VaultFilter, + FolderFilter, + OrganizationFilter, VaultItemsTransferService, DefaultVaultItemsTransferService, } from "@bitwarden/vault"; @@ -104,10 +117,6 @@ import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/in import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; -import { - getNestedCollectionTree, - getFlatCollectionTree, -} from "../../admin-console/organizations/collections"; import { AutoConfirmPolicy, AutoConfirmPolicyDialogComponent, @@ -140,16 +149,6 @@ import { } from "./bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component"; import { VaultBannersComponent } from "./vault-banners/vault-banners.component"; import { VaultFilterComponent } from "./vault-filter/components/vault-filter.component"; -import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service"; -import { RoutedVaultFilterBridgeService } from "./vault-filter/services/routed-vault-filter-bridge.service"; -import { RoutedVaultFilterService } from "./vault-filter/services/routed-vault-filter.service"; -import { createFilterFunction } from "./vault-filter/shared/models/filter-function"; -import { - All, - RoutedVaultFilterModel, -} from "./vault-filter/shared/models/routed-vault-filter.model"; -import { VaultFilter } from "./vault-filter/shared/models/vault-filter.model"; -import { FolderFilter, OrganizationFilter } from "./vault-filter/shared/models/vault-filter.type"; import { VaultFilterModule } from "./vault-filter/vault-filter.module"; import { VaultHeaderComponent } from "./vault-header/vault-header.component"; import { VaultOnboardingComponent } from "./vault-onboarding/vault-onboarding.component"; @@ -161,7 +160,7 @@ type EmptyStateType = "trash" | "favorites" | "archive"; type EmptyStateItem = { title: string; description: string; - icon: Icon; + icon: BitSvg; }; type EmptyStateMap = Record; @@ -747,7 +746,8 @@ export class VaultComponent implements OnInit, OnDestr const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "archiveItem" }, - content: { key: "archiveItemConfirmDesc" }, + content: { key: "archiveItemDialogContent" }, + acceptButtonText: { key: "archiveVerb" }, type: "info", }); @@ -925,6 +925,7 @@ export class VaultComponent implements OnInit, OnDestr const dialogRef = AttachmentsV2Component.open(this.dialogService, { cipherId: cipher.id as CipherId, organizationId: cipher.organizationId as OrganizationId, + canEditCipher: cipher.edit, }); const result: AttachmentDialogCloseResult = await lastValueFrom(dialogRef.closed); @@ -1271,6 +1272,7 @@ export class VaultComponent implements OnInit, OnDestr } restore = async (c: C): Promise => { + let toastMessage; if (!CipherViewLikeUtils.isDeleted(c)) { return; } @@ -1284,13 +1286,19 @@ export class VaultComponent implements OnInit, OnDestr return; } + if (CipherViewLikeUtils.isArchived(c)) { + toastMessage = this.i18nService.t("archivedItemRestored"); + } else { + toastMessage = this.i18nService.t("restoredItem"); + } + try { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); await this.cipherService.restoreWithServer(uuidAsString(c.id), activeUserId); this.toastService.showToast({ variant: "success", title: null, - message: this.i18nService.t("restoredItem"), + message: toastMessage, }); this.refresh(); } catch (e) { @@ -1299,11 +1307,18 @@ export class VaultComponent implements OnInit, OnDestr }; async bulkRestore(ciphers: C[]) { + let toastMessage; if (ciphers.some((c) => !c.edit)) { this.showMissingPermissionsError(); return; } + if (ciphers.some((c) => !CipherViewLikeUtils.isArchived(c))) { + toastMessage = this.i18nService.t("restoredItems"); + } else { + toastMessage = this.i18nService.t("archivedItemsRestored"); + } + if (!(await this.repromptCipher(ciphers))) { return; } @@ -1323,7 +1338,7 @@ export class VaultComponent implements OnInit, OnDestr this.toastService.showToast({ variant: "success", title: null, - message: this.i18nService.t("restoredItems"), + message: toastMessage, }); this.refresh(); } @@ -1522,8 +1537,7 @@ export class VaultComponent implements OnInit, OnDestr const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const cipherFullView = await this.cipherService.getFullCipherView(cipher); cipherFullView.favorite = !cipherFullView.favorite; - const encryptedCipher = await this.cipherService.encrypt(cipherFullView, activeUserId); - await this.cipherService.updateWithServer(encryptedCipher); + await this.cipherService.updateWithServer(cipherFullView, activeUserId); this.toastService.showToast({ variant: "success", diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts index c6a7c9c830d..bd97ed4ea55 100644 --- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts @@ -1,18 +1,18 @@ import { TestBed } from "@angular/core/testing"; import { BehaviorSubject, of } from "rxjs"; -import { CollectionAdminService, CollectionAdminView } from "@bitwarden/admin-console/common"; +import { CollectionAdminService } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; +import { CollectionAdminView } 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 { mockAccountServiceWith } from "@bitwarden/common/spec"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; - -import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service"; +import { RoutedVaultFilterService } from "@bitwarden/vault"; import { AdminConsoleCipherFormConfigService } from "./admin-console-cipher-form-config.service"; diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts index 939729568e9..01a2f23f4e1 100644 --- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts @@ -16,9 +16,12 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; -import { CipherFormConfig, CipherFormConfigService, CipherFormMode } from "@bitwarden/vault"; - -import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service"; +import { + CipherFormConfig, + CipherFormConfigService, + CipherFormMode, + RoutedVaultFilterService, +} from "@bitwarden/vault"; /** Admin Console implementation of the `CipherFormConfigService`. */ @Injectable() diff --git a/apps/web/src/connectors/platform/proxy-cookie-redirect.html b/apps/web/src/connectors/platform/proxy-cookie-redirect.html new file mode 100644 index 00000000000..1daa6d2e412 --- /dev/null +++ b/apps/web/src/connectors/platform/proxy-cookie-redirect.html @@ -0,0 +1,29 @@ + + + + + + + + Bitwarden Web vault + + + + + + + + + +
+ Bitwarden +
+ +
+
+ + diff --git a/apps/web/src/connectors/platform/proxy-cookie-redirect.ts b/apps/web/src/connectors/platform/proxy-cookie-redirect.ts new file mode 100644 index 00000000000..79c5092caab --- /dev/null +++ b/apps/web/src/connectors/platform/proxy-cookie-redirect.ts @@ -0,0 +1,17 @@ +/** + * ONLY FOR SELF-HOSTED SETUPS + * Redirects the user to the SSO cookie vendor endpoint when the window finishes loading. + * + * This script listens for the window's load event and automatically redirects the browser + * to the `api/sso-cookie-vendor` path on the current origin. This is used as part + * of an authentication flow where cookies need to be set or validated through a vendor endpoint. + */ +window.addEventListener("DOMContentLoaded", () => { + const origin = window.location.origin; + let apiURL = `${window.location.origin}/api/sso-cookie-vendor`; + // Override for local testing + if (origin.startsWith("https://localhost")) { + apiURL = "http://localhost:4000/sso-cookie-vendor"; + } + window.location.href = apiURL; +}); diff --git a/apps/web/src/images/integrations/logo-huntress-siem.svg b/apps/web/src/images/integrations/logo-huntress-siem.svg new file mode 100644 index 00000000000..06f2a3443c0 --- /dev/null +++ b/apps/web/src/images/integrations/logo-huntress-siem.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/locales/af/messages.json b/apps/web/src/locales/af/messages.json index 1155942b1fd..fb359d3a02e 100644 --- a/apps/web/src/locales/af/messages.json +++ b/apps/web/src/locales/af/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "E-pos" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefoon" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Kies alles" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Onkies alles" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Nee" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Wysig Versameling" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Stel gekose terug" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Teruggestelde item" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Teks" }, - "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": "Skep nuwe Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Een of meer organisasiebeleide verhoed u om u persoonlike kluis uit te stuur." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Kies SSO-tipe" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Kies ’n plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/ar/messages.json b/apps/web/src/locales/ar/messages.json index b37be2c6dfd..5a58089abf9 100644 --- a/apps/web/src/locales/ar/messages.json +++ b/apps/web/src/locales/ar/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "لا توجد تطبيقات حرجة في خطر" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "الوصول إلى الذكاء" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "مخاطر كلمة المرور" }, @@ -250,6 +274,9 @@ "application": { "message": "تطبيق" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "كلمات المرور المعرضة للخطر" }, @@ -586,6 +613,9 @@ "email": { "message": "البريد الإلكتروني" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "الهاتف" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "اختيار الكل" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "إلغاء اختيار الكل" }, @@ -1365,6 +1398,12 @@ "no": { "message": "لا" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "الموقع" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "تعديل المجموعة" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "بيانات المجموعة" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/az/messages.json b/apps/web/src/locales/az/messages.json index b7d9bfd8790..a9a25f658b5 100644 --- a/apps/web/src/locales/az/messages.json +++ b/apps/web/src/locales/az/messages.json @@ -1,33 +1,57 @@ { "allApplications": { - "message": "Bütün tətbiqlər" + "message": "Bütün proqramlar" }, "activity": { "message": "Fəaliyyət" }, "appLogoLabel": { - "message": "Bitwarden loqosu" + "message": "Açar alətləri" }, "criticalApplications": { - "message": "Kritik tətbiqlər" + "message": "Kritik proqramlar" }, "noCriticalAppsAtRisk": { "message": "Risk altında heç bir kritik tətbiq yoxdur" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { - "message": "Access Intelligence" + "message": "Giriş məlumatları" + }, + "noApplicationsMatchTheseFilters": { + "message": "Bu filtrlərlə uyuşan heç bir tətbiq yoxdur" }, "passwordRisk": { - "message": "Parol riski" + "message": "Açar təhlükəsi" }, "noEditPermissions": { - "message": "Bu elementə düzəliş etmə icazəniz yoxdur" + "message": "Bu bəndə düzəliş etmə icazəniz yoxdur" }, "reviewAtRiskPasswords": { - "message": "Tətbiqlər arasında riskli (zəif, ifşa olunmuş və ya təkrar istifadə olunmuş) parolları incələyin. İstifadəçilərinizin riskli parollara yönəlmiş təhlükəsizlik tədbirlərinə əhəmiyyət vermələri üçün kritik tətbiqlərinizi seçin." + "message": "Proqramlar arasında risk altında olan açarları (zəif, açıq və ya təkrar istifadə olunmuş) nəzərdən keçirin. İstehlakçılarınızın risk altında olan açarları həll etməsinə yönəlmiş təhlükəsizlik tədbirlərinə üstünlük vermək üçün ən vacib proqramlarınızı seçin." }, "reviewAtRiskLoginsPrompt": { - "message": "Riskli girişləri incələ" + "message": "Risk altında olan girişi nəzərdən keçirin" }, "dataLastUpdated": { "message": "Verilərin son güncəlləmə tarixi: $DATE$", @@ -39,10 +63,10 @@ } }, "noReportRan": { - "message": "Hələ heç bir hesabat yaratmamısınız" + "message": "Hələ heç bir hesabat qurmamısınız" }, "notifiedMembers": { - "message": "Məlumatlandırılan üzvlər" + "message": "Bildirilən üzvlər" }, "revokeMembers": { "message": "Üzvləri ləğv et" @@ -51,10 +75,10 @@ "message": "Üzvləri bərpa et" }, "cannotRestoreAccessError": { - "message": "Təşkilat erişimi bərpa edilə bilmir" + "message": "Təşkilata giriş bərpa edilə bilmir" }, "allApplicationsWithCount": { - "message": "Bütün tətbiqlər ($COUNT$)", + "message": "Bütün proqramlar ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -63,7 +87,7 @@ } }, "createNewLoginItem": { - "message": "Yeni giriş elementi yarat" + "message": "Yeni giriş elementi meydana gətirin" }, "percentageCompleted": { "message": "$PERCENT$% tamamlandı", @@ -88,28 +112,28 @@ } }, "passwordChangeProgress": { - "message": "Parol dəyişmə irəliləyişi" + "message": "Açar dəyişikliyi prosesi" }, "assignMembersTasksToMonitorProgress": { - "message": "İrəliləyişi izləmək üçün üzvlərə tapşırıqlar təyin edin" + "message": "Prosesi izləmək üçün üzvlərə tapşırıqlar təyin edin" }, "onceYouReviewApplications": { - "message": "Tətbiqləri incələyib kritik olaraq işarələdikdən sonra, üzvlərə parollarını dəyişməsi üçün tapşırıqlar təyin edin." + "message": "Proqramları nəzərdən keçirib kritik olaraq qeyd etdikdən sonra, üzvlərə açarları dəyişməsi üçün tapşırıqlar təyin edin." }, "sendReminders": { - "message": "Xatırlatma göndər" + "message": "Yaddaqalan qeydləri göndər" }, "onceYouMarkApplicationsCriticalTheyWillDisplayHere": { - "message": "Tətbiqləri kritik olaraq işarələsəniz, onlar burada nümayiş olunacaq." + "message": "Proqramları kritik olaraq qeyd etsəniz, onlar burada nümayiş olunacaq." }, "viewAtRiskMembers": { - "message": "Riskli üzvlərə bax" + "message": "Təhlükəli üzvlərə bax" }, "viewAtRiskApplications": { - "message": "Riskli tətbiqlərə bax" + "message": "Təhlükəli proqramlara bax" }, "criticalApplicationsAreAtRisk": { - "message": "$COUNT$/$TOTAL$ kritik tətbiq, riskli parollara görə risk altındadır", + "message": "$COUNT$/$TOTAL$ kritik tətbiq, təhlükəli şifrələrə görə risk altındadır", "placeholders": { "count": { "content": "$1", @@ -122,7 +146,7 @@ } }, "criticalApplicationsWithCount": { - "message": "Kritik tətbiqlər ($COUNT$)", + "message": "Kritik proqramlar ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -131,7 +155,7 @@ } }, "criticalApplicationsMarked": { - "message": "kritik olaraq işarələnmiş tətbiqlər" + "message": "kritik olaraq qeyd edilmiş proqramlar" }, "countOfCriticalApplications": { "message": "$COUNT$ kritik tətbiq", @@ -152,7 +176,7 @@ } }, "countOfAtRiskPasswords": { - "message": "$COUNT$ parol risk altındadır", + "message": "$COUNT$ Açar risk altındadır", "placeholders": { "count": { "content": "$1", @@ -161,7 +185,7 @@ } }, "newPasswordsAtRisk": { - "message": "$COUNT$ yeni parol risklidir", + "message": "$COUNT$ yeni açar təhlükə altındadır", "placeholders": { "count": { "content": "$1", @@ -170,7 +194,7 @@ } }, "notifiedMembersWithCount": { - "message": "Məlumatlandırılan üzvlər ($COUNT$)", + "message": "Bildirilən üzvlər ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -179,19 +203,19 @@ } }, "noDataInOrgTitle": { - "message": "Heç bir veri tapılmadı" + "message": "Heç bir məlumat aşkar olunmadı" }, "noDataInOrgDescription": { - "message": "Access Intelligence-i istifadə etməyə başlamaq üçün təşkilatınızın giriş verilərini daxilə köçürün. Bunu etdikdən sonra, bunları edə biləcəksiniz:" + "message": "Giriş məlumatı ilə başlamaq üçün müəssisənizin giriş məlumatlarını idxal edin. Bunu etdikdən sonra, bunları edə biləcəksiniz:" }, "feature1Title": { - "message": "Tətbiqləri kritik olaraq işarələmə" + "message": "Proqramları kritik kimi qeyd edin" }, "feature1Description": { - "message": "Bu, əvvəlcə ən vacib tətbiqlərinizdəki riskləri xaric etməyinizə kömək edəcək." + "message": "Bu, əvvəlcə ən vacib proqramlarınızdakı riskləri aradan qaldırmağa kömək edəcək." }, "feature2Title": { - "message": "Üzvlərin təhlükəsizliyini təkmilləşdirməsinə kömək" + "message": "Üzvlərə təhlükəsizliklərini yaxşılaşdırmağa kömək edin" }, "feature2Description": { "message": "Riskli üzvlərə kimlik məlumatlarını güncəlləməsi üçün təhlükəsizlik tapşırıqları təyin edin." @@ -221,7 +245,7 @@ "message": "Tətbiqi kritik olaraq işarələ" }, "markAsCritical": { - "message": "Kritik olaraq işarələ" + "message": "Kritik olaraq qeyd et" }, "applicationsSelected": { "message": "tətbiq seçildi" @@ -250,6 +274,9 @@ "application": { "message": "Tətbiq" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Riskli parollar" }, @@ -586,6 +613,9 @@ "email": { "message": "E-poçt" }, + "emails": { + "message": "E-poçtlar" + }, "phone": { "message": "Telefon" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Hamısını seç" }, + "deselectAll": { + "message": "Heç birini seçmə" + }, "unselectAll": { "message": "Heç birini seçmə" }, @@ -1365,6 +1398,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ə" }, @@ -1750,7 +1789,7 @@ "message": "Sadalanacaq heç bir üzv yoxdur." }, "noMembersToExport": { - "message": ".dillonvince767@gmail.com" + "message": "Xaricə köçürüləcək heç bir üzv yoxdur." }, "noEventsInList": { "message": "Sadalanacaq heç bir tədbir yoxdur." @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Növbəti ödəniş" }, + "nextChargeDate": { + "message": "Növbəti ödəniş vaxtı" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Kolleksiyaya düzəliş et" }, + "viewCollection": { + "message": "Kolleksiyaya bax" + }, "collectionInfo": { "message": "Kolleksiya məlumatı" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Seçiləni bərpa et" }, + "archivedItemRestored": { + "message": "Arxivlənmiş element bərpa edildi" + }, + "archivedItemsRestored": { + "message": "Arxivlənmiş elementlər bərpa edildi" + }, "restoredItem": { "message": "Element bərpa edildi" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Mətn" }, - "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\" yarat", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -5620,13 +5667,33 @@ "message": "Send uğurla yaradıldı!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Bu Send keçidini kopyala və paylaş. Send, təyin etdiyiniz keçidə və parola sahib olan hər kəs üçün növbəti $TIME$ ərzində əlçatan olacaq.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Bu Send keçidini kopyala və paylaş. Qeyd etdiyiniz şəxslər buna növbəti $TIME$ ərzində baxa bilər.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Təşkilat sahibliyini mərkəzləşdirin" + }, + "centralizeDataOwnershipDesc": { + "message": "Bütün üzv elementləri, təşkilat tərəfindən sahiblənəcək və idarə ediləcək. Adminlər və sahibləri daxil deyil. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Mərkəzi sahiblik barədə daha ətraflı", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Faydaları" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Paylaşılan və paylaşılmayan elementlər daxil olmaqla kimlik məlumatı sağlamlığına tam görünmə əldə edin." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Üzvlərin tərk etməsi və təhvil zamanı elementləri asanlıqla köçürün, erişim zamanı heç bir boşluğa yer buraxmayın." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Bütün istifadəçilərə öz giriş məlumatlarını idarə edə biləcəkləri \"Elementlərim\" sahəsi verin." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Üzvlərdən öz elementlərini köçürməsi soruşulsun" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "Üzvlərin fərdi seyflərində elementləri varsa, onlardan elementləri təşkilata köçürməsi, ya da tərk etməsi soruşulacaq. Əgər tərk etsələr, erişimləri ləğv ediləcək, ancaq istənilən vaxt bərpa edə biləcəklər." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Köçürmə barədə daha ətraflı" + }, "organizationDataOwnership": { "message": "Təşkilata veri üzərində məcburi sahiblik ver" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Bir və ya daha çox təşkilat siyasəti, fərdi seyfi xaricə köçürməyinizi əngəlləyir." }, - "activateAutofill": { - "message": "Avto-doldurmanı aktivləşdir" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Bütün mövcud və yeni üzvlər üçün brauzer uzantısında səhifə yüklənəndə avto-doldurmanı ayarını aktivləşdirin." + "activateAutofillPolicyDescription": { + "message": "Bütün mövcud və yeni üzvlər üçün brauzer uzantısında \"Səhifə yüklənəndə avto-doldur\" ayarını aktivləşdirin." }, - "experimentalFeature": { - "message": "Təhlükəli və ya güvənilməyən veb saytlar, səhifə yüklənərkən avto-doldurmanı istifadə edə bilər." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Avto-doldurma haqqında daha ətraflı" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "SSO növü seçin" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Seyf event verilərini Datadog serverinizə göndərin" }, + "huntressEventIntegrationDesc": { + "message": "Event verilərini öz \"Huntress SIEM instance\"na göndər" + }, "failedToSaveIntegration": { "message": "İnteqrasiya saxlanılmadı. Lütfən daha sonra yenidən sınayın." }, + "mustBeOrganizationOwnerAdmin": { + "message": "Bu əməliyyatı icra etmək üçün Təşkilatın Sahibi və ya Admin olmalısınız." + }, "mustBeOrgOwnerToPerformAction": { "message": "Bu əməliyyatı icra etmək üçün təşkilatın sahibi olmalısınız." }, @@ -10506,6 +10610,12 @@ "index": { "message": "İndeks" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Bir plan seçin" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden, ilk 72 saat ərzində domeni 3 dəfə götürməyə çalışacaq. Əgər domen götürülə bilməsə, \"host\"unuzdakı DNS qeydini yoxlayın və manual götürün. Domen götürülməsə, 7 gün ərzində təşkilatınızdan silinəcək." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden, domeni 72 saat ərzində götürməyə çalışacaq. Domen götürülə bilmirsə, DNS qeydinizi doğrulayın və manual götürün. Götürülməmiş domenlər 7 gün sonra xaric edilir." + }, + "automaticDomainClaimProcess2": { + "message": "Götürüldükdən sonra, domeni götürmüş mövcud üzvlərə e-poçtla məlumat veriləcək " + }, + "accountOwnershipChange": { + "message": "hesab sahibliyinin dəyişməsi" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ götürülmədi. DNS qeydlərinizi yoxlayın.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Götürüldü" }, - "domainStatusUnderVerification": { - "message": "Doğrulama altında" + "domainStatusPending": { + "message": "Gözlənilir" }, "claimedDomainsDescription": { "message": "Üzv hesablarına sahiblik etmək üçün bir domen götürün. Domen götürmüş üzvlər üçün giriş zamanı SSO identifikatoru səhifəsi ötürüləcək və inzibatçılar bu domenə aid hesabları silə biləcək." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Bu giriş risk altındadır və bir veb sayt əskikdir. Daha güclü təhlükəsizlik üçün bir veb sayt əlavə edin və parolu dəyişdirin." }, + "vulnerablePassword": { + "message": "Zəifliyi olan parol." + }, + "changeNow": { + "message": "İndi dəyişdir" + }, "missingWebsite": { "message": "Əskik veb sayt" }, @@ -11695,8 +11823,8 @@ "message": "Elementi arxivlə", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Elementləri arxivlə", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "İndi doğrula." }, + "unlockWithPasskey": { + "message": "Kilidi keçid açarı ilə aç" + }, + "prfUnlockFailed": { + "message": "Kilid keçid açarı ilə açılmadı. Lütfən yenidən sınayın, ya da başqa kilid açma üsulunu sınayın." + }, + "noPrfCredentialsAvailable": { + "message": "Kilidi açmaq üçün PRF dəstəkli keçid açarı yoxdur." + }, "additionalStorageGB": { "message": "Əlavə anbar sahəsi GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "Bütün $GB$ GB-lıq şifrələnmiş anbar sahənizi istifadə etmisiniz. Faylları saxlaya bilmək üçün daha çox anbar sahəsi əlavə edin." + }, + "whoCanView": { + "message": "Kimlər baxa bilər" + }, + "specificPeople": { + "message": "Xüsusi insanlar" + }, + "emailVerificationDesc": { + "message": "Bu Send keçidini paylaşdıqdan sonra, bu \"Send\"ə baxması üçün insanlar e-poçtlarını bir kodla doğrulamalıdırlar." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Birdən çox e-poçtu daxil edərkən vergül istifadə edin." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "Anbar sahəsini xaric etdiyiniz zaman, növbəti hesabınıza avtomatik olaraq köçürüləcək mütənasib hesab krediti alacaqsınız." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "\"Premium\"unuz var" + }, + "emailProtected": { + "message": "E-poçt qorunur" + }, + "invalidSendPassword": { + "message": "Yararsız Send parolu" + }, + "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." + }, + "perUser": { + "message": "bir istifadəçi üçün" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/be/messages.json b/apps/web/src/locales/be/messages.json index 83e68de6029..cfe2bf4fa23 100644 --- a/apps/web/src/locales/be/messages.json +++ b/apps/web/src/locales/be/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Кіраванне доступам" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Рызыка пароля" }, @@ -250,6 +274,9 @@ "application": { "message": "Праграма" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Паролі ў зоне рызыкі" }, @@ -586,6 +613,9 @@ "email": { "message": "Электронная пошта" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Тэлефон" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Выбраць усё" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Зняць выбар з усіх" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Рэдагаваць калекцыю" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Інфармацыя пра калекцыю" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Аднавіць выбранае" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Элемент адноўлены" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Адна або больш палітык арганізацыі не дазваляюць вам экспартаваць асабістае сховішча." }, - "activateAutofill": { - "message": "Актываваць аўтазапаўненне" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Актываваць аўтазапаўненне падчас загрузкі старонкі ў наладах пашырэння браўзера для ўсіх існуючых і новых удзельнікаў." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Скампраметаваныя або ненадзейныя вэб-сайты могуць задзейнічаць функцыю аўтазапаўнення падчас загрузкі старонкі." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Даведацца больш пра аўтазапаўненне" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Выберыце тып SSO" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/bg/messages.json b/apps/web/src/locales/bg/messages.json index 59552b67252..44fc1b675cb 100644 --- a/apps/web/src/locales/bg/messages.json +++ b/apps/web/src/locales/bg/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Няма важни приложения в риск" }, + "critical": { + "message": "Критични ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Некритични ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Критично" + }, "accessIntelligence": { "message": "Анализ на достъпа" }, + "noApplicationsMatchTheseFilters": { + "message": "Няма приложения отговарящи на тези филтри" + }, "passwordRisk": { "message": "Рискова парола" }, @@ -250,6 +274,9 @@ "application": { "message": "Приложение" }, + "applications": { + "message": "Приложения" + }, "atRiskPasswords": { "message": "Пароли в риск" }, @@ -586,6 +613,9 @@ "email": { "message": "Електронна поща" }, + "emails": { + "message": "Е-пощи" + }, "phone": { "message": "Телефон" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Избиране на всичко" }, + "deselectAll": { + "message": "Отмяна на избора" + }, "unselectAll": { "message": "Без избиране" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Всеки с връзката" + }, + "anyOneWithPassword": { + "message": "Всеки с парола, зададена от Вас" + }, "location": { "message": "Местоположение" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Следващо плащане" }, + "nextChargeDate": { + "message": "Следваща дата за таксуване" + }, "plan": { "message": "План" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Редактиране на колекция" }, + "viewCollection": { + "message": "Преглед на колекцията" + }, "collectionInfo": { "message": "Информация за колекцията" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Възстановяване на избраните" }, + "archivedItemRestored": { + "message": "Архивираният елемент е възстановен" + }, + "archivedItemsRestored": { + "message": "Архивираните елементи са възстановени" + }, "restoredItem": { "message": "Записът е възстановен" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "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." @@ -5620,13 +5667,33 @@ "message": "Изпращането е създадено успешно!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Копирайте и споделете връзката към Изпращането. То ще бъде достъпно за всеки с връзката в рамките на следващите $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Копирайте и споделете връзката към Изпращането. То ще бъде достъпно за всеки с връзката и паролата, която зададете, в рамките на следващите $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Копирайте и споделете връзка към това Изпращане. То ще може да бъде видяно само от хората, които сте посочили, в рамките на следващите $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Централизиране на собствеността на организацията" + }, + "centralizeDataOwnershipDesc": { + "message": "Всички данни на членовете ще се притежават и управляват от организацията. Това не включва администраторите и собствениците. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Научете повече относно централизираната собственост", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Предимства" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Получавате пълна видимост върху състоянието на данните, включително споделени и несподелени елементи." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Можете лесно да прехвърляте елементи при премахване и наследяване на потребители, така че да няма пролуки в достъпа." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Можете да дадете на всеки потребител негово собствено място, наречено „Моите елементи“, където всеки да съхранява свои лични данни за вписване." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Изискване от потребителите да прехвърлят данните си" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "Ако членовете имат данни в собствените си трезори, от тях ще бъде изискано да ги прехвърлят в организацията, или да напуснат. Ако напуснат, достъпът им ще бъде отнет, но и може да бъде възстановен по всяко време." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Научете повече относно прехвърлянето" + }, "organizationDataOwnership": { "message": "Задължителна собственост на организационните данни" }, @@ -6888,16 +6986,16 @@ "personalVaultExportPolicyInEffect": { "message": "Една или повече от настройките на организацията Ви не позволяват да изнасяте личния си трезор." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Включване на автоматичното попълване" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Включване на автоматичното попълване при зареждане на страница в браузърното разширение на всички текущи и бъдещи членове." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "Компроментирани и измамни уеб сайтове могат да се възползват от автоматичното попълване при зареждане на страницата." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Научете повече относно автоматичното попълване" }, "selectType": { @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Изпращане на данните за събитията в трезора към Вашата инсталация на Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Изпращане на данни за събитията до Вашата инстанция на Huntress SIEM" + }, "failedToSaveIntegration": { "message": "Интеграцията не беше запазена. Опитайте отново по-късно." }, + "mustBeOrganizationOwnerAdmin": { + "message": "Трябва да бъдете собственик на организацията или администратор, за да извършите това действие." + }, "mustBeOrgOwnerToPerformAction": { "message": "Трябва да бъдете собственик на организацията, за да извършите това действие." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Индекс" }, + "httpEventCollectorUrl": { + "message": "Адрес на събирача на събития по HTTP" + }, + "httpEventCollectorToken": { + "message": "Идентификатор на събирача на събития по HTTP" + }, "selectAPlan": { "message": "Изберете план" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Битуорден ще се опита да присвои домейна 3 пъти през първите 72 часа. Ако той не може да бъде присвоен, проверете записа за DNS в сървъра си и направете присвояването ръчно. Домейнът ще бъде премахнат от организацията Ви след 7 дни, ако не бъде присвоен." }, + "automaticDomainClaimProcess1": { + "message": "Битуорден ще се опита да присвои домейна в рамките на 72 часа. Ако той не може да бъде присвоен, проверете записа си в DNS и направете присвояването ръчно. Неприсвоените домейни се премахват след 7 дни." + }, + "automaticDomainClaimProcess2": { + "message": "След присвояването, текущите членове с присвоени домейни ще получат е-писмо относно " + }, + "accountOwnershipChange": { + "message": "промяната на собствеността на акаунта" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "Домейнът $DOMAIN$ не е присвоен. Проверете записите в DNS.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Присвоен" }, - "domainStatusUnderVerification": { - "message": "В процес на проверка" + "domainStatusPending": { + "message": "На изчакване" }, "claimedDomainsDescription": { "message": "Присвойте домейн, за да притежавате акаунтите на членовете. Страницата за еднократно удостоверяване ще бъде пропускана при вписването на членове с присвоени домейни, а администраторите ще могат да изтриват присвоените акаунти." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Този елемент за вписване е в риск и в него липсва уеб сайт. Добавете уеб сайт и сменете паролата, за по-добра сигурност." }, + "vulnerablePassword": { + "message": "Уязвима парола." + }, + "changeNow": { + "message": "Промяна сега" + }, "missingWebsite": { "message": "Липсващ уеб сайт" }, @@ -11695,8 +11823,8 @@ "message": "Архивиране на елемента", "description": "Verb" }, - "archiveItemConfirmDesc": { - "message": "Архивираните елементи са изключени от общите резултати при търсене и от предложенията за автоматично попълване. Наистина ли искате да архивирате този елемент?" + "archiveItemDialogContent": { + "message": "След като бъде архивиран, този елемент няма да се показва в резултатите при търсене, нито в предложенията за автоматично попълване." }, "archiveBulkItems": { "message": "Архивиране на елементите", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Потвърдете сега." }, + "unlockWithPasskey": { + "message": "Отключване със секретен ключ" + }, + "prfUnlockFailed": { + "message": "Отключването със секретен ключ не беше успешно. Опитайте отново или използвайте друг начин за отключване." + }, + "noPrfCredentialsAvailable": { + "message": "Няма секретни ключове с включено PRF, налични за отключване." + }, "additionalStorageGB": { "message": "Допълнително място в ГБ" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "Използвали сте всичките си $GB$ GB от наличното си място за съхранение на шифровани данни. Ако искате да продължите да добавяте файлове, добавете повече място за съхранение." + }, + "whoCanView": { + "message": "Кой може да преглежда" + }, + "specificPeople": { + "message": "Определени хора" + }, + "emailVerificationDesc": { + "message": "След като споделите тази връзка към Изпращане, хората ще трябва да потвърдят е-пощата си чрез код, за да могат да видят това Изпращане." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Можете да въведете повече е-пощи, като ги разделите със запетая." + }, + "emailPlaceholder": { + "message": "потребител@bitwarden.com , потребител@acme.com" + }, + "whenYouRemoveStorage": { + "message": "Когато премахнете съхранението, ще получите пропорционално задължение към акаунта си, което ще бъде включено автоматично в следващата Ви сметка." + }, + "ownerBadgeA11yDescription": { + "message": "Собственик, $OWNER$, показване на всички елементи собственост на $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "Имате платен абонамент" + }, + "emailProtected": { + "message": "Е-пощата е защитена" + }, + "invalidSendPassword": { + "message": "Неправилна парола за Изпращане" + }, + "sendPasswordHelperText": { + "message": "Хората ще трябва да въведат паролата, за да видят това Изпращане", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/bn/messages.json b/apps/web/src/locales/bn/messages.json index 4e3bf69ba6a..6175f78b814 100644 --- a/apps/web/src/locales/bn/messages.json +++ b/apps/web/src/locales/bn/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "ই-মেইল" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ফোন" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Select all" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Unselect all" }, @@ -1365,6 +1398,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/bs/messages.json b/apps/web/src/locales/bs/messages.json index e83e1f9c463..53570e37935 100644 --- a/apps/web/src/locales/bs/messages.json +++ b/apps/web/src/locales/bs/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "Imejl" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Odaberite Sve" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Poništite odabir" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/ca/messages.json b/apps/web/src/locales/ca/messages.json index 020f3d763c6..010426e3c55 100644 --- a/apps/web/src/locales/ca/messages.json +++ b/apps/web/src/locales/ca/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Intel·ligència d'accés" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Risc de contrasenya" }, @@ -250,6 +274,9 @@ "application": { "message": "Aplicació" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "Correu electrònic" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telèfon" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Selecciona-ho tot" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Anul·la tota la selecció" }, @@ -1365,6 +1398,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edita col·lecció" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Informació de col·lecció" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restaura selecció" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Element restaurat" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Una o més polítiques d'organització us impedeixen exportar la vostra caixa forta." }, - "activateAutofill": { - "message": "Activa l'emplenament automàtic" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activeu l'emplenament automàtic a la configuració de la càrrega de la pàgina en l'extensió del navegador per a tots els membres existents i nous." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Els llocs web compromesos o no fiables poden aprofitar l'emplenament automàtic en carregar de la pàgina." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Més informació sobre l'emplenament automàtic" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Selecciona el tipus d'SSO" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Seleccioneu un pla" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/cs/messages.json b/apps/web/src/locales/cs/messages.json index 518d6856054..84139941a7b 100644 --- a/apps/web/src/locales/cs/messages.json +++ b/apps/web/src/locales/cs/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Žádné ohrožené kritické aplikace" }, + "critical": { + "message": "Kritické ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Nekritické ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Kritické" + }, "accessIntelligence": { "message": "Přístup k inteligenci" }, + "noApplicationsMatchTheseFilters": { + "message": "Těmto filtrům neodpovídají žádné aplikace" + }, "passwordRisk": { "message": "Rizikové heslo" }, @@ -250,6 +274,9 @@ "application": { "message": "Aplikace" }, + "applications": { + "message": "Aplikace" + }, "atRiskPasswords": { "message": "Ohrožená hesla" }, @@ -586,6 +613,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "E-maily" + }, "phone": { "message": "Telefon" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Označit vše" }, + "deselectAll": { + "message": "Odznačit vše" + }, "unselectAll": { "message": "Odznačit vše" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Kdokoli s odkazem" + }, + "anyOneWithPassword": { + "message": "Kdokoli s heslem od Vás" + }, "location": { "message": "Umístění" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Další platba" }, + "nextChargeDate": { + "message": "Datum další platby" + }, "plan": { "message": "Plán" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Upravit sbírku" }, + "viewCollection": { + "message": "Zobrazit kolekci" + }, "collectionInfo": { "message": "Informace o sbírce" }, @@ -3830,7 +3875,7 @@ "message": "Tento uživatel by měl být nezávislý na poskytovateli. Pokud není poskytovatel spojen s organizací, tento uživatel si zachová vlastnictví organizace." }, "admin": { - "message": "Administrátor" + "message": "Správce" }, "adminDesc": { "message": "Spravuje přístup v organizaci, všechny sbírky, členy, hlášení a nastavení zabezpečení." @@ -5296,7 +5341,7 @@ "message": "Bude požadovat po uživatelích nastavení dvoufázového přihlášení pro jejich osobní účty." }, "twoStepLoginPolicyWarning": { - "message": "Členové organizace, kteří nejsou vlastníky nebo administrátory a kteří nemají povoleno dvoufázové přihlášení pro svůj osobní účet, budou odebráni z organizace a obdrží e-mail, který je o změně informuje." + "message": "Členové organizace, kteří nejsou vlastníky nebo správci a kteří nemají povoleno dvoufázové přihlášení pro svůj osobní účet, budou odebráni z organizace a obdrží e-mail, který je o změně informuje." }, "twoStepLoginPolicyUserWarning": { "message": "Jste členem organizace, která vyžaduje použití dvoufázové přihlášení na Vašem uživatelském účtu. Pokud zakážete všechny poskytovatele dvoufázového přihlášení, budete z takovýchto organizací automaticky odebráni." @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Obnovit vybrané" }, + "archivedItemRestored": { + "message": "Archivovaná položka byla obnovena" + }, + "archivedItemsRestored": { + "message": "Archivované položky byly obnoveny" + }, "restoredItem": { "message": "Položka byla obnovena" }, @@ -5547,7 +5598,7 @@ "message": "Omezí členům vstup do jiných organizací. Tato zásada je vyžadována u organizací, které povolily ověřování domény." }, "singleOrgBlockCreateMessage": { - "message": "Vaše aktuální organizace má pravidla, která Vám nedovolují připojit se k více než jedné organizaci. Obraťte se na administrátory organizace nebo se zaregistrujte z jiného účtu na Bitwardenu." + "message": "Vaše aktuální organizace má pravidla, která Vám nedovolují připojit se k více než jedné organizaci. Obraťte se na správce organizace nebo se zaregistrujte z jiného účtu na Bitwardenu." }, "singleOrgPolicyMemberWarning": { "message": "Nevyhovující členové budou zařazeni do statusu odvolaných členů, dokud neopustí všechny ostatní organizace. Správci jsou osvobozeni a mohou obnovit členy, jakmile je splněna podmínka shody." @@ -5568,7 +5619,7 @@ "message": "Jednotná pravidla organizace nebyla nastavena." }, "requireSsoExemption": { - "message": "Majitelé a administrátoři organizací jsou od prosazování těchto zásad osvobozeni." + "message": "Vlastníci a správci organizací jsou od prosazování těchto zásad osvobozeni." }, "limitSendViews": { "message": "Omezit zobrazení" @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send byl úspěšně vytvořen!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Zkopírujte a sdílejte tento odkaz Send. Send bude k dispozici komukoli s odkazem na dalších $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Zkopírujte a sdílejte tento odkaz Send. Send bude k dispozici komukoli s odkazem a heslem na dalších $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Zkopírujte a sdílejte tento Send pro odesílání. Můžou jej zobrazit osoby, které jste zadali, a to po dobu $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralizovat vlastnictví organizace" + }, + "centralizeDataOwnershipDesc": { + "message": "Všechny položky uživatele budou vlastněny a spravovány organizací. Správci a vlastníci jsou osvobozeni. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Zjistěte více o centralizovaném vlastnictví", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Výhody" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Získejte úplný přehled o stavu přihlašovacích údajů, včetně sdílených a nesdílených položek." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Snadno přenášjte položky při odchodu a nástupu nových uživatelů pro zajištění bezproblémového přístupu." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Všem uživatelům poskytněte vyhrazený prostor \"Moje položky\" pro správu jejich vlastních přihlašovacích údajů." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Vyzvěte uživatele, aby převedli své položky" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "Pokud mají uživatelé ve svém individuálním trezoru nějaké položky, budou vyzváni, aby je buď převedli do organizace, nebo odešli. Pokud odejdou, jejich přístup bude zrušen, ale může být kdykoli obnoven." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Více informací o převodu" + }, "organizationDataOwnership": { "message": "Vynutit vlastnictví dat organizace" }, @@ -6009,7 +6107,7 @@ "message": "Bude vyžadovat, aby členové ukládali položky do organizace tím, že odeberete možnost osobního trezoru." }, "personalOwnershipExemption": { - "message": "Majitelé a správci organizací jsou od prosazování těchto zásad osvobozeni." + "message": "Vlastníci a správci organizací jsou od prosazování těchto zásad osvobozeni." }, "personalOwnershipSubmitError": { "message": "Z důvodu podnikových zásad nemůžete ukládat položky do svého osobního trezoru. Změňte vlastnictví položky na organizaci a poté si vyberte z dostupných sbírek." @@ -6058,7 +6156,7 @@ "message": "Výchozí zjišťování shody URI" }, "uriMatchDetectionPolicyDesc": { - "message": "Určete, kdy jsou přihlášení navržena pro automatické vyplňování. Administrátoři a vlastníci jsou od těchto zásad osvobozeni." + "message": "Určete, kdy jsou přihlášení navržena pro automatické vyplňování. Správci a vlastníci jsou od těchto zásad osvobozeni." }, "uriMatchDetectionOptionsLabel": { "message": "Výchozí zjišťování shody URI" @@ -6623,7 +6721,7 @@ "description": "This is used as a table header to describe which client application created an event log." }, "providerAdmin": { - "message": "Administrátor poskytovatele" + "message": "Správce poskytovatele" }, "providerAdminDesc": { "message": "Uživatel s nejvyšším oprávněním, který může spravovat všechny aspekty Vašeho poskytovatele a také přístup a správu klientských organizací." @@ -6644,7 +6742,7 @@ "message": "Byly jste pozváni se připojit k výše uvedenému poskytovateli. Pro přijetí pozvánky se musíte přihlásit nebo si založit nový účet na Bitwardenu." }, "providerInviteAcceptFailed": { - "message": "Nelze přijmout pozvánku. Požádejte administrátora poskytovatele o zaslání nové pozvánky." + "message": "Nelze přijmout pozvánku. Požádejte správce poskytovatele o zaslání nové pozvánky." }, "providerInviteAcceptedDesc": { "message": "K tomuto poskytovateli můžete přistupovat jakmile správce potvrdí Vaše členství. Až se tak stane, pošleme Vám e-mail." @@ -6888,16 +6986,16 @@ "personalVaultExportPolicyInEffect": { "message": "Jedna nebo více zásad organizace Vám brání v exportu Vašeho osobního trezoru." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Aktivovat automatické vyplnění" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Aktivuje automatické vyplnění při načítání stránky pro rozšíření prohlížeče pro všechny existující i nové členy." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "Kompromitované nebo nedůvěryhodné webové stránky mohou zneužívat automatické vyplňování při načítání stránky." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Více informací o automatickém vyplňování" }, "selectType": { @@ -8771,7 +8869,7 @@ } }, "freeOrgInvLimitReachedNoManageBilling": { - "message": "Bezplatné organizace mohou mít až $SEATCOUNT$ členů. Pro aktualizaci kontaktujte majitele organizace.", + "message": "Bezplatné organizace mohou mít až $SEATCOUNT$ členů. Pro aktualizaci kontaktujte vlastníka organizace.", "placeholders": { "seatcount": { "content": "$1", @@ -8807,7 +8905,7 @@ } }, "freeOrgMaxCollectionReachedNoManageBilling": { - "message": "Bezplatné organizace mohou mít až $COLLECTIONCOUNT$ sbírek. Pro aktualizaci kontaktujte majitele organizace.", + "message": "Bezplatné organizace mohou mít až $COLLECTIONCOUNT$ sbírek. Pro aktualizaci kontaktujte vlastníka organizace.", "placeholders": { "COLLECTIONCOUNT": { "content": "$1", @@ -9660,7 +9758,7 @@ "message": "Nastaví chování sbírky pro organizaci" }, "allowAdminAccessToAllCollectionItemsDescription": { - "message": "Povolit vlastníkům a administrátorům spravovat všechny sbírky a položky z konzole administrace" + "message": "Povolit vlastníkům a správcům spravovat všechny sbírky a položky z konzole administrace" }, "restrictCollectionCreationDescription": { "message": "Omezí vytváření sbírky na vlastníky a správce" @@ -9804,7 +9902,7 @@ } }, "allowAdminAccessToAllCollectionItemsEnabled": { - "message": "Zapnuto nastavení povolení vlastníkům a administrátorům spravovat všechny sbírky a položky $ID$.", + "message": "Zapnuto nastavení povolení vlastníkům a správcům spravovat všechny sbírky a položky $ID$.", "placeholders": { "id": { "content": "$1", @@ -9813,7 +9911,7 @@ } }, "allowAdminAccessToAllCollectionItemsDisabled": { - "message": "Vypnuto nastavení povolení vlastníkům a administrátorům spravovat všechny sbírky a položky $ID$.", + "message": "Vypnuto nastavení povolení vlastníkům a správcům spravovat všechny sbírky a položky $ID$.", "placeholders": { "id": { "content": "$1", @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Odeslat data o trezoru do Vaší instance Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Odešle data události do Vaší instanci SIEM Huntress" + }, "failedToSaveIntegration": { "message": "Nepodařilo se uložit integraci. Opakujte akci později." }, + "mustBeOrganizationOwnerAdmin": { + "message": "Pro provedení této akce musíte být vlastníkem organizace nebo správcem." + }, "mustBeOrgOwnerToPerformAction": { "message": "Pro provedení této akce musíte být vlastníkem organizace." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "URL kolektoru HTTP událostí" + }, + "httpEventCollectorToken": { + "message": "Token kolektoru HTTP událostí" + }, "selectAPlan": { "message": "Vyberte plán" }, @@ -10976,7 +11086,7 @@ } }, "familiesPlanInvLimitReachedNoManageBilling": { - "message": "Bezplatné rodinné organizace mohou mít až $SEATCOUNT$ členů. Pro aktualizaci kontaktujte majitele organizace.", + "message": "Bezplatné rodinné organizace mohou mít až $SEATCOUNT$ členů. Pro aktualizaci kontaktujte vlastníka organizace.", "placeholders": { "seatcount": { "content": "$1", @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden se pokusí uplatnit doménu třikrát během prvních 72 hodin. Pokud doménu nelze uplatnit, zkontrolujte záznam DNS v hostitelském počítači a uplatněte ji ručně. Pokud se doménu nepodaří uplatnit, bude z Vaší organizace odebrána do 7 dnů." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden se pokusí nárokovat doménu do 72 hodin. Pokud doménu nelze nárokovat, ověřte svůj DNS záznam a nárokujte ručně. Nenárokované domény jsou odebrány po 7 dnech." + }, + "automaticDomainClaimProcess2": { + "message": "Po nárokování budou stávající členové s nárokovanými doménami obeznámeni e-mailem o " + }, + "accountOwnershipChange": { + "message": "změně vlastnictví účtu" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nebyla uplatněna. Zkontrolujte DNS záznamy.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Uplatněno" }, - "domainStatusUnderVerification": { - "message": "V ověřování" + "domainStatusPending": { + "message": "Čekající" }, "claimedDomainsDescription": { "message": "Požádejte o doménu, abyste mohli vlastnit členské účty. Stránka s identifikátorem SSO bude při přihlašování členů s deklarovanými doménami přeskočena a správci budou moci deklarované účty smazat." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Tyto přihlašovací údaje jsou ohrožené a chybí jim webová stránka. Přidejte webovou stránku a změňte heslo pro větší bezpečnost." }, + "vulnerablePassword": { + "message": "Zranitelné heslo." + }, + "changeNow": { + "message": "Změnit nyní" + }, "missingWebsite": { "message": "Chybějící webová stránka" }, @@ -11695,8 +11823,8 @@ "message": "Archivovat položku", "description": "Verb" }, - "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í." }, "archiveBulkItems": { "message": "Archivovat položky", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Ověřit nyní" }, + "unlockWithPasskey": { + "message": "Odemknout pomocí přístupového klíče" + }, + "prfUnlockFailed": { + "message": "Nepodařilo se odemknout pomocí přístupového klíče. Zkuste to znovu nebo použijte jinou metodu odemknutí." + }, + "noPrfCredentialsAvailable": { + "message": "K odemknutí nejsou k dispozici žádné přístupové klíče s podporou PRF." + }, "additionalStorageGB": { "message": "Další úložiště (GB)" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "Využili jste celých $GB$ GB Vašeho šifrovaného úložiště. Chcete-li pokračovat v ukládání souborů, přidejte další úložiště." + }, + "whoCanView": { + "message": "Kdo může zobrazit" + }, + "specificPeople": { + "message": "Vybraní lidé" + }, + "emailVerificationDesc": { + "message": "Po sdílení tohoto odkazu Send budou muset jednotlivci ověřit svůj e-mail pomocí kódu pro zobrazení tohoto Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Zadejte více e-mailů oddělených čárkou." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "Když odeberete úložiště, obdržíte kredit, který bude automaticky převeden do Vašeho dalšího vyúčtování." + }, + "ownerBadgeA11yDescription": { + "message": "Vlastník, $OWNER$, zobrazí všechny položky ve vlastnictví $OWNER$.", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "Máte Premium" + }, + "emailProtected": { + "message": "E-mail je chráněný" + }, + "invalidSendPassword": { + "message": "Neplatné heslo k Send" + }, + "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." + }, + "perUser": { + "message": "za uživatele" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/cy/messages.json b/apps/web/src/locales/cy/messages.json index 724b63f0f48..809021b63a3 100644 --- a/apps/web/src/locales/cy/messages.json +++ b/apps/web/src/locales/cy/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "Ebost" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Ffôn" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Select all" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Unselect all" }, @@ -1365,6 +1398,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/da/messages.json b/apps/web/src/locales/da/messages.json index 561f46ce391..fd206ae10cf 100644 --- a/apps/web/src/locales/da/messages.json +++ b/apps/web/src/locales/da/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Adgangsefterretning" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Adgangskoderisiko" }, @@ -250,6 +274,9 @@ "application": { "message": "Applikation" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Udsatte adgangskoder" }, @@ -586,6 +613,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Vælg alle" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Fravælg alle" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Nej" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Redigér samling" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Info om samling" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Gendan valgte" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Emne gendannet" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Tekst" }, - "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": "Ny Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "En eller flere organisationspolitikker forhindrer eksport af din personlige boks." }, - "activateAutofill": { - "message": "Aktivér autoudfyldning" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Aktivér indstillingen Autoudfyldning ved sideindlæsning i browserudvidelsen for alle eksisterende og nye medlemmer." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Kompromitterede eller ikke-betroede websteder kan udnytte autoudfyldning ved sideindlæsning." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Læs mere om autoudfyldning" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Vælg SSO-type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Vælg en abonnementstype" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden vil forsøge at registrere domænet 3 gange i løbet af de første 72 timer. Kan domænet ikke registreres, tjek DNS-posten på værten og registrér manuelt. Såfremt uregistreret efter 7 dage, fjernes domænet fra organisationen." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ ikke registreret. Tjek DNS-posterne.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Registreret" }, - "domainStatusUnderVerification": { - "message": "Under verifikation" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/de/messages.json b/apps/web/src/locales/de/messages.json index 690cc30f5b4..016ed2c73f4 100644 --- a/apps/web/src/locales/de/messages.json +++ b/apps/web/src/locales/de/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Keine kritischen Anwendungen gefährdet" }, + "critical": { + "message": "Kritisch ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Nicht kritisch ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Kritisch" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "Es gibt keine Anwendungen, die diesen Filtern entsprechen" + }, "passwordRisk": { "message": "Passwort-Risiko" }, @@ -250,6 +274,9 @@ "application": { "message": "Anwendung" }, + "applications": { + "message": "Anwendungen" + }, "atRiskPasswords": { "message": "Gefährdete Passwörter" }, @@ -586,6 +613,9 @@ "email": { "message": "E-Mail" }, + "emails": { + "message": "E-Mails" + }, "phone": { "message": "Telefon" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Alle auswählen" }, + "deselectAll": { + "message": "Alles abwählen" + }, "unselectAll": { "message": "Alle abwählen" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Nein" }, + "noAuth": { + "message": "Alle mit dem Link" + }, + "anyOneWithPassword": { + "message": "Alle mit einem von dir festgelegtem Passwort" + }, "location": { "message": "Standort" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Nächste Abbuchung" }, + "nextChargeDate": { + "message": "Nächstes Zahlungsdatum" + }, "plan": { "message": "Tarif" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Sammlung bearbeiten" }, + "viewCollection": { + "message": "Sammlung anzeigen" + }, "collectionInfo": { "message": "Sammlungsinformationen" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Auswahl wiederherstellen" }, + "archivedItemRestored": { + "message": "Archivierter Eintrag wiederhergestellt" + }, + "archivedItemsRestored": { + "message": "Archivierte Einträge wiederhergestellt" + }, "restoredItem": { "message": "Eintrag wiederhergestellt" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send erfolgreich erstellt!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Kopiere und teile diesen Send-Link. Das Send wird für jeden mit dem Link für die nächsten $TIME$ verfügbar sein.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Kopiere und teile diesen Send-Link. Das Send wird für jeden mit dem Link und dem von dir festgelegten Passwort für die nächsten $TIME$ verfügbar sein.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Kopiere und teile diesen Send-Link. Er kann von den von dir angegebenen Personen für die nächsten $TIME$ angesehen werden.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Zentralisierung der Eigentumsrechte an Organisationen" + }, + "centralizeDataOwnershipDesc": { + "message": "Alle Mitgliedereinträge gehen in den Besitz der Organisation über und werden von dieser verwaltet. Administratoren und Eigentümer sind davon ausgenommen. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Erfahre mehr über zentralisierte Eigentumsrechte", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Vorteile" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Verschaffe dir einen vollständigen Überblick über den Zustand deiner Zugangsdaten, einschließlich geteilter und nicht geteilter Einträge." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Übertrage Einträge bei Austritt und Ablösung von Mitgliedern ganz einfach und sorge dafür, dass keine Zugriffslücken entstehen." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Stelle allen Benutzern einen eigenen \"Meine Einträge\"-Bereich für die Verwaltung ihrer eigenen Zugangsdaten zur Verfügung." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Mitglieder auffordern, ihre Einträge zu übertragen" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "Wenn Mitglieder Einträge in ihrem eigenen Tresor haben, werden sie aufgefordert, diese entweder an die Organisation zu übertragen oder zu die Organisation verlassen. Wenn sie gehen, wird ihr Zugriff widerrufen, kann aber jederzeit wiederhergestellt werden." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Erfahre mehr über die Übertragung" + }, "organizationDataOwnership": { "message": "Eigentumsrechte an Unternehmensdaten erzwingen" }, @@ -6888,16 +6986,16 @@ "personalVaultExportPolicyInEffect": { "message": "Eine oder mehrere Unternehmensrichtlinien verhindern es, dass du deinen persönlichen Tresor exportieren kannst." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Auto-Ausfüllen aktivieren" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Aktiviere die Einstellung \"Auto-Ausfüllen beim Laden einer Seite\" in der Browser-Erweiterung für alle bestehenden und neuen Mitglieder." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "Kompromittierte oder nicht vertrauenswürdige Websites können Auto-Ausfüllen beim Laden der Seite ausnutzen." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Erfahre mehr über Auto-Ausfüllen" }, "selectType": { @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Tresor-Ereignisdaten an deine Datadog-Instanz senden" }, + "huntressEventIntegrationDesc": { + "message": "Sende Ereignisdaten an deine Huntress SIEM-Instanz" + }, "failedToSaveIntegration": { "message": "Fehler beim Speichern der Integration. Bitte versuche es später erneut." }, + "mustBeOrganizationOwnerAdmin": { + "message": "Du musst der Besitzer oder Administrator der Organisation sein, um diese Aktion ausführen zu können." + }, "mustBeOrgOwnerToPerformAction": { "message": "Du musst der Besitzer der Organisation sein, um diese Aktion auszuführen." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Ereignissammler-URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Ereignissammler-Token" + }, "selectAPlan": { "message": "Einen Tarif auswählen" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden wird in den ersten 72 Stunden 3 Mal versuchen, die Domain zu beanspruchen. Wenn die Domain nicht beansprucht werden kann, überprüfe den DNS-Eintrag auf deinem Host und beanspruche sie manuell. Die Domain wird in 7 Tagen aus deiner Organisation entfernt, wenn sie nicht beansprucht ist." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden wird versuchen, die Domain innerhalb von 72 Stunden zu beanspruchen. Wenn die Domain nicht beansprucht werden kann, überprüfe deinen DNS-Eintrag und beanspruche die Domain manuell. Nicht beanspruchte Domains werden nach 7 Tagen entfernt." + }, + "automaticDomainClaimProcess2": { + "message": "Einmal beansprucht, werden bestehende Mitglieder mit beanspruchten Domains per E-Mail über die " + }, + "accountOwnershipChange": { + "message": "Änderung des Kontoeigentums" + }, + "automaticDomainClaimProcessEnd": { + "message": "informiert." + }, "domainNotClaimed": { "message": "$DOMAIN$ nicht beansprucht. Überprüfe deine DNS-Einträge.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Beansprucht" }, - "domainStatusUnderVerification": { - "message": "In Verifizierung" + "domainStatusPending": { + "message": "Ausstehend" }, "claimedDomainsDescription": { "message": "Beanspruche eine Domain für eigene Mitgliederkonten. Die SSO-Kennungsseite wird beim Anmelden für Mitglieder mit beanspruchten Domains übersprungen und Administratoren werden in der Lage sein, beanspruchte Konten zu löschen." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Diese Zugangsdaten sind gefährdet und es fehlt eine Website. Füge eine Website hinzu und ändere das Passwort für mehr Sicherheit." }, + "vulnerablePassword": { + "message": "Gefährdetes Passwort." + }, + "changeNow": { + "message": "Jetzt ändern" + }, "missingWebsite": { "message": "Fehlende Webseite" }, @@ -11695,8 +11823,8 @@ "message": "Eintrag archivieren", "description": "Verb" }, - "archiveItemConfirmDesc": { - "message": "Archivierte Einträge werden von allgemeinen Suchergebnissen und Auto-Ausfüllen-Vorschlägen ausgeschlossen. Bist du sicher, dass du diesen Eintrag archivieren möchtest?" + "archiveItemDialogContent": { + "message": "Nach der Archivierung wird dieser Eintrag aus den Suchergebnissen und Auto-Ausfüllen-Vorschlägen ausgeschlossen." }, "archiveBulkItems": { "message": "Einträge archivieren", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Jetzt verifizieren." }, + "unlockWithPasskey": { + "message": "Mit Passkey entsperren" + }, + "prfUnlockFailed": { + "message": "Entsperren mit Passkey fehlgeschlagen. Bitte versuche es erneut oder verwende eine andere Entsperrmethode." + }, + "noPrfCredentialsAvailable": { + "message": "Es sind keine PRF-fähigen Passkeys zum Entsperren verfügbar." + }, "additionalStorageGB": { "message": "Zusätzlicher Speicher GB" }, @@ -12597,7 +12734,7 @@ "message": "Dein Abonnement wurde gekündigt am" }, "storageFull": { - "message": "Speicher voll" + "message": "Speicherplatz voll" }, "storageUsedDescription": { "message": "Du hast $USED$ von $AVAILABLE$ GB deines verschlüsselten Datenspeichers verwendet.", @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "Du hast die gesamten $GB$ GB deines verschlüsselten Speichers verwendet. Um mit dem Speichern von Dateien fortzufahren, füge mehr Speicher hinzu." + }, + "whoCanView": { + "message": "Wer kann das sehen" + }, + "specificPeople": { + "message": "Bestimmte Personen" + }, + "emailVerificationDesc": { + "message": "Nach dem Teilen dieses Send-Links müssen Einzelpersonen 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" + }, + "whenYouRemoveStorage": { + "message": "Wenn du Speicherplatz entfernst, erhältst du eine anteilige Gutschrift, die automatisch mit deiner nächsten Rechnung verrechnet wird." + }, + "ownerBadgeA11yDescription": { + "message": "Eigentümer, $OWNER$, alle Einträge anzeigen, die $OWNER$ gehören", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "Du hast Premium" + }, + "emailProtected": { + "message": "E-Mail-Adresse geschützt" + }, + "invalidSendPassword": { + "message": "Ungültiges Send-Passwort" + }, + "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." + }, + "perUser": { + "message": "pro Benutzer" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/el/messages.json b/apps/web/src/locales/el/messages.json index 463d9a906de..cda6c1eddb2 100644 --- a/apps/web/src/locales/el/messages.json +++ b/apps/web/src/locales/el/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Πληροφορίες Πρόσβασης" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Ρίσκος Κωδικού Πρόσβασης" }, @@ -250,6 +274,9 @@ "application": { "message": "Εφαρμογή" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Κωδικοί πρόσβασης σε κίνδυνο" }, @@ -586,6 +613,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Τηλέφωνο" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Επιλογή Όλων" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Κατάργηση επιλογής όλων" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Όχι" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Τοποθεσία" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Επεξεργασία συλλογής" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Πληροφορίες συλλογής" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Επαναφορά Επιλεγμένων" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Στοιχείο που έχει Ανακτηθεί" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Μία ή περισσότερες οργανωτικές πολιτικές σας αποτρέπει από την εξαγωγή του προσωπικού vault." }, - "activateAutofill": { - "message": "Ενεργοποίηση αυτόματης συμπλήρωσης" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Μάθετε περισσότερα για την αυτόματη συμπλήρωση" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Επιλογή Τύπου SSO" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Επιλογή προγράμματος" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 8adfaac88f2..97bb46029a7 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical":{ + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge":{ + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Select all" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Unselect all" }, @@ -1365,6 +1398,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership":{ + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc":{ + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits":{ + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1":{ + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2":{ + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3":{ + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11701,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12055,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12621,10 +12752,47 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, + "ownerBadgeA11yDescription":{ + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders":{ + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, "youHavePremium": { "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } } diff --git a/apps/web/src/locales/en_GB/messages.json b/apps/web/src/locales/en_GB/messages.json index 777e18a90c9..45a22128710 100644 --- a/apps/web/src/locales/en_GB/messages.json +++ b/apps/web/src/locales/en_GB/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Select all" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Unselect all" }, @@ -1365,6 +1398,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralise organisation ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organisation. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralised ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member off boarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organisation or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organisation data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organisation policies prevent you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organisation Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organisation owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organisation in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/en_IN/messages.json b/apps/web/src/locales/en_IN/messages.json index 7af244b5537..33e6c0385ea 100644 --- a/apps/web/src/locales/en_IN/messages.json +++ b/apps/web/src/locales/en_IN/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Select all" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Unselect all" }, @@ -1365,6 +1398,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Restored item" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralise organisation ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organisation. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralised ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member off boarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organisation or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organisation data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organisation policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organisation Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organisation owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organisation in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/eo/messages.json b/apps/web/src/locales/eo/messages.json index 493f0121269..b163dfc8603 100644 --- a/apps/web/src/locales/eo/messages.json +++ b/apps/web/src/locales/eo/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Aplikaĵo" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "Retpoŝto" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefono" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Elekti ĉiujn" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Malelekti ĉiujn" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Loko" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Redakti kolekton" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Rehavigi elektitajn" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Ero rehaviĝis" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Teksto" }, - "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": "Krei novan sendon", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "En konfirmado" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/es/messages.json b/apps/web/src/locales/es/messages.json index e2149d10ded..1c44f266bd5 100644 --- a/apps/web/src/locales/es/messages.json +++ b/apps/web/src/locales/es/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No hay aplicaciones críticas en riesgo" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Inteligencia de Acceso" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Riesgo de contraseña" }, @@ -250,6 +274,9 @@ "application": { "message": "Aplicación" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "Correo electrónico" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Teléfono" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Seleccionar todo" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Deseleccionar todo" }, @@ -1365,6 +1398,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Ubicación" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Editar colección" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Información de la colección" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restaurar seleccionados" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Elemento restaurado" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Texto" }, - "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 nuevo Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Una o más políticas de tu organización te impiden exportar tu caja fuerte personal." }, - "activateAutofill": { - "message": "Activar autocompletar" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Active el autocompletar con los ajustes de carga de página en la extensión del navegador para todos los miembros existentes y nuevos." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Los sitios web comprometidos o no confiables pueden explotar autocompletar al cargar la página." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Más información sobre autocompletar" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Seleccionar tipo de SSO" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Selecciona un plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/et/messages.json b/apps/web/src/locales/et/messages.json index 4eba43f3f21..10e067afadd 100644 --- a/apps/web/src/locales/et/messages.json +++ b/apps/web/src/locales/et/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefoninumber" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Vali kõik" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Tühista valik" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Ei" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Asukoht" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Muuda kogumikku" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Kogumiku info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Taasta valitud" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Kirje on taastatud" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Tekst" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Üks või enam organisatsiooni poliitikat ei võimalda sul oma personaalset hoidlat eksportida." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/eu/messages.json b/apps/web/src/locales/eu/messages.json index 5931b12b53f..8d4192cdc35 100644 --- a/apps/web/src/locales/eu/messages.json +++ b/apps/web/src/locales/eu/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "Emaila" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefonoa" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Hautatu guztiak" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Deshautatu guztiak" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Ez" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Editatu bilduma" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Hautatuak berreskuratu" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Elementua berreskuratua" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Testua" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Erakundeko politika batek edo gehiagok kutxa gotorra esportatzea galarazten dute." }, - "activateAutofill": { - "message": "Gaitu betetze automatikoa" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Automatikoki betetzeari buruzko informazio gehiago" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Aukeratu SSO mota" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/fa/messages.json b/apps/web/src/locales/fa/messages.json index 9c6be5fb603..89ef17fa870 100644 --- a/apps/web/src/locales/fa/messages.json +++ b/apps/web/src/locales/fa/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "هیچ برنامه حیاتی در معرض خطر نیست" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "دسترسی به هوش مصنوعی" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "خطر کلمه عبور" }, @@ -250,6 +274,9 @@ "application": { "message": "برنامه" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "کلمات عبور در معرض خطر" }, @@ -586,6 +613,9 @@ "email": { "message": "ایمیل" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "تلفن" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "انتخاب همه" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "انتخاب هیچ‌کدام" }, @@ -1365,6 +1398,12 @@ "no": { "message": "خیر" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "موقعیت" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "ویرایش مجموعه" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "اطلاعات مجموعه" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "بازیابی انتخاب شده" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "مورد بازیابی شد" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "اجرای مالکیت داده‌های سازمانی" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "یک یا چند خط مشی سازمان از برون ریزی گاوصندوق شخصی شما جلوگیری می‌کند." }, - "activateAutofill": { - "message": "پر کردن خودکار را فعال کنید" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "تنظیم پر کردن خودکار در بارگذاری صفحه را در افزونه مرورگر برای همه اعضای موجود و جدید فعال کنید." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "وب‌سایت‌های در معرض خطر یا نامعتبر می‌توانند از پر کردن خودکار در بارگذاری صفحه سوء استفاده کنند." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "درباره پر کردن خودکار بیشتر بدانید" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "نوع SSO را انتخاب کنید" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "یک طرح انتخاب کنید" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden در ۷۲ ساعت اول سه بار تلاش خواهد کرد دامنه را ثبت کند. اگر دامنه ثبت نشد، رکورد DNS در میزبان خود را بررسی کرده و به‌صورت دستی ثبت کنید. اگر دامنه ثبت نشود، پس از ۷ روز از سازمان شما حذف خواهد شد." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "دامنه $DOMAIN$ ثبت نشده است. رکوردهای DNS خود را بررسی کنید.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "ثبت شده" }, - "domainStatusUnderVerification": { - "message": "در حال تأیید" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/fi/messages.json b/apps/web/src/locales/fi/messages.json index 4ac4c62b567..16a7472fce2 100644 --- a/apps/web/src/locales/fi/messages.json +++ b/apps/web/src/locales/fi/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Salasanariski" }, @@ -250,6 +274,9 @@ "application": { "message": "Sovellus" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Riskialttiit salasanat" }, @@ -586,6 +613,9 @@ "email": { "message": "Sähköpostiosoite" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Puhelinnumero" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Valitse kaikki" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Tyhjennä valinnat" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Ei" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Sijainti" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Muokkaa kokoelmaa" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Kokoelman tiedot" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Palauta valitut" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Kohde palautettiin" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Teksti" }, - "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": "Uusi Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Yksi tai useampi organisaatiokäytäntö estää yksityisen holvisi viennin." }, - "activateAutofill": { - "message": "Aktivoi automaattitäyttö" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Aktivoi selainlaajennuksen \"Automaattitäyttö sivun avautuessa\" -asetus kaikille nykyisille ja uusille jäsenille." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Vaarantuneet tai epäluotettavat sivustot voivat väärinkäyttää sivun avautuessa suoritettavaa automaattitäyttöä." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Lue lisää automaattitäytöstä" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Valitse kertakirjautumisen tyyppi" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Valitse tilaus" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Vahvistettavana" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/fil/messages.json b/apps/web/src/locales/fil/messages.json index c7cc530bfd4..a96ee6ad2df 100644 --- a/apps/web/src/locales/fil/messages.json +++ b/apps/web/src/locales/fil/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telepono" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Piliin lahat" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Huwag piliin lahat" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Hindi" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "I-edit ang koleksyon" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Impormasyon sa koleksyon" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Ibalik muli ang napiling" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item na nai-restore" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Teksto" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Ang isa o higit pang mga patakaran ng organisasyon ay nagpipigil sa iyo mula sa pag-export ng iyong indibidwal na vault." }, - "activateAutofill": { - "message": "Buksan ang autofill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Maaaring pagsamantalahan ng mga nakompromisong website ang pag-autofill pagka-load ng pahina." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Matuto pa tungkol sa autofill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Pumili ng uri ng SSO" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/fr/messages.json b/apps/web/src/locales/fr/messages.json index 7c66f291ca1..7910f9e5816 100644 --- a/apps/web/src/locales/fr/messages.json +++ b/apps/web/src/locales/fr/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Aucune application critique à risques" }, + "critical": { + "message": "Critique ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Non critique ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critique" + }, "accessIntelligence": { "message": "Accéder à Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "Aucune application ne correspond à ces filtres" + }, "passwordRisk": { "message": "Risque du mot de passe" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Mots de passes à risque" }, @@ -586,6 +613,9 @@ "email": { "message": "Courriel" }, + "emails": { + "message": "Courriels" + }, "phone": { "message": "Téléphone" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Tout sélectionner" }, + "deselectAll": { + "message": "Tout désélectionner" + }, "unselectAll": { "message": "Tout désélectionner" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Non" }, + "noAuth": { + "message": "Toute personne disposant du lien" + }, + "anyOneWithPassword": { + "message": "N'importe qui avec un mot de passe défini par vous" + }, "location": { "message": "Localisation" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Prochain paiement" }, + "nextChargeDate": { + "message": "Prochaine date de facturation" + }, "plan": { "message": "Forfait" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Modifier la collection" }, + "viewCollection": { + "message": "Afficher la Collection" + }, "collectionInfo": { "message": "Informations de la collection" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restaurer la sélection" }, + "archivedItemRestored": { + "message": "Élément archivé restauré" + }, + "archivedItemsRestored": { + "message": "Élément archivé restauré" + }, "restoredItem": { "message": "Élément restauré" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Texte" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send créé avec succès !", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copiez et partagez ce lien Send. Il peut être consulté par les personnes que vous avez spécifiées pour $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copiez et partagez ce lien Send. Le Send sera disponible à quiconque avec le lien pour les prochains $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copiez et partagez ce lien Send. Le Send sera disponible à quiconque avec le lien et le mot de passe que vous avez configurez pour les prochain(e)s $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { + "message": "Copiez et partagez ce lien Send. Il peut être consulté par les personnes que vous avez spécifiées pour les prochain(e)s $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centraliser la propriété de l'organisation" + }, + "centralizeDataOwnershipDesc": { + "message": "Tous les éléments du membre seront détenus et gérés par l'organisation. Les administrateurs et les propriétaires sont exemptés. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "En savoir plus sur la propriété centralisée", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Les bénéfices" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gagnez une pleine visibilité sur la santé des identifiants, y compris les éléments partagés et non partagés." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Transférez facilement les éléments pendant la période de lancement et de succession du membre, en veillant à ce qu'il n'y ait pas de manques d'accès." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Donnez à tous les utilisateurs un espace dédié \"Mes éléments\" pour gérer leurs propres identifiants." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Demander aux membres de transférer leurs éléments" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "Si les membres ont des éléments dans leur coffre individuel, ils seront invités à les transférer à l'organisation ou à quitter. S'ils quittent, leur accès est révoqué mais peut être restauré à tout moment." + }, + "centralizeDataOwnershipWarningLink": { + "message": "En savoir plus sur le transfert" + }, "organizationDataOwnership": { "message": "Forcer la propriété des données de l'organisation" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Une ou plusieurs politiques de sécurité de l'organisation vous empêchent d'exporter votre coffre individuel." }, - "activateAutofill": { - "message": "Activer la saisie automatique" + "activateAutofillPolicy": { + "message": "Activer le remplissage automatique" }, - "activateAutofillPolicyDesc": { - "message": "Activer le paramètre de saisie automatique au chargement de la page sur l'extension du navigateur pour tous les membres existants et nouveaux." + "activateAutofillPolicyDescription": { + "message": "Activer le remplissage automatique au chargement de la page dans les paramètres de l'extension du navigateur pour tous les membres existants et nouveaux." }, - "experimentalFeature": { - "message": "Les sites web compromis ou non fiables peuvent exploiter la saisie automatique au chargement de la page." + "autofillOnPageLoadExploitWarning": { + "message": "Les sites web compromis ou non fiables peuvent exploiter le remplissage automatique au chargement de la page." }, - "learnMoreAboutAutofill": { - "message": "En savoir plus sur la saisie automatique" + "learnMoreAboutAutofillPolicy": { + "message": "En savoir plus sur le remplissage automatique" }, "selectType": { "message": "Sélectionnez le type de SSO" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Envoyer les données de l'événement du coffre à votre instance Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Envoyer les données de l'événement à votre instance de Huntress SIEM" + }, "failedToSaveIntegration": { "message": "Impossible d'enregistrer l'intégration. Veuillez réessayer plus tard." }, + "mustBeOrganizationOwnerAdmin": { + "message": "Vous devez être un Propriétaire d'Organisation ou un Administrateur pour effectuer cette action." + }, "mustBeOrgOwnerToPerformAction": { "message": "Vous devez être le propriétaire de l'organisation pour effectuer cette action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "URL HTTP du Collecterur d'Événements" + }, + "httpEventCollectorToken": { + "message": "Jeton du Collecteur d'Événements HTTP" + }, "selectAPlan": { "message": "Sélectionnez un plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden tentera de récupérer le domaine 3 fois pendant les 72 premières heures. Si le domaine ne peut pas être réclamé, vérifiez l'enregistrement DNS dans votre hôte et réclamez manuellement. Le domaine sera supprimé de votre organisation dans 7 jours s'il n'est pas réclamé." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden tentera de réclamer le domaine dans les 72 heures. Si le domaine ne peut pas être réclamé, vérifiez votre enregistrement DNS et réclamez-le manuellement. Les domaines non réclamés sont supprimés après 7 jours." + }, + "automaticDomainClaimProcess2": { + "message": "Une fois réclamé, les membres existants ayant des domaines réclamés recevrontdes courriels à propos de la " + }, + "accountOwnershipChange": { + "message": "changement de propriétaire du compte" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ non réclamé. Vérifiez vos enregistrements DNS.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Réclamé" }, - "domainStatusUnderVerification": { - "message": "En cours de vérification" + "domainStatusPending": { + "message": "En attente" }, "claimedDomainsDescription": { "message": "Réclamer un domaine pour réclamer les comptes des membres. La page d'identification SSO sera ignorée lors de la connexion pour les membres ayant des domaines réclamés et les administrateurs pourront supprimer les comptes réclamés." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Cet identifiant est à risques et manque un site web. Ajoutez un site web et changez le mot de passe pour une meilleure sécurité." }, + "vulnerablePassword": { + "message": "Mot de passe vulnérable." + }, + "changeNow": { + "message": "Changer maintenant" + }, "missingWebsite": { "message": "Site Web manquant" }, @@ -11695,8 +11823,8 @@ "message": "Archiver l'élément", "description": "Verb" }, - "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": "Une fois archivé, cet élément sera exclu des résultats de recherche et des suggestions de remplissage automatique." }, "archiveBulkItems": { "message": "Archiver les éléments", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Vérifier maintenant." }, + "unlockWithPasskey": { + "message": "Déverrouiller avec une clé d'accès" + }, + "prfUnlockFailed": { + "message": "Le déverrouillage par clé d'accès a échoué. Veuillez réessayer ou utiliser une autre méthode pour déverrouiller." + }, + "noPrfCredentialsAvailable": { + "message": "Aucune clé d’accès avec PRF activé n’est disponible pour le déverrouillage." + }, "additionalStorageGB": { "message": "Stockage additionnel (Go)" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "Vous avez utilisé tous les $GB$ Go de votre stockage chiffré. Pour continuer à stocker des fichiers, ajoutez plus de stockage." + }, + "whoCanView": { + "message": "Qui peut afficher" + }, + "specificPeople": { + "message": "Personnes spécifiques" + }, + "emailVerificationDesc": { + "message": "Après avoir partagé ce lien Send, les personnes devront vérifier leur courriel avec un code pour afficher ce Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Entrez plusieurs courriels en les séparant avec une virgule." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "Lorsque vous supprimez le stockage, vous recevrez un crédit de compte au prorata qui sera automatiquement appliqué à votre prochaine facture." + }, + "ownerBadgeA11yDescription": { + "message": "Propriétaire, $OWNER$, affiche tous les éléments appartenant à $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "Vous avez Premium" + }, + "emailProtected": { + "message": "Protégé par courriel" + }, + "invalidSendPassword": { + "message": "Mot de passe Send invalide" + }, + "sendPasswordHelperText": { + "message": "Les personnes devront entrer le mot de passe pour afficher ce Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "perUser": { + "message": "par utilisateur" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/gl/messages.json b/apps/web/src/locales/gl/messages.json index 63c03675fb5..9fcece92871 100644 --- a/apps/web/src/locales/gl/messages.json +++ b/apps/web/src/locales/gl/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "Enderezo de correo electrónico" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Número de teléfono" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Select all" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Unselect all" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Non" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/he/messages.json b/apps/web/src/locales/he/messages.json index 8bb6863fbae..90acb236f87 100644 --- a/apps/web/src/locales/he/messages.json +++ b/apps/web/src/locales/he/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "אין יישומים קריטיים בסיכון" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "מודיעין גישות" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "סיכון סיסמה" }, @@ -94,7 +118,7 @@ "message": "הקצה משימות לחברים כדי לנטר התקדמות" }, "onceYouReviewApplications": { - "message": "Once you review applications and mark them as critical, assign tasks to your members to change their passwords." + "message": "לאחר סקירת הבקשות וסימונן כנחוצות, מומלץ להקצות מטלות לחברים שלכם לשינוי הסיסמאות שלהם." }, "sendReminders": { "message": "שלח תזכורות" @@ -179,34 +203,34 @@ } }, "noDataInOrgTitle": { - "message": "No data found" + "message": "לא נמצאו נתונים" }, "noDataInOrgDescription": { - "message": "Import your organization's login data to get started with Access Intelligence. Once you do that, you'll be able to:" + "message": "בצעו ייבוא של פרטי ההתחברות של הארגון שלכם כדי להתחיל שימוש ב-Access Intelligence. לאחר שתבצעו זאת, באפשרותכם:" }, "feature1Title": { - "message": "Mark applications as critical" + "message": "סימון יישומים כנחוצים" }, "feature1Description": { - "message": "This will help you remove risks to your most important applications first." + "message": "הפעולה תסייע לך להסיר סיכונים ליישומים החשובים לך ביותר תחילה." }, "feature2Title": { - "message": "Help members improve their security" + "message": "סייעו לחברים לשפר את האבטחה שלהם" }, "feature2Description": { - "message": "Assign at-risk members guided security tasks to update credentials." + "message": "הקצו מטלות אבטחה מודרכות לחברים שבסיכון לעדכון נתוני ההתחברות." }, "feature3Title": { - "message": "Monitor progress" + "message": "ניטור ההתקדמות" }, "feature3Description": { - "message": "Track changes over time to show security improvements." + "message": "עקבו אחר שינויים לאורך זמן כדי להציג שיפורי אבטחה." }, "noReportsRunTitle": { - "message": "Generate report" + "message": "יצירת דו\"ח" }, "noReportsRunDescription": { - "message": "You’re ready to start generating reports. Once you generate, you’ll be able to:" + "message": "באפשרותך כעת להתחיל ליצור דו\"חות. לאחר יצירת דו\"חות, יהיה ניתן:" }, "noCriticalApplicationsTitle": { "message": "לא סימנת אף יישום כקריטי" @@ -250,6 +274,9 @@ "application": { "message": "יישום" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "סיסמאות בסיכון" }, @@ -266,13 +293,13 @@ "message": "חברים בסיכון" }, "membersWithAccessToAtRiskItemsForCriticalApplications": { - "message": "These members have access to vulnerable items for critical applications." + "message": "לחברים אלה ניתנת גישה לפריטים רגישים עבור יישומים חיוניים." }, "membersWithAtRiskPasswords": { "message": "חברים עם סיסמאות בסיכון" }, "membersWillReceiveSecurityTask": { - "message": "Members of your organization will be assigned a task to change vulnerable passwords. They’ll receive a notification within their Bitwarden browser extension." + "message": "לחברים בארגון שלך תוקצה מטלה לשינוי סיסמאות רגישות. הם יקבלו התראה בתוסף הדפדפן של Bitwarden." }, "membersAtRiskCount": { "message": "$COUNT$ חברים בסיכון", @@ -302,7 +329,7 @@ } }, "atRiskMemberDescription": { - "message": "These members are logging into critical applications with weak, exposed, or reused passwords." + "message": "חברים אלה נכנסו אל יישומים עם סיסמאות חלשות, חשופות, או משומשות." }, "atRiskMembersDescriptionNone": { "message": "אין חברים שנכנסו אל יישומים עם סיסמאות חלשות, חשופות, או משומשות." @@ -586,6 +613,9 @@ "email": { "message": "אימייל" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "טלפון" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "בחר הכל" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "נקה הכל" }, @@ -1365,6 +1398,12 @@ "no": { "message": "לא" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "מיקום" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "החיוב הבא" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "תוכנית" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "ערוך אוסף" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "פרטי אוסף" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "שחזר את מה שנבחר" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "הפריט שוחזר" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "אכוף בעלות נתוני ארגון" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "מדיניות ארגון אחת או יותר מונעת ממך מלייצא את הכספת האישית שלך." }, - "activateAutofill": { - "message": "הפעל מילוי אוטומטי" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "הפעל את הגדרת המילוי האוטומטי בעת טעינת עמוד בהרחבת הדפדפן עבור כל החברים הקיימים והחדשים." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "אתרים פרוצים או לא מהימנים יכולים לנצל מילוי אוטומטי בעת טעינת עמוד." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "למד עוד על מילוי אוטומטי" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "בחר סוג SSO" @@ -9358,7 +9456,7 @@ "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Members will not need a master password when logging in with SSO. Master password is replaced with an encryption key stored on the device, making that device trusted. The first device a member creates their account and logs into will be trusted. New devices will need to be approved by an existing trusted device or by an administrator. The single organization policy, SSO required policy, and account recovery administration policy will turn on when this option is used.'" }, "memberDecryptionOptionTdeDescPart2": { - "message": "", + "message": "policy,", "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Members will not need a master password when logging in with SSO. Master password is replaced with an encryption key stored on the device, making that device trusted. The first device a member creates their account and logs into will be trusted. New devices will need to be approved by an existing trusted device or by an administrator. The single organization policy, SSO required policy, and account recovery administration policy will turn on when this option is used.'" }, "memberDecryptionOptionTdeDescLink2": { @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "שלח נתוני אירועי כספת אל מופע ה־Datadog שלך" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "שמירת האינטגרציה נכשלה. נא לנסות שוב מאוחר יותר." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "אתה מוכרח להיות מנהל הארגון כדי לבצע פעולה זו." }, @@ -10506,6 +10610,12 @@ "index": { "message": "אינדקס" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "בחר תוכנית" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden ינסה לדרוש את הדומיין 3 פעמים במהלך 72 השעות הראשונות. אם לא ניתן לדרוש את הדומיין, בדוק את רשומת ה־DNS במארח שלך ודרוש באופן ידני. הדומיין יוסר מהארגון שלך תוך 7 ימים אם הוא לא נדרש." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ אינו נדרש. בדוק את רשומות ה־DNS שלך.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "נדרש" }, - "domainStatusUnderVerification": { - "message": "תחת אימות" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "דרוש דומיין כדי להיות הבעלים של חשבונות חברים. עמוד מזהה ה־SSO ידולג במהלך כניסה עבור חברים עם דומיינים שנדרשו ומנהלים יוכלו למחוק חשבונות שנדרשו." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "כניסה זו נמצאת בסיכון וחסר לה אתר אינטרנט. הוסף אתר אינטרנט ושנה את הסיסמה עבור אבטחה חזקה יותר." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "אתר אינטרנט חסר" }, @@ -11695,8 +11823,8 @@ "message": "העבר פריט לארכיון", "description": "Verb" }, - "archiveItemConfirmDesc": { - "message": "פריטים בארכיון מוחרגים מתוצאות חיפוש כללי והצעות למילוי אוטומטי. האם אתה בטוח שברצונך להעביר פריט זה לארכיון?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archiveBulkItems": { "message": "העבר פריטים לארכיון", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "אמת כעת." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "אחסון נוסף ב־GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/hi/messages.json b/apps/web/src/locales/hi/messages.json index fa3e64df703..cf0a3d625c4 100644 --- a/apps/web/src/locales/hi/messages.json +++ b/apps/web/src/locales/hi/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "ईमेल" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "फोन" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "सभी का चयन करें" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "सभी का चयन रद्द करें" }, @@ -1365,6 +1398,12 @@ "no": { "message": "नहीं" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/hr/messages.json b/apps/web/src/locales/hr/messages.json index 4b3a6025db5..c01086848f2 100644 --- a/apps/web/src/locales/hr/messages.json +++ b/apps/web/src/locales/hr/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Nema kritičnih aplikacija u opasnosti" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Pristup inteligenciji" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Rizik lozinke" }, @@ -250,6 +274,9 @@ "application": { "message": "Aplikacija" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Rizične lozinke" }, @@ -586,6 +613,9 @@ "email": { "message": "E-pošta" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Odaberi sve" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Poništi odabir" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokacija" }, @@ -1750,7 +1789,7 @@ "message": "Nema članova za prikaz." }, "noMembersToExport": { - "message": "There are no members to export." + "message": "Nema članova za izvoz." }, "noEventsInList": { "message": "Nema događaja za prikaz." @@ -2541,7 +2580,7 @@ "message": "Omogućeno" }, "optionEnabled": { - "message": "Enabled" + "message": "Uključeno" }, "restoreAccess": { "message": "Vrati pristup" @@ -2641,7 +2680,7 @@ "message": "Ključ" }, "unnamedKey": { - "message": "Unnamed key" + "message": "Neimenovani ključ" }, "twoStepAuthenticatorEnterCodeV2": { "message": "Kôd za provjeru" @@ -3153,7 +3192,7 @@ "message": "Za ponovni pristup svojoj arhivi, ponovno pokreni Premium pretplatu. Ako urediš detalje arhivirane stavke prije ponovnog pokretanja, ona će biti vraćena u tvoj trezor." }, "itemRestored": { - "message": "Item has been restored" + "message": "Stavka je vraćena" }, "restartPremium": { "message": "Ponovno Pokreni Premium" @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Sljedeća naplata" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Paket" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Uredi zbirku" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Info o zbirci" }, @@ -4661,7 +4706,7 @@ "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": "Unesi svoju glavnu lozinku" @@ -5195,19 +5240,19 @@ "description": "This is a verb. ex. 'Fix The Car'" }, "fixEncryption": { - "message": "Fix encryption" + "message": "Popravi šifriranje" }, "fixEncryptionTooltip": { - "message": "This file is using an outdated encryption method." + "message": "Ova datoteka koristi zastarjelu metodu šifriranja." }, "attachmentUpdated": { - "message": "Attachment updated" + "message": "Privitak ažuriran" }, "oldAttachmentsNeedFixDesc": { "message": "Postoje stari privitci u tvom trezoru koje je potrebno popraviti prije rotacije ključa za šifriranje." }, "itemsTransferred": { - "message": "Items transferred" + "message": "Stavke prenesene" }, "yourAccountsFingerprint": { "message": "Jedinstvena fraza tvog računa", @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Vrati odabrano" }, + "archivedItemRestored": { + "message": "Arhivirana stavka vraćena" + }, + "archivedItemsRestored": { + "message": "Arhivirane stavke vraćene" + }, "restoredItem": { "message": "Stavka vraćena" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Tekst" }, - "sendPasswordDescV3": { - "message": "Dodaj neobaveznu lozinku za pristup ovom Sendu.", - "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." @@ -5617,16 +5664,36 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendCreatedSuccessfully": { - "message": "Send created successfully!", + "message": "Send je uspješno stvoren!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5640,11 +5707,11 @@ } }, "newTextSend": { - "message": "New Text Send", + "message": "Novi teksutalni Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "newFileSend": { - "message": "New File Send", + "message": "Novi datotečni Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editedSend": { @@ -5687,7 +5754,7 @@ "message": "Opozvano" }, "accepted": { - "message": "Accepted" + "message": "Prihvaćeno" }, "sendLink": { "message": "Veza na Send", @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Pogodnosti" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Provedi vlasništvo nad podacima organizacije" }, @@ -6348,10 +6446,10 @@ "message": "Oporavak računa uključen" }, "enrolled": { - "message": "Enrolled" + "message": "Učlanjen" }, "notEnrolled": { - "message": "Not enrolled" + "message": "Neučlanjen" }, "withdrawAccountRecovery": { "message": "Isključi oporavak računa" @@ -6531,7 +6629,7 @@ "message": "Uspješno ponovno pozvano" }, "bulkReinviteSuccessToast": { - "message": "$COUNT$ users re-invited", + "message": "Korisnika ponovno pozvano: $COUNT$", "placeholders": { "count": { "content": "$1", @@ -6540,7 +6638,7 @@ } }, "bulkReinviteLimitedSuccessToast": { - "message": "$LIMIT$ of $SELECTEDCOUNT$ users re-invited. $EXCLUDEDCOUNT$ were not invited due to the $LIMIT$ invite limit.", + "message": "$LIMIT$ od $SELECTEDCOUNT$ korisnika ponovno pozvano. $EXCLUDEDCOUNT$ nije pozvatno zbog ograničenja poziva ($LIMIT$).", "placeholders": { "limit": { "content": "$1", @@ -6877,7 +6975,7 @@ "message": "Istek trezora nije unutar zadanog vremena." }, "disableExport": { - "message": "Remove export" + "message": "Ukloni izvoz" }, "disablePersonalVaultExportDescription": { "message": "Onemogućuje korisnicima izvoz osobnog trezora." @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Jedno ili više pravila organizacija onemogućuje izvoz osobnog trezora." }, - "activateAutofill": { - "message": "Aktiviraj auto-ispunu" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Aktivira auto-ispunu kod učitavanja stranice u dodatku za preglednik za sve postojeće i nove korisnike." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Ugrožene ili nepouzdane web stranice mogu iskoristiti auto-ispunu prilikom učitavanja stranice." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Saznaj više o auto-ispuni" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Odaberi vrstu SSO" @@ -7255,7 +7353,7 @@ "message": "SSO omogućen" }, "ssoTurnedOff": { - "message": "SSO turned off" + "message": "SSO isključen" }, "emailMustLoginWithSso": { "message": "$EMAIL$ se mora prijavljivati sa SSO", @@ -9546,7 +9644,7 @@ "message": "Potrebna je SSO prijava" }, "emailRequiredForSsoLogin": { - "message": "Email is required for SSO" + "message": "Za SSO je potrebna e-pošta" }, "selectedRegionFlag": { "message": "Zastava odabrane regije" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Pošalji podatke o događajima trezora svojoj Datadog instanci" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Spremanje integracije nije uspjelo. Pokušaj ponovno kasnije." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "Za ovo, moraš biti vlasnik organizacije." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Indeks" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Odaberi plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden će pokušati potvrditi domenu 3 puta tijekom prva 72 sata. Ako se domena ne može potvrditi, provjeri DNS zapis na svom poslužitelju i ručno potvrdi. Domena će, ako se ne potvrdi, biti uklonjena iz vaše organizacije nakon 7 dana." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nije potvrđena. Provjeri DNS zapise.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Potvrđena" }, - "domainStatusUnderVerification": { - "message": "Provjera u tijeku" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Potvrdi domenu za vlasništvo nad članskim računima. Stranica za SSO identifikator bit će preskočena tijekom prijave za članove sa potvrđenim domenama, a administratori će moći izbrisati zatražene račune." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Ova prijava je ugrožena i nedostaje joj web-stranica. Dodaj web-stranicu i promijeni lozinku za veću sigurnost." }, + "vulnerablePassword": { + "message": "Ranjiva lozinka." + }, + "changeNow": { + "message": "Promijeni sada" + }, "missingWebsite": { "message": "Nedostaje web-stranica" }, @@ -11659,7 +11787,7 @@ "message": "Poništi arhiviranje" }, "archived": { - "message": "Archived" + "message": "Arhivirano" }, "unArchiveAndSave": { "message": "Unarchive and save" @@ -11680,7 +11808,7 @@ "message": "Stavke poslane u arhivu" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "Stavka vraćena iz arhive" }, "itemUnarchived": { "message": "Stavka vraćena iz arhive" @@ -11695,8 +11823,8 @@ "message": "Arhiviraj stavku", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Arhiviraj stavke", @@ -11813,7 +11941,7 @@ "message": "Bitwarden proširenje je instalirano!" }, "bitwardenExtensionIsInstalled": { - "message": "Bitwarden extension is installed!" + "message": "Bitwarden proširenje je instalirano!" }, "openExtensionToAutofill": { "message": "Otvori proširenje i prijavi se za početak korištenja auto-ispune." @@ -11830,11 +11958,11 @@ "description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'" }, "openExtensionFromToolbarPart1": { - "message": "If the extension didn't open, you may need to open Bitwarden from the icon ", + "message": "Ako se proširenje nije otvorilo, možda ćeš trebati otvoriti Bitwarden iz ikone ", "description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'If the extension didn't open, you may need to open Bitwarden from the icon [Bitwarden Icon] on the toolbar.'" }, "openExtensionFromToolbarPart2": { - "message": " on the toolbar.", + "message": " na alatnoj traci.", "description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'If the extension didn't open, you may need to open Bitwarden from the icon [Bitwarden Icon] on the toolbar.'" }, "gettingStartedWithBitwardenPart3": { @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Potvrdi sada." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Dodati GB pohrane" }, @@ -12318,7 +12455,7 @@ "message": "Do not continue" }, "domain": { - "message": "Domain" + "message": "Domena" }, "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." @@ -12408,7 +12545,7 @@ "message": "Save Diagnostic Logs" }, "sessionTimeoutSettingsManagedByOrganization": { - "message": "This setting is managed by your organization." + "message": "Ovom postavkom upravlja tvoja organizacija." }, "sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes": { "message": "Your organization has set the maximum session timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).", @@ -12440,13 +12577,13 @@ } }, "sessionTimeoutOnRestart": { - "message": "On browser refresh" + "message": "Pri osvježavanju preglednika" }, "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": { - "message": "Set an unlock method to change your timeout action" + "message": "Postavi metodu otključavanja za promjenu radnje nakon isteka vremenskog ograničenja trezora" }, "leaveConfirmationDialogTitle": { - "message": "Are you sure you want to leave?" + "message": "Sigurno želiš napustiti?" }, "leaveConfirmationDialogContentOne": { "message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features." @@ -12485,13 +12622,13 @@ } }, "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?" }, "youHaveBitwardenPremium": { "message": "You have Bitwarden Premium" @@ -12539,10 +12676,10 @@ "message": "View all plans" }, "planDescPremium": { - "message": "Complete online security" + "message": "Dovrši online sigurnost" }, "updatePayment": { - "message": "Update payment" + "message": "Ažuriraj plaćanje" }, "weCouldNotProcessYourPayment": { "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." @@ -12563,7 +12700,7 @@ "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." }, "youHaveAGracePeriod": { - "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "message": "Imaš razdoblje odgode od $DAYS$ dana od datuma isteka pretplate da održiš svoju pretplatu. Molimo plati nepodmirene račune do $DATE$.", "placeholders": { "days": { "content": "$1", @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/hu/messages.json b/apps/web/src/locales/hu/messages.json index 3b2b90c4985..d540c59df3a 100644 --- a/apps/web/src/locales/hu/messages.json +++ b/apps/web/src/locales/hu/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Nincsenek veszélyben levő kritikus alkalmazások." }, + "critical": { + "message": "Kritikus ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Nem-kritikus ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Kritikus" + }, "accessIntelligence": { "message": "Elérés intelligencia" }, + "noApplicationsMatchTheseFilters": { + "message": "Egyetlen alkalmazás sem felel meg ezeknek a szűrőknek." + }, "passwordRisk": { "message": "Jelszó kockázat" }, @@ -250,6 +274,9 @@ "application": { "message": "Alkalmazás" }, + "applications": { + "message": "Alkalmazások" + }, "atRiskPasswords": { "message": "Veszélyes jelszavak" }, @@ -586,6 +613,9 @@ "email": { "message": "Email cím" }, + "emails": { + "message": "Email címek" + }, "phone": { "message": "Telefonszám" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Összes kijelölése" }, + "deselectAll": { + "message": "Összes kijelölés megszüntetése" + }, "unselectAll": { "message": "Összes kijelölés megszüntetése" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Nem" }, + "noAuth": { + "message": "Bárki ezzel a hivatkozással" + }, + "anyOneWithPassword": { + "message": "Bárki az általam beállított jelszóval" + }, "location": { "message": "Hely" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Következő terhelés" }, + "nextChargeDate": { + "message": "Következő terhelés dátum" + }, "plan": { "message": "Csomag" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Gyűjtemény szerkesztése" }, + "viewCollection": { + "message": "Gyűjtemény megtekintése" + }, "collectionInfo": { "message": "Gyűjtemény adatok" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Kiválasztottak visszaállítása" }, + "archivedItemRestored": { + "message": "Az archivált elem visszaállításra került." + }, + "archivedItemsRestored": { + "message": "Az archivált elemek visszaállításra kerültek." + }, "restoredItem": { "message": "Az elem visszaállításra került." }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Szöveg" }, - "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." @@ -5620,13 +5667,33 @@ "message": "A Send sikeresen létrejött!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Másoljuk és osszuk meg ezt a Send elem hivatkozást. A Send elem bárki számára elérhető lesz, aki rendelkezik a hivatkozással a következő $TIME$ alatt.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Másoljuk és osszuk meg ezt a Send elem hivatkozást. A Send elem bárki számára elérhető lesz, aki rendelkezik a hivatkozással és a jelszóval a következő $TIME$ alatt.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Másoljuk és osszuk meg ezt a Send hivatkozást. Megtekinthetik a megadott személyek a következő $TIME$ intervallumban.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "A szervezet tulajdonjog központosítása" + }, + "centralizeDataOwnershipDesc": { + "message": "Az összes tagi elem a szervezet tulajdonában és kezelésében lesz. Az adminisztrátorok és a tulajdonosok mentesülnek. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "További információ a központosított tulajdonról", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Előnyök" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Szerezzünk teljes láthatóságot a hitelesítő egészséghez, beleértve a megosztott és nem megosztott elemeket is." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Könnyen átvihetjük az elemeket a tagok kilépése és utódlása során, biztosítva, hogy ne legyenek hozzáférési hiányosságok." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Adjunk minden felhasználónak egy dedikált \"Saját elemek\" helyet saját a bejelentkezései kezelésére." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Kérjük meg a tagokat, hogy vigyék át az elemeiket." + }, + "centralizeDataOwnershipWarningDesc": { + "message": "Ha a tagok vannak elemei az egyedi széfükben, a rendszer felkéri őket, hogy vigyék át azokat a szervezethez vagy távozzanak. Ha távoznak, a hozzáféréseik visszavonásra kerülnek, de bármikor visszaállíthatók." + }, + "centralizeDataOwnershipWarningLink": { + "message": "További információ az átvitelről" + }, "organizationDataOwnership": { "message": "A szervezet adat tulajdonjogának érvényesítése" }, @@ -6888,16 +6986,16 @@ "personalVaultExportPolicyInEffect": { "message": "Egy vagy több szervezeti házirend tiltja a személyes széf exportálását." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Automatikus kitöltés bekapcsolása" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Bekapcsolja az automatikus kitöltést az oldalbetöltési beállításokkal a böngésző bővítményben minden meglévő és új tag esetében." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "A veszélyeztetett vagy nem megbízható webhelyek kihasználhatják az oldal betöltéskor végrehajtott automatikus kitöltést." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "További információ az automatikus kitöltésről" }, "selectType": { @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Széf eseményadatok küldése a Datadog példánynak" }, + "huntressEventIntegrationDesc": { + "message": "Eseményadatok küldése a Huntress SIEM éldánynak" + }, "failedToSaveIntegration": { "message": "Nem sikerült menteni az integrációt. Próbáljuk újra később." }, + "mustBeOrganizationOwnerAdmin": { + "message": "A művelet végrehajtásához a szervezet tulajdonosának vagy adminisztrátorának kell lenni." + }, "mustBeOrgOwnerToPerformAction": { "message": "Ennek a műveletnek a végrehajtásához a szervezet tulajdonosának kell lenni." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP eseménygyűjtő webcím" + }, + "httpEventCollectorToken": { + "message": "HTTP eseménygyűjtő vezérjel" + }, "selectAPlan": { "message": "Előfizetés kiválasztása" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "A Bitwarden az első 72 óra során 3 alkalommal kísérli meg a tartomány ellenőrzését. Ha a tartomány nem ellenőrizhető, ellenőrizésre kerül a DNS rekordt a kiszolgálón és az ellenőrzés manuálisan történik. A tartomány 7 napon belül eltávolításra kerül, ha nem kerül igénylésre." }, + "automaticDomainClaimProcess1": { + "message": "A Bitwarden 72 órán belül megkísérli igényelni a tartományt. Ha a tartomány nem igényelhető, ellenőrizzük DNS-rekordot és igényeljünk manuálisan. A nem igényelt tartományok 7 nap múlva eltávolításra kerülnek." + }, + "automaticDomainClaimProcess2": { + "message": "Az igénylést követően az igényelt tartományokkal rendelkező meglévő tagok emailt kapnak: " + }, + "accountOwnershipChange": { + "message": "fiók tulajdonos váltás" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nincs igényelve. Ellenőrizzük a DNS rekordot.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Igényelve" }, - "domainStatusUnderVerification": { - "message": "Ellenőrzés alatt" + "domainStatusPending": { + "message": "Függő" }, "claimedDomainsDescription": { "message": "Tartomány igénylése tagfiókok birtoklására. Az igényelt tartományokkal rendelkező tagok bejelentkezése során az SSO azonosító oldal kihagyásra kerül és az adminisztrátorok törölhetik az igényelt fiókokat." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Ez a bejelentkezés veszélyben van és hiányzik egy webhely. Adjunk hozzá egy webhelyet és módosítsuk a jelszót az erősebb biztonság érdekében." }, + "vulnerablePassword": { + "message": "A jelszó sérülékeny." + }, + "changeNow": { + "message": "Módosítás most" + }, "missingWebsite": { "message": "Hiányzó webhely" }, @@ -11695,8 +11823,8 @@ "message": "Elem archiválása", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Elemek archiválása", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Ellenőrzés most" }, + "unlockWithPasskey": { + "message": "Hozzáférési kulcs" + }, + "prfUnlockFailed": { + "message": "Nem sikerült a feloldás a hozzéférési kulccsal. Próbáljuk újra vagy használjunk más feloldási metódust." + }, + "noPrfCredentialsAvailable": { + "message": "A feloldáshoz nem állnak rendelkezésre PRF kompatibilis hozzáférési kucsok." + }, "additionalStorageGB": { "message": "Kiegészítő tárhely (GB)" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "A titkosított tárhely összes $GB$ mérete felhasználásra került. A fájlok tárolásának folytatásához adjunk hozzá további tárhelyet." + }, + "whoCanView": { + "message": "Ki láthatja" + }, + "specificPeople": { + "message": "Adott személyek" + }, + "emailVerificationDesc": { + "message": "A Send hivatkozás megosztása után a személyeknek ellenőrizniük kell email címüket egy kóddal a Send megtekintéséhez." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Írjunk be több email címet vesszővel elválasztva." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "A tárhely eltávolításakor arányos számlajóváírást kapunk, amely automatikusan a következő számlára kerül." + }, + "ownerBadgeA11yDescription": { + "message": "Tulajdonos, $OWNER$, összes elem megjelenítése: $OWNER$.", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "Prémium felhasználó vagyunk" + }, + "emailProtected": { + "message": "Védett email cím" + }, + "invalidSendPassword": { + "message": "Érvénytelen a Send jelszó." + }, + "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." + }, + "perUser": { + "message": "felhasználónként" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/id/messages.json b/apps/web/src/locales/id/messages.json index 92a4f786b54..bffb574e894 100644 --- a/apps/web/src/locales/id/messages.json +++ b/apps/web/src/locales/id/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Tidak ada aplikasi penting berisiko" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Akses Pintar" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Petunjuk Sandi" }, @@ -250,6 +274,9 @@ "application": { "message": "Aplikasi" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Sandi berisiko" }, @@ -586,6 +613,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telepon" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Pilih Semua" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Batal Pilih Semua" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Tidak" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokasi" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit Koleksi" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Pulihkan yang Dipilih" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item yang Dipulihkan" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Teks" }, - "sendPasswordDescV3": { - "message": "Tambahkan sandi opsional bagi penerima untuk mengakses Kirim ini.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "createSend": { "message": "Kirim baru", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Satu atau beberapa kebijakan organisasi mencegah Anda mengekspor brankas individual Anda." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Pilih Tipe SSO" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/it/messages.json b/apps/web/src/locales/it/messages.json index 40555fb1d4e..691c22c2912 100644 --- a/apps/web/src/locales/it/messages.json +++ b/apps/web/src/locales/it/messages.json @@ -14,14 +14,38 @@ "noCriticalAppsAtRisk": { "message": "Nessuna applicazione critica a rischio" }, + "critical": { + "message": "Rilevanza critica ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Rilevanza standard ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Intelligence sugli accessi" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Rischio password" }, "noEditPermissions": { - "message": "Non hai i permessi per modificare questo elemento" + "message": "Non hai il permesso per modificare questo elemento" }, "reviewAtRiskPasswords": { "message": "Controlla le password a rischio (deboli, esposte o riutilizzate). Seleziona le applicazioni critiche per determinare la priorità delle azioni di sicurezza." @@ -179,7 +203,7 @@ } }, "noDataInOrgTitle": { - "message": "Nessun dato disponibile" + "message": "Dati non trovati" }, "noDataInOrgDescription": { "message": "Importa i dati di accesso della tua organizzazione per configurare Access Intelligence. Una volta fatto, sarai in grado di:" @@ -250,6 +274,9 @@ "application": { "message": "Applicazione" }, + "applications": { + "message": "Applicazioni" + }, "atRiskPasswords": { "message": "Password a rischio" }, @@ -586,6 +613,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Indirizzi email" + }, "phone": { "message": "Telefono" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Seleziona tutto" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Deseleziona tutto" }, @@ -1365,6 +1398,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Chiunque abbia il link" + }, + "anyOneWithPassword": { + "message": "Chiunque abbia una password impostata da te" + }, "location": { "message": "Luogo" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Prossimo addebito" }, + "nextChargeDate": { + "message": "Data del prossimo addebito" + }, "plan": { "message": "Piano" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Modifica raccolta" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Informazioni raccolta" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Ripristina selezionati" }, + "archivedItemRestored": { + "message": "Elemento estratto dall'archivio" + }, + "archivedItemsRestored": { + "message": "Elementi archiviati ripristinati" + }, "restoredItem": { "message": "Elemento ripristinato" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Testo" }, - "sendPasswordDescV3": { - "message": "Richiedi ai destinatari una parola d'accesso 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." @@ -5620,13 +5667,33 @@ "message": "Send creato con successo!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copia e condividi questo link Send: potrà essere visualizzato dalle persone che hai specificato per le prossime $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copia e condividi questo link di invio. Sarà disponibile a chiunque ne sia a disposizione la prossima $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralizza la proprietà dell'organizzazione" + }, + "centralizeDataOwnershipDesc": { + "message": "Tutti gli elementi membri saranno di proprietà e gestiti dall'organizzazione. Gli amministratori e i proprietari sono esenti. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Scopri di più sulla proprietà centralizzata", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefit" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Ottieni piena visibilità sulla salute delle credenziali, sia per gli elementi condivisi che per quelli non condivisi." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Trasferisci facilmente gli elementi durante l'onboarding dei membri e la successione, garantendo che non ci siano vuoti di accesso." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Fornisci a tutti gli utenti uno spazio dedicato 'I miei elementi' per gestire i propri login." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Chiedi ai membri di trasferire i loro elemento salvati" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "Se gli utenti membri hanno elementi nella loro cassaforte individuale, saranno invitati a trasferirli all'organizzazione o a lasciarla. In caso di uscita dall'organizzazione, il loro accesso è revocato, ma può essere ripristinato in qualsiasi momento." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Scopri di più sul trasferimento" + }, "organizationDataOwnership": { "message": "Forza la proprietà dei dati dell'organizzazione" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Una o più politiche dell'organizzazione ti impediscono di esportare la tua cassaforte personale." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Attiva riempimento automatico" }, - "activateAutofillPolicyDesc": { - "message": "Attiva l'impostazione di riempimento automatico al caricamento della pagina nella estensione per il browser per tutti i membri esistenti e nuovi." + "activateAutofillPolicyDescription": { + "message": "Attiva il completamento automatico sulla pagina di caricamento delle impostazioni dell'estensione browser per tutti i membri nuovi ed esistenti." }, - "experimentalFeature": { - "message": "Siti compromessi potrebbero sfruttare il riempimento automatico al caricamento della pagina." + "autofillOnPageLoadExploitWarning": { + "message": "Siti compromessi o inaffidabili possono sfruttare il riempimento automatico al caricamento della pagina." }, - "learnMoreAboutAutofill": { - "message": "Ulteriori informazioni" + "learnMoreAboutAutofillPolicy": { + "message": "Ulteriori informazioni sul riempimento automatico" }, "selectType": { "message": "Seleziona tipo di SSO" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Invia i dati dell'evento della cassaforte all'istanza di Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Invia i dati dell'evento all'istanza di Huntress SIEM" + }, "failedToSaveIntegration": { "message": "Impossibile salvare l'integrazione. Riprova più tardi." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "Devi essere il proprietario dell'organizzazione per eseguire questa azione." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Indice" }, + "httpEventCollectorUrl": { + "message": "URL del collettore di eventi HTTP" + }, + "httpEventCollectorToken": { + "message": "Token del collettore eventi HTTP" + }, "selectAPlan": { "message": "Seleziona un piano" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden tenterà di verificare il dominio 3 volte durante le prossime 72 ore. Se il dominio non può essere acquisito, controlla il record DNS del tuo servizio di hosting e procedi manualmente. Il dominio sarà rimosso dall'organizzazione dopo 7 giorni se la procedura non andrà a buon fine." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden proverà ad ottenere il dominio nelle prossime 72 ore. Se il dominio non può essere ottenuto, verifica il tuo record DNS ed ottienilo manualmente. I domini non ottenuti vengono rimossi dopo 7 giorni." + }, + "automaticDomainClaimProcess2": { + "message": "Una volta ottenuto, i membri con domini riservati verranno contattati via e-mail per " + }, + "accountOwnershipChange": { + "message": "modifica della proprietà dell'account" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ non verificato. Controlla il record DNS.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Verificato" }, - "domainStatusUnderVerification": { - "message": "In attesa di verifica" + "domainStatusPending": { + "message": "In attesa" }, "claimedDomainsDescription": { "message": "Richiedi un dominio per avere a disposizione account membri. La pagina SSO sarà saltata durante il login per i membri con domini rivendicati e gli amministratori saranno in grado di eliminare gli account rivendicati." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Questo login è a rischio e non contiene un sito web. Aggiungi un sito web e cambia la password per maggiore sicurezza." }, + "vulnerablePassword": { + "message": "Password vulnerabile." + }, + "changeNow": { + "message": "Cambiala subito" + }, "missingWebsite": { "message": "Sito web mancante" }, @@ -11680,23 +11808,23 @@ "message": "Elementi archiviati" }, "itemWasUnarchived": { - "message": "Elemento rimosso dall'archivio" + "message": "Elemento estratto dall'archivio" }, "itemUnarchived": { - "message": "Elemento rimosso dall'archivio" + "message": "Elemento estratto dall'archivio" }, "bulkArchiveItems": { "message": "Elementi archiviati" }, "bulkUnarchiveItems": { - "message": "Elementi rimossi dall'archivio" + "message": "Elementi estratti dall'archivio" }, "archiveItem": { "message": "Archivia elemento", "description": "Verb" }, - "archiveItemConfirmDesc": { - "message": "Gli elementi archiviati sono esclusi dai risultati di ricerca e dall'auto-riempimento. Vuoi davvero archiviare questo elemento?" + "archiveItemDialogContent": { + "message": "Una volta archiviato, questo elemento sarà escluso dai risultati di ricerca e dai consigli di auto completamento." }, "archiveBulkItems": { "message": "Archivia elementi", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verifica adesso." }, + "unlockWithPasskey": { + "message": "Sblocca con passkey" + }, + "prfUnlockFailed": { + "message": "Impossibile sbloccare con passkey. Riprova o utilizza un altro metodo." + }, + "noPrfCredentialsAvailable": { + "message": "Non sono disponibili chiavi PRF abilitate per lo sblocco." + }, "additionalStorageGB": { "message": "Spazio di archiviazione aggiuntivo (GB)" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "Hai usato tutti i $GB$ GB del tuo spazio di archiviazione crittografato. Per archiviare altri file, aggiungi altro spazio." + }, + "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" + }, + "whenYouRemoveStorage": { + "message": "Quando rimuovi spazio di archiviazione, riceverai un credito che sarà automaticamente applicato al tuo prossimo pagamento." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "Sei un utente Premium" + }, + "emailProtected": { + "message": "Email protetta" + }, + "invalidSendPassword": { + "message": "Password del Send non valida" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/ja/messages.json b/apps/web/src/locales/ja/messages.json index 87f9aeabe42..963e59c4ffc 100644 --- a/apps/web/src/locales/ja/messages.json +++ b/apps/web/src/locales/ja/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "危険にさらされた重要なアプリケーションはありません" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "アクセス インテリジェンス" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "パスワードのリスク" }, @@ -250,6 +274,9 @@ "application": { "message": "アプリ" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "リスクがあるパスワード" }, @@ -586,6 +613,9 @@ "email": { "message": "メールアドレス" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "電話番号" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "すべて選択" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "すべて選択解除" }, @@ -1365,6 +1398,12 @@ "no": { "message": "いいえ" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "場所" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "コレクションの編集" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "コレクション情報" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "選択したものをリストア" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "リストアされたアイテム" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "1 つ以上の組織ポリシーにより、個人の保管庫をエクスポートできません。" }, - "activateAutofill": { - "message": "自動入力を有効にする" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "すべての既存および新規メンバーのブラウザ拡張機能のページ読み込み設定で、自動入力を有効にします。" + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "ウイルス感染したり信頼できないウェブサイトは、ページの読み込み時の自動入力を悪用できてしまいます。" + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "自動入力についての詳細" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "SSO のタイプを選択" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "プランを選択" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/ka/messages.json b/apps/web/src/locales/ka/messages.json index 6ac5adc869e..73cc59d65e1 100644 --- a/apps/web/src/locales/ka/messages.json +++ b/apps/web/src/locales/ka/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "ელ-ფოსტა" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ტელეფონი" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "ყველას შერჩევა" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "არშერჩევა ყველასი" }, @@ -1365,6 +1398,12 @@ "no": { "message": "არა" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/km/messages.json b/apps/web/src/locales/km/messages.json index 1ec92241671..2e96d9b844d 100644 --- a/apps/web/src/locales/km/messages.json +++ b/apps/web/src/locales/km/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Select all" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Unselect all" }, @@ -1365,6 +1398,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/kn/messages.json b/apps/web/src/locales/kn/messages.json index 077fcdded91..2b6adb295b0 100644 --- a/apps/web/src/locales/kn/messages.json +++ b/apps/web/src/locales/kn/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "ಇಮೇಲ್" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ಫೋನ್‌" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "ಎಲ್ಲವನ್ನು ಆರಿಸು" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "ಎಲ್ಲವನ್ನೂ ಆಯ್ಕೆ ರದ್ದುಮಾಡಿ" }, @@ -1365,6 +1398,12 @@ "no": { "message": "ಇಲ್ಲ" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "ಸಂಗ್ರಹವನ್ನು ತಿದ್ದು" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "ಆಯ್ಕೆಮಾಡಿ ಮರುಸ್ಥಾಪಿಸಿ" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "ಐಟಂ ಅನ್ನು ಮರುಸ್ಥಾಪಿಸಲಾಗಿದೆ" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/ko/messages.json b/apps/web/src/locales/ko/messages.json index 6e75342b110..d72fcaad6bb 100644 --- a/apps/web/src/locales/ko/messages.json +++ b/apps/web/src/locales/ko/messages.json @@ -1,12 +1,12 @@ { "allApplications": { - "message": "All applications" + "message": "모든 앱" }, "activity": { "message": "Activity" }, "appLogoLabel": { - "message": "Bitwarden logo" + "message": "Bitwarden 로고" }, "criticalApplications": { "message": "Critical applications" @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "치명적" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "이메일" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "전화번호" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "모두 선택" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "모두 선택 해제" }, @@ -1365,6 +1398,12 @@ "no": { "message": "아니오" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "컬렉션 편집" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "컬렉션 정보" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "선택 항목 복원" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "복구된 항목" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "SSO 유형 선택" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/lv/messages.json b/apps/web/src/locales/lv/messages.json index 6497cd985f8..406177f0f37 100644 --- a/apps/web/src/locales/lv/messages.json +++ b/apps/web/src/locales/lv/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Nav riskam pakļautu būtisku lietotņu" }, + "critical": { + "message": "Būtiski ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Nav būtiski ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Būtiska" + }, "accessIntelligence": { "message": "Piekļuves inteliģence" }, + "noApplicationsMatchTheseFilters": { + "message": "Neviena lietotne neatbilst šīm atlases vērtībām" + }, "passwordRisk": { "message": "Paroļu risks" }, @@ -250,6 +274,9 @@ "application": { "message": "Lietotne" }, + "applications": { + "message": "Lietotnes" + }, "atRiskPasswords": { "message": "Riskam pakļautās paroles" }, @@ -586,6 +613,9 @@ "email": { "message": "E-pasts" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Tālrunis" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Atlasīt visu" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Noņemt atlasi" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Nē" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Atrašanās vieta" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Nākamais maksājums" }, + "nextChargeDate": { + "message": "Nākamās apmaksas datums" + }, "plan": { "message": "Plāns" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Labot krājumu" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Krājuma informācija" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Atjaunot atlasīto" }, + "archivedItemRestored": { + "message": "Arhīva vienums atjaunots" + }, + "archivedItemsRestored": { + "message": "Arhīva vienumi atjaunoti" + }, "restoredItem": { "message": "Vienums atjaunots" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Teksts" }, - "sendPasswordDescV3": { - "message": "Pēc izvēles var pievienot paroli saņēmējiem, lai 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." @@ -5620,13 +5667,33 @@ "message": "Send tika veiksmīgi izveidots.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Ievieto starpliktuvē un kopīgo šī Send saiti! To $TIME$ no šī brīža var apskatīt cilvēki, kurus norādīji.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralizēt apvienības īpašumtiesības" + }, + "centralizeDataOwnershipDesc": { + "message": "Visi dalībnieku vienumi piederēs un tos pārvaldīs apvienība. Pārvaldītāji un īpašnieki ir izņēmums. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Uzzināt vairāk par centralizētām īpašumtiesībām", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Ieguvumi" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Tiek iegūta pilna pārredzamība par pieteikšanās vienumu stāvokli, tajā skaitā kopīgotajiem un nekopīgotajiem vienumiem." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Vienkārša vienumu pārcelšana dalībnieku aiziešanas un aizstāšanas gadījumos, nodrošinot, ka nav piekļuvju spraugu." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Uzspiest apvienības datu īpašumtiesības" }, @@ -6888,16 +6986,16 @@ "personalVaultExportPolicyInEffect": { "message": "Viens vai vairāki apvienības nosacījumi neļauj izgūt privātās glabātavas saturu." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Iespējot automātisko aizpildi" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Pārlūka paplašinājumā iespējot automātisko aizpildi lapas ielādes brīdī visiem esošajiem un jaunajiem dalībniekiem." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "Pārveidotās vai neuzticamās tīmekļvietnēs automātiskā aizpilde lapas ielādes laikā var tikt ļaunprātīgi izmantota." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Uzzināt vairāk par automātisko aizpildi" }, "selectType": { @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Nosūtīt glabātavas notikumu datus uz savu Datadog serveri" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Neizdevās saglabāt iekļaušanu. Lūgums vēlāk mēģināt vēlreiz." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "Jābūt apvienības īpašniekam, lai veiktu šo darbību." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Indekss" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Atlasīt plānu" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden mēģinās pārbaudīt domēnu 3 reizes pirmajās 72 stundās. Ja domēnu nevarēs pieteikt, būs jāpārbauda DNS ieraksts saimniekdatorā un tas pašrocīgi jāpiesaka. Domēns tiks noņemts no apvienības pēc 7 dienām, ja tas nebūs pieteikts." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nav pieteikts. Jāpārbauda DNS ieraksts.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Pieteikts" }, - "domainStatusUnderVerification": { - "message": "Apliecināšanā" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Iegūsti domēna piederību, lai iegūtu dalībnieku kontu īpašumtiesības. SSO identificētāja lapa tiks izlaista, kad pieteiksies dalībnieki, kuru kontu piederība ir atkarīga no domēna, un pārvaldītāji varēs izdzēst šāda veida kontus." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Šis pieteikšanās vienums ir pakļauts riskam, un tam nav norādīta tīmekļvietne. Lielākai drošībai jāpievieno tīmekļvietne un jānomaina parole." }, + "vulnerablePassword": { + "message": "Ievainojama parole." + }, + "changeNow": { + "message": "Mainīt tagad" + }, "missingWebsite": { "message": "Nav norādīta tīmekļvietne" }, @@ -11695,8 +11823,8 @@ "message": "Arhivēt vienumu", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Arhivēt vienumus", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Apliecini tagad!" }, + "unlockWithPasskey": { + "message": "Atslēgt ar piekļuves atslēgu" + }, + "prfUnlockFailed": { + "message": "Neizdevās atslēgt ar piekļuves atslēgu. Lūgums mēģināt vēlreiz vai izmantot citu atslēgšanas veidu." + }, + "noPrfCredentialsAvailable": { + "message": "Atslēgšanai nav pieejama neviena PRF iespējota piekļuves atslēga." + }, "additionalStorageGB": { "message": "Papildu krātuve GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "Kad noņemsi krātuvi, saņemsi konta kredītu noteiktā apjomā, kas tiks automātiski izmantots nākamajā rēķinā." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "Tev ir Premium" + }, + "emailProtected": { + "message": "E-pasts aizsargāts" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "1 lietotājam" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/ml/messages.json b/apps/web/src/locales/ml/messages.json index c09858df908..aa2892a92fa 100644 --- a/apps/web/src/locales/ml/messages.json +++ b/apps/web/src/locales/ml/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "ഇമെയിൽ" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ഫോൺ" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "എല്ലാം തിരഞ്ഞെടുക്കുക" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "എല്ലാം തിരഞ്ഞെടുത്തത് മാറ്റുക" }, @@ -1365,6 +1398,12 @@ "no": { "message": "അല്ല" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "കളക്ഷൻ എഡിറ്റുചെയ്യുക" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "തിരഞ്ഞെടുത്തത് വീണ്ടെടുക്കുക " }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "വീണ്ടെടുത്ത ഇനങ്ങൾ" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/mr/messages.json b/apps/web/src/locales/mr/messages.json index d65365c4fbe..2be3cda468e 100644 --- a/apps/web/src/locales/mr/messages.json +++ b/apps/web/src/locales/mr/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "कोणतेही महत्त्वाचे अ‍ॅप्लिकेशन्स धोक्यात नाहीत" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "अ‍ॅक्सेस इंटेलिजेंस" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "पासवर्डचा धोका" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "धोकादायक पासवर्ड" }, @@ -586,6 +613,9 @@ "email": { "message": "ईमेल" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Select all" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Unselect all" }, @@ -1365,6 +1398,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/my/messages.json b/apps/web/src/locales/my/messages.json index 1ec92241671..2e96d9b844d 100644 --- a/apps/web/src/locales/my/messages.json +++ b/apps/web/src/locales/my/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Select all" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Unselect all" }, @@ -1365,6 +1398,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/nb/messages.json b/apps/web/src/locales/nb/messages.json index 9dbbc6e08a0..d6be0481648 100644 --- a/apps/web/src/locales/nb/messages.json +++ b/apps/web/src/locales/nb/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Program" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Velg alt" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Avvelg alt" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Nei" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Sted" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Rediger samling" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Informasjon om samlingen" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Gjenopprett valgte" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Gjenopprettet objekt" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Tekst" }, - "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 ny Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "En eller flere regler i organisasjonsoppsettet forhindrer deg i å eksportere ditt personlige hvelv." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Velg SSO-type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/ne/messages.json b/apps/web/src/locales/ne/messages.json index 1f4f043322e..7f69d34a54c 100644 --- a/apps/web/src/locales/ne/messages.json +++ b/apps/web/src/locales/ne/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "इमेल" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "फोन" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Select all" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Unselect all" }, @@ -1365,6 +1398,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "सबै अवस्थित र नयाँ सदस्यहरूको लागि ब्राउजर एक्सटेन्सनको पृष्ठ लोड सेटिङमा स्वत: भरण विकल्प सक्रिय गर्नुहोस्।" + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/nl/messages.json b/apps/web/src/locales/nl/messages.json index 7b036798f50..95e38404b7f 100644 --- a/apps/web/src/locales/nl/messages.json +++ b/apps/web/src/locales/nl/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Geen kritische applicaties in gevaar" }, + "critical": { + "message": "Kritiek ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Niet kritiek ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Kritiek" + }, "accessIntelligence": { "message": "Toegangsintelligentie" }, + "noApplicationsMatchTheseFilters": { + "message": "Er zijn geen applicaties die met deze filters overeenkomen" + }, "passwordRisk": { "message": "Wachtwoordrisico" }, @@ -250,6 +274,9 @@ "application": { "message": "Applicatie" }, + "applications": { + "message": "Applicaties" + }, "atRiskPasswords": { "message": "Wachtwoorden in gevaar" }, @@ -586,6 +613,9 @@ "email": { "message": "E-mailadres" }, + "emails": { + "message": "E-mails" + }, "phone": { "message": "Telefoonnummer" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Alles selecteren" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Alles deselecteren" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Nee" }, + "noAuth": { + "message": "Iedereen met de link" + }, + "anyOneWithPassword": { + "message": "Iedereen met een door jou ingesteld wachtwoord" + }, "location": { "message": "Locatie" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Volgende betaling" }, + "nextChargeDate": { + "message": "Volgende datum van betaling" + }, "plan": { "message": "Pakket" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Verzameling bewerken" }, + "viewCollection": { + "message": "Collectie weergeven" + }, "collectionInfo": { "message": "Collectieinformatie" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Selectie herstellen" }, + "archivedItemRestored": { + "message": "Gearchiveerd item hersteld" + }, + "archivedItemsRestored": { + "message": "Gearchiveerde items hersteld" + }, "restoredItem": { "message": "Hersteld item" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Tekst" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Kopieer en deel deze Send-link. De Send is beschikbaar voor iedereen met de link voor de volgende $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Kopieer en deel deze Send-link. De Send is beschikbaar voor iedereen met de link en het ingestelde wachtwoord voor de volgende $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { + "message": "Kopieer en deel deze Send-link. Het kan worden bekeken door de mensen die je hebt opgegeven voor de volgende $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centraliseer organisatie-eigendom" + }, + "centralizeDataOwnershipDesc": { + "message": "Alle items van leden worden eigendom van en beheerd door de organisatie. Beheerders en eigenaren zijn vrijgesteld. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Meer informatie over gecentraliseerd eigendom", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Voordelen" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Krijg volledige zichtbaarheid in de gezondheid van inloggegevens, inclusief gedeelde en niet-gedeelde items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Eenvoudig items tijdens het offboarden van leden en opvolging verplaatsen, verzekerd dat er geen toegangsgaten zijn." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Geef alle gebruikers een toegewijde \"Mijn Items\"-ruimte voor het beheren van hun eigen inloggegevens." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Vraagt de leden om hun items over te brengen" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "Als leden items in hun individuele kluis hebben, worden ze gevraagd deze over te dragen naar de organisatie of te vertrekken. Als ze vertrekken, wordt hun toegang ingetrokken maar kan deze op elk moment worden hersteld." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Meer informatie over de overstap" + }, "organizationDataOwnership": { "message": "Gegevenseigendom van organisatie afdwingen" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Organisatiebeleid voorkomt dat je je persoonlijke kluis exporteert." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Automatisch invullen activeren" }, - "activateAutofillPolicyDesc": { - "message": "Activeer de automatisch invullen wanneer de pagina geladen is instelling in de browser extensie voor bestaande en nieuwe gebruikers." + "activateAutofillPolicyDescription": { + "message": "Activeer de \"automatisch invullen wanneer de pagina geladen is\"-instelling in de browserextensie voor alle bestaande en nieuwe gebruikers." }, - "experimentalFeature": { - "message": "Gehackte of onbetrouwbare websites kunnen automatisch invullen bij laden van pagina misbruiken." + "autofillOnPageLoadExploitWarning": { + "message": "Gehackte of onbetrouwbare websites kunnen automatisch invullen tijdens het laden van de pagina misbruiken." }, - "learnMoreAboutAutofill": { - "message": "Meer info over automatisch invullen" + "learnMoreAboutAutofillPolicy": { + "message": "Lees meer over automatisch invullen" }, "selectType": { "message": "Selecteer Type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Stuur gebeurtenisgegevens van je kluis naar je Datadog-instance" }, + "huntressEventIntegrationDesc": { + "message": "Stuur eventgegevens naar je Huntress SIEM-instantie" + }, "failedToSaveIntegration": { "message": "Opslaan van integratie mislukt. Probeer het later opnieuw." }, + "mustBeOrganizationOwnerAdmin": { + "message": "Je moet de eigenaar van de organisatie of beheerder zijn om deze actie uit te voeren." + }, "mustBeOrgOwnerToPerformAction": { "message": "Je moet de eigenaar van de organisatie zijn om deze actie uit te voeren." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Selecteer een plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden probeert het domein gedurende de eerste 72 uur driemaal te verifiëren. Als het domein niet geverifieerd kan worden, controleer dan het DNS-record bij je host en verifieer handmatig. Het domein wordt binnen 7 dagen verwijderd uit je organisatie als het niet geverifieerd is." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden probeert het domein binnen 72 uur te claimen. Als het domein niet kan worden geclaimd, controleer dan je DNS-record en claim handmatig. Niet-geclaimde domeinen worden na 7 dagen verwijderd." + }, + "automaticDomainClaimProcess2": { + "message": "Eenmaal geclaimd, zullen bestaande leden met geclaimde domeinen gemaild worden over de " + }, + "accountOwnershipChange": { + "message": "accounteigendom wijziging" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ niet geverifieerd. Controleer je DNS-records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Geverifieerd" }, - "domainStatusUnderVerification": { - "message": "Gebruikersverificatie" + "domainStatusPending": { + "message": "In behandeling" }, "claimedDomainsDescription": { "message": "Claim een domein voor eigendom van ledenaccounts. De SSO-identificatiepagina wordt overgeslagen tijdens het inloggen voor leden met geclaimde domeinen en beheerders kunnen geclaimde accounts verwijderen." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Deze login is in gevaar en mist een website. Voeg een website toe en verander het wachtwoord voor een sterkere beveiliging." }, + "vulnerablePassword": { + "message": "Kwetsbaar wachtwoord." + }, + "changeNow": { + "message": "Nu wijzigen" + }, "missingWebsite": { "message": "Ontbrekende website" }, @@ -11695,8 +11823,8 @@ "message": "Item archiveren", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Items archiveren", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Nu verifiëren." }, + "unlockWithPasskey": { + "message": "Ontgrendelen met passkey" + }, + "prfUnlockFailed": { + "message": "Ontgrendelen met passkey mislukt. Probeer het opnieuw of gebruik een andere ontgrendelingsmethode." + }, + "noPrfCredentialsAvailable": { + "message": "Geen PRF-ingeschakelde passkeys beschikbaar om te ontgrendelen." + }, "additionalStorageGB": { "message": "Extra opslagruimte (GB)" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "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" + }, + "whenYouRemoveStorage": { + "message": "Wanneer je opslag verwijdert, krijg je op je volgende rekening automatisch pro-rata rekeningkrediet." + }, + "ownerBadgeA11yDescription": { + "message": "Eigenaar, $OWNER$, toon alle items in eigendom van $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "Je hebt Premium" + }, + "emailProtected": { + "message": "E-mail beveiligd" + }, + "invalidSendPassword": { + "message": "Ongeldig Send-wachtwoord" + }, + "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." + }, + "perUser": { + "message": "per gebruiker" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/nn/messages.json b/apps/web/src/locales/nn/messages.json index ce9d8f21f40..82a4950f681 100644 --- a/apps/web/src/locales/nn/messages.json +++ b/apps/web/src/locales/nn/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Marker alle" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Marker ingen" }, @@ -1365,6 +1398,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Rei samling" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/or/messages.json b/apps/web/src/locales/or/messages.json index 1ec92241671..2e96d9b844d 100644 --- a/apps/web/src/locales/or/messages.json +++ b/apps/web/src/locales/or/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Select all" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Unselect all" }, @@ -1365,6 +1398,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/pl/messages.json b/apps/web/src/locales/pl/messages.json index a36b3dd4242..4017edab3c4 100644 --- a/apps/web/src/locales/pl/messages.json +++ b/apps/web/src/locales/pl/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Brak zagrożonych aplikacji krytycznych" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Dostęp do informacji" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Ryzyko związne z hasłem" }, @@ -250,6 +274,9 @@ "application": { "message": "Aplikacja" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Zagrożone hasła" }, @@ -586,6 +613,9 @@ "email": { "message": "Adres e-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Zaznacz wszystko" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Odznacz wszystko" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Nie" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokalizacja" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edytuj kolekcję" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Informacje o kolekcji" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Przywróć zaznaczone" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Element został przywrócony" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Tekst" }, - "sendPasswordDescV3": { - "message": "Zabezpiecz tę Wysyłkę hasłem, które będzie wymagane, aby uzyskać do niej dostęp.", - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Wymuś własność danych organizacji" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Co najmniej jedna zasada organizacji uniemożliwia wyeksportowanie osobistego sejfu." }, - "activateAutofill": { - "message": "Włącz autouzupełnianie" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Aktywuj autouzupełnianie podczas wczytywania strony w rozszerzeniu przeglądarki dla wszystkich istniejących i nowych użytkowników." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Zaatakowane lub niezaufane witryny internetowe mogą wykorzystać funkcję autouzupełniania podczas wczytywania strony, aby wyrządzić szkody." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Dowiedz się więcej o autouzupełnianiu" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Wybierz rodzaj logowania jednokrotnego SSO" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Wybierz plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden spróbuje zgłosić domenę 3 razy w ciągu pierwszych 72 godzin. Jeśli nie można zgłosić domeny, sprawdź rekord DNS w swoim serwerze i sprawdź go ręcznie. Domena zostanie usunięta z Twojej organizacji w ciągu 7 dni, jeśli nie zostanie zgłoszona." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nie została zgłoszona. Sprawdź swój rekord DNS.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Zgłoszono" }, - "domainStatusUnderVerification": { - "message": "W trakcie weryfikacji" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Zgłoś domenę, aby posiadać konta członków. Strona identyfikatora SSO zostanie pominięta podczas logowania dla członków z zadeklarowanymi domenami, a administratorzy będą mogli usuwać zadeklarowane konta." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/pt_BR/messages.json b/apps/web/src/locales/pt_BR/messages.json index 27fe0fe76cf..4334dba17b1 100644 --- a/apps/web/src/locales/pt_BR/messages.json +++ b/apps/web/src/locales/pt_BR/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Nenhum aplicativo crítico em risco" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Inteligência de acesso" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Risco de senhas" }, @@ -250,6 +274,9 @@ "application": { "message": "Aplicativo" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Senhas em risco" }, @@ -586,6 +613,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefone" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Selecionar tudo" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Deselecionar tudo" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Não" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Localização" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Próxima cobrança" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plano" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Editar conjunto" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Informações do conjunto" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restaurar selecionados" }, + "archivedItemRestored": { + "message": "Item arquivado restaurado" + }, + "archivedItemsRestored": { + "message": "Itens arquivados restaurados" + }, "restoredItem": { "message": "Item restaurado" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Texto" }, - "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." @@ -5617,21 +5664,41 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendCreatedSuccessfully": { - "message": "Send created successfully!", + "message": "Send criado com sucesso!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copie e compartilhe este link do Send. O Send ficará disponível para qualquer um com o link por $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, "durationTimeHours": { - "message": "$HOURS$ hours", + "message": "$HOURS$ horas", "placeholders": { "hours": { "content": "$1", @@ -5640,11 +5707,11 @@ } }, "newTextSend": { - "message": "New Text Send", + "message": "Novo Send de texto", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "newFileSend": { - "message": "New File Send", + "message": "Novo Send de arquivo", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editedSend": { @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralizar propriedade da organização" + }, + "centralizeDataOwnershipDesc": { + "message": "Todos os itens dos membros serão propriedade e gerenciados pela organização. Os administradores e proprietários estão isentos. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Saiba mais sobre a propriedade centralizada", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefícios" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Tenha visibilidade total na saúde das credenciais, incluindo itens compartilhados ou não." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Transfira itens com facilidade na remoção de um membro, garantindo que não haverão problemas com o acesso." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Dê a todos os usuários um espaço dedicado de \"Meus itens\" para que gerenciem suas próprias credenciais." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Solicitar transferência dos itens dos membros" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "Se os membros tiverem itens no seu cofre individual, eles terão que transferi-los para a organização ou sair. Se saírem, seu acesso será revogado mas pode ser restaurado a qualquer momento." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Saiba mais sobre a transferência" + }, "organizationDataOwnership": { "message": "Aplicar propriedade de dados da organização" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Uma ou mais políticas da organização impedem que você exporte seu cofre individual." }, - "activateAutofill": { - "message": "Ativar preenchimento automático" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Ative a configuração de preenchimento automático no carregamento da página na extensão do navegador para todos os membros existentes e novos." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Sites comprometidos ou não confiáveis podem tomar vantagem do preenchimento automático ao carregar a página." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Saiba mais sobre o preenchimento automático" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Selecionar tipo de SSO" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Envie dados de eventos do cofre a sua instância do Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Envie dados de eventos para sua instância do Huntress SIEM" + }, "failedToSaveIntegration": { "message": "Falha ao salvar a integração. Tente novamente mais tarde." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "Você precisa ser o proprietário da organização para executar esta ação." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Índice" }, + "httpEventCollectorUrl": { + "message": "URL do coletor de eventos HTTP" + }, + "httpEventCollectorToken": { + "message": "Token do coletor de eventos HTTP" + }, "selectAPlan": { "message": "Selecione um plano" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "O Bitwarden tentará reivindicar o domínio 3 vezes durante as primeiras 72 horas. Se o domínio não poder ser reivindicado, confira o registro de DNS no seu servidor e reivindique manualmente. Se não for reivindicado, o domínio será removido da sua organização em 7 dias." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ não reivindicado. Confira os seus registros de DNS.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Reivindicado" }, - "domainStatusUnderVerification": { - "message": "Em verificação" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Reivindique um domínio para ser o proprietário das contas dos membros. A página do identificador do SSO será pulada durante a autenticação dos membros com os domínios reivindicados, e os administradores poderão apagar contas reivindicadas." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Esta credencial está em risco e está sem um site. Adicione um site e altere a senha para segurança melhor." }, + "vulnerablePassword": { + "message": "Senha vulnerável." + }, + "changeNow": { + "message": "Alterar agora" + }, "missingWebsite": { "message": "Site ausente" }, @@ -11680,7 +11808,7 @@ "message": "Itens foram enviados para o arquivo" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "O item foi desarquivado" }, "itemUnarchived": { "message": "O item foi desarquivado" @@ -11695,8 +11823,8 @@ "message": "Arquivar item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Arquivar itens", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verifique agora." }, + "unlockWithPasskey": { + "message": "Desbloquear com chave de acesso" + }, + "prfUnlockFailed": { + "message": "Falha no desbloqueio com a chave de acesso. Tente novamente ou use outro método de desbloqueio." + }, + "noPrfCredentialsAvailable": { + "message": "Nenhuma chave de acesso com PRF está disponível para desbloqueio." + }, "additionalStorageGB": { "message": "GB de armazenamento adicional" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "Você usou todos os $GB$ GB do seu armazenamento criptografado. Para continuar armazenando arquivos, adicione mais armazenamento." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "Quando você remover o armazenamento, você receberá um crédito de conta proporcional que irá automaticamente para sua próxima fatura." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "Você tem o Premium" + }, + "emailProtected": { + "message": "E-mail protegido" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/pt_PT/messages.json b/apps/web/src/locales/pt_PT/messages.json index f7c6a655bc8..912019b298c 100644 --- a/apps/web/src/locales/pt_PT/messages.json +++ b/apps/web/src/locales/pt_PT/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Não há aplicações críticas em risco" }, + "critical": { + "message": "Críticas ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Não críticas ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Crítica" + }, "accessIntelligence": { "message": "Inteligência de Acesso" }, + "noApplicationsMatchTheseFilters": { + "message": "Nenhuma aplicação corresponde a estes filtros" + }, "passwordRisk": { "message": "Risco da palavra-passe" }, @@ -250,6 +274,9 @@ "application": { "message": "Aplicação" }, + "applications": { + "message": "Aplicações" + }, "atRiskPasswords": { "message": "Palavras-passe em risco" }, @@ -586,6 +613,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "E-mails" + }, "phone": { "message": "Telefone" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Selecionar tudo" }, + "deselectAll": { + "message": "Desmarcar tudo" + }, "unselectAll": { "message": "Desmarcar tudo" }, @@ -1291,7 +1324,7 @@ "message": "Eliminar anexo" }, "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" @@ -1365,6 +1398,12 @@ "no": { "message": "Não" }, + "noAuth": { + "message": "Qualquer pessoa com o link" + }, + "anyOneWithPassword": { + "message": "Qualquer pessoa com uma palavra-passe definida por si" + }, "location": { "message": "Localização" }, @@ -3156,7 +3195,7 @@ "message": "O item foi restaurado" }, "restartPremium": { - "message": "Reiniciar Premium" + "message": "Reiniciar o Premium" }, "additionalStorageGb": { "message": "Armazenamento adicional (GB)" @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Próxima cobrança" }, + "nextChargeDate": { + "message": "Próxima data de cobrança" + }, "plan": { "message": "Plano" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Editar coleção" }, + "viewCollection": { + "message": "Ver coleção" + }, "collectionInfo": { "message": "Informações da coleção" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restaurar selecionados" }, + "archivedItemRestored": { + "message": "Item arquivado restaurado" + }, + "archivedItemsRestored": { + "message": "Itens arquivados restaurados" + }, "restoredItem": { "message": "Item restaurado" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Texto" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send criado com sucesso!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copie e partilhe este link do Send. O Send estará disponível para qualquer pessoa com o link durante os próximos $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copie e partilhe este link do Send. O Send estará disponível para qualquer pessoa com o link e palavras-passe durante os próximos $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copie e partilhe este link do Send. Pode ser visualizado pelas pessoas que especificou durante os próximos $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralizar a propriedade da organização" + }, + "centralizeDataOwnershipDesc": { + "message": "Todos os itens dos membros serão propriedade da organização e geridos por esta. Os administradores e proprietários estão isentos. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Saiba mais sobre a propriedade centralizada", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefícios" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Obtenha visibilidade total sobre a segurança das credenciais, incluindo itens partilhados e não partilhados." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Transfira facilmente os itens durante a saída de membros ou sucessão, garantindo que não existem lacunas de acesso." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Ofereça a todos os utilizadores um espaço dedicado “Os meus itens” para gerirem as suas próprias credenciais." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Solicitar aos membros que transfiram os seus itens" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "Se os membros tiverem itens no seu cofre individual, será solicitado que os transfiram para a organização ou que os deixem no seu cofre pessoal. Se optarem por deixar, o acesso será revogado, mas poderá ser restaurado a qualquer momento." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Saiba mais sobre a transferência" + }, "organizationDataOwnership": { "message": "Reforçar a propriedade dos dados da organização" }, @@ -6888,16 +6986,16 @@ "personalVaultExportPolicyInEffect": { "message": "Uma ou mais políticas da organização impedem-no de exportar o seu cofre pessoal." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Ativar o preenchimento automático" }, - "activateAutofillPolicyDesc": { - "message": "Ative a definição de preenchimento automático ao carregar a página na extensão do navegador para todos os membros existentes e novos." + "activateAutofillPolicyDescription": { + "message": "Ative a definição de preenchimento automático ao carregar a página na extensão do navegador para todos os membros atuais e novos." }, - "experimentalFeature": { - "message": "Os sites comprometidos ou não fiáveis podem explorar o preenchimento automático ao carregar a página." + "autofillOnPageLoadExploitWarning": { + "message": "Os sites comprometidos ou não confiáveis podem explorar o preenchimento automático ao carregar a página." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Saber mais sobre o preenchimento automático" }, "selectType": { @@ -7998,7 +8096,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", @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Envie dados de eventos do cofre para a sua instância da Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Enviar dados de eventos para a sua instância Huntress SIEM" + }, "failedToSaveIntegration": { "message": "Falha ao guardar a integração. Por favor, tente novamente mais tarde." }, + "mustBeOrganizationOwnerAdmin": { + "message": "Precisa de ser um proprietário da organização ou administrador para executar esta ação." + }, "mustBeOrgOwnerToPerformAction": { "message": "Precisa de ser o proprietário da organização para executar esta ação." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Índice" }, + "httpEventCollectorUrl": { + "message": "URL do coletor de eventos HTTP" + }, + "httpEventCollectorToken": { + "message": "Token do coletor de eventos HTTP" + }, "selectAPlan": { "message": "Selecionar um plano" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "O Bitwarden tentará reivindicar o domínio 3 vezes durante as primeiras 72 horas. Se o domínio não puder ser reivindicado, verifique o registo DNS no seu anfitrião e reivindique manualmente. O domínio será removido da sua organização em 7 dias se não for reivindicado." }, + "automaticDomainClaimProcess1": { + "message": "O Bitwarden tentará reclamar o domínio no prazo de 72 horas. Caso não seja possível reclamar o domínio, verifique o registo DNS e faça a reclamação manualmente. Os domínios não reclamados são removidos após 7 dias." + }, + "automaticDomainClaimProcess2": { + "message": "Após a reclamação, os membros existentes com domínios reclamados serão notificados por e-mail sobre a " + }, + "accountOwnershipChange": { + "message": "alteração da titularidade da conta" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ não reivindicado. Verifique o seu registo DNS.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Reivindicado" }, - "domainStatusUnderVerification": { - "message": "Sob verificação" + "domainStatusPending": { + "message": "Pendente" }, "claimedDomainsDescription": { "message": "Reivindique um domínio para possuir contas de membros. A página do identificador SSO será ignorada durante o início de sessão para membros com domínios reivindicados e os administradores poderão eliminar contas reivindicadas." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Esta credencial está em risco e não tem um site. Adicione um site e altere a palavra-passe para uma segurança mais forte." }, + "vulnerablePassword": { + "message": "Palavra-passe vulnerável." + }, + "changeNow": { + "message": "Alterar agora" + }, "missingWebsite": { "message": "Site em falta" }, @@ -11695,8 +11823,8 @@ "message": "Arquivar item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Arquivar itens", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verificar agora." }, + "unlockWithPasskey": { + "message": "Desbloquear com chave de acesso" + }, + "prfUnlockFailed": { + "message": "Não foi possível desbloquear com a chave de acesso. Por favor, tente novamente ou utilize outro método de desbloqueio." + }, + "noPrfCredentialsAvailable": { + "message": "Não estão disponíveis chaves de acesso com PRF ativado para o desbloqueio." + }, "additionalStorageGB": { "message": "Armazenamento adicional (GB)" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "Utilizou os $GB$ GB do seu armazenamento encriptado. Para continuar a guardar ficheiros, adicione mais espaço de armazenamento." + }, + "whoCanView": { + "message": "Quem pode ver" + }, + "specificPeople": { + "message": "Pessoas específicas" + }, + "emailVerificationDesc": { + "message": "Após partilhar este Send através do link, os indivíduos terão de verificar o e-mail com um código para poderem ver este Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Introduza vários e-mails, separados por vírgula." + }, + "emailPlaceholder": { + "message": "utilizador@bitwarden.com , utilizador@acme.com" + }, + "whenYouRemoveStorage": { + "message": "Ao remover espaço de armazenamento, receberá um crédito proporcional na conta, que será automaticamente aplicado na sua próxima fatura." + }, + "ownerBadgeA11yDescription": { + "message": "Proprietário, $OWNER$, mostrar todos os itens pertencentes a $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "Tem Premium" + }, + "emailProtected": { + "message": "E-mail protegido" + }, + "invalidSendPassword": { + "message": "Palavra-passe do Send inválida" + }, + "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." + }, + "perUser": { + "message": "por utilizador" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/ro/messages.json b/apps/web/src/locales/ro/messages.json index a18feeaf24c..f7c232a321e 100644 --- a/apps/web/src/locales/ro/messages.json +++ b/apps/web/src/locales/ro/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Nicio aplicație critică în pericol" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Risc Parola" }, @@ -250,6 +274,9 @@ "application": { "message": "Aplicație" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Selectare totală" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Deselectare toate" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Nu" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Locație" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Editare colecție" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restaurare selecție" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Articol restaurat" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Una sau mai multe politici de organizație împiedică exportul seifului individual." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Selectare tip SSO" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/ru/messages.json b/apps/web/src/locales/ru/messages.json index 59b9d91b5e8..2e4cf303394 100644 --- a/apps/web/src/locales/ru/messages.json +++ b/apps/web/src/locales/ru/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Никакие критичные приложения не подвергаются риску" }, + "critical": { + "message": "Критичные ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Не критичные ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Критично" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "Нет приложений, соответствующих этим фильтрам" + }, "passwordRisk": { "message": "Риск пароля" }, @@ -250,6 +274,9 @@ "application": { "message": "Приложение" }, + "applications": { + "message": "Приложения" + }, "atRiskPasswords": { "message": "Пароли, подверженные риску" }, @@ -404,7 +431,7 @@ "message": "Обзор приложения сохранен" }, "newApplicationsReviewed": { - "message": "Рассмотрены новые заявки" + "message": "Рассмотрены новые приложения" }, "errorSavingReviewStatus": { "message": "Ошибка сохранения статуса обзора" @@ -586,6 +613,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Телефон" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Выбрать все" }, + "deselectAll": { + "message": "Отменить выбор" + }, "unselectAll": { "message": "Отменить выбор" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Нет" }, + "noAuth": { + "message": "Любой, у кого есть ссылка" + }, + "anyOneWithPassword": { + "message": "Любой, у кого есть установленный вами пароль" + }, "location": { "message": "Местоположение" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Следующий платеж" }, + "nextChargeDate": { + "message": "Дата следующего платежа" + }, "plan": { "message": "План" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Редактировать коллекцию" }, + "viewCollection": { + "message": "Посмотреть коллекцию" + }, "collectionInfo": { "message": "Информация о коллекции" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Восстановить выбранные" }, + "archivedItemRestored": { + "message": "Архивированный элемент восстановлен" + }, + "archivedItemsRestored": { + "message": "Архивированные элементы восстановлены" + }, "restoredItem": { "message": "Элемент восстановлен" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "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." @@ -5620,13 +5667,33 @@ "message": "Send успешно создана!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Скопируйте и распространите эту ссылку для Send. Она может быть просмотрена указанными вами пользователями в следующие $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Скопируйте и поделитесь этой ссылкой Send. Send будет доступна всем, у кого есть ссылка, в течение следующих $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Скопируйте и поделитесь этой ссылкой Send. Send будет доступна всем, у кого есть ссылка и пароль в течение следующих $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { + "message": "Скопируйте и распространите эту ссылку для Send. Она может быть просмотрена указанными вами пользователями в течение $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Централизация ответственности за организацию" + }, + "centralizeDataOwnershipDesc": { + "message": "Все элементы пользователя будут принадлежать и управляться организацией. Администраторы и владельцы не участвуют в этом процессе. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Узнайте больше о централизованном владении", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Преимущества" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Получите полную информацию о состоянии учетных данных, включая общие и частные элементы." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Легко перемещайте элементы во время увольнения участников и их наследования, гарантируя отсутствие пробелов в доступе." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Предоставьте всем пользователям выделенное пространство \"Мои элементы\" для управления их собственными логинами." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Предложите пользователям перенести их элементы" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "Если у участников есть какие-либо элементы в их личном хранилище, им будет предложено либо передать их организации, либо покинуть ее. После выхода из организации их доступ будет аннулирован, но может быть восстановлен в любое время." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Узнайте больше о передаче" + }, "organizationDataOwnership": { "message": "Принудительное соблюдение прав собственности на данные организации" }, @@ -6888,16 +6986,16 @@ "personalVaultExportPolicyInEffect": { "message": "Одна или несколько политик организации запрещают вам экспортировать личное хранилище." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Активировать автозаполнение" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Включить автозаполнение при загрузке страницы в расширении браузера для всех существующих и новых участников." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "Взломанные или недоверенные сайты могут внедрить вредоносный код во время автозаполнения при загрузке страницы." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Узнать больше об автозаполнении" }, "selectType": { @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Отправляйте данные о событиях хранилища в ваш экземпляр Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Отправлять данные о событиях в ваш инстанс Huntress SIEM" + }, "failedToSaveIntegration": { "message": "Не удалось сохранить интеграцию. Пожалуйста, повторите попытку позже." }, + "mustBeOrganizationOwnerAdmin": { + "message": "Для выполнения этого действия вы должны быть владельцем организации или администратором." + }, "mustBeOrgOwnerToPerformAction": { "message": "Для выполнения этого действия вы должны быть владельцем организации." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Индекс" }, + "httpEventCollectorUrl": { + "message": "URL коллектора событий HTTP" + }, + "httpEventCollectorToken": { + "message": "Токен коллектора событий HTTP" + }, "selectAPlan": { "message": "Выберите план" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden попытается зарегистрировать домен 3 раза в течение первых 72 часов. Если домен не удастся зарегистрировать, проверьте запись DNS на вашем хосте и зарегистрируйте вручную. Домен будет удален из вашей организации через 7 дней, если он не будет зарегистрирован." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden попытается заявить права на домен в течение 72 часов. Если домен не может быть заявлен, проверьте свою запись в DNS и подайте заявку вручную. Невостребованные домены удаляются через 7 дней." + }, + "automaticDomainClaimProcess2": { + "message": "После подачи заявки существующим участникам с заявленными доменами будет отправлено электронное письмо с информацией об " + }, + "accountOwnershipChange": { + "message": "изменении владельца аккаунта" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ не зарегистрирован. Проверьте записи DNS.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Зарегистрирован" }, - "domainStatusUnderVerification": { - "message": "Проверяется" + "domainStatusPending": { + "message": "Ожидание" }, "claimedDomainsDescription": { "message": "Заявите права на домен, чтобы владеть аккаунтами членов. Страница идентификатора SSO будет пропущена при пользователей с заявленными доменами, а администраторы смогут удалять заявленные аккаунты." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Этот логин подвержен риску и у него отсутствует веб-сайт. Добавьте веб-сайт и смените пароль для большей безопасности." }, + "vulnerablePassword": { + "message": "Уязвимый пароль." + }, + "changeNow": { + "message": "Изменить сейчас" + }, "missingWebsite": { "message": "Отсутствует сайт" }, @@ -11695,8 +11823,8 @@ "message": "Архивировать элемент", "description": "Verb" }, - "archiveItemConfirmDesc": { - "message": "Архивированные элементы исключены из общих результатов поиска и предложений автозаполнения. Вы уверены, что хотите архивировать этот элемент?" + "archiveItemDialogContent": { + "message": "После архивации этот элемент будет исключен из результатов поиска и предложений по автозаполнению." }, "archiveBulkItems": { "message": "Архивировать элементы", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Подтвердить сейчас." }, + "unlockWithPasskey": { + "message": "Разблокировать при помощи passkey" + }, + "prfUnlockFailed": { + "message": "Не удалось разблокировать с помощью passkey. Пожалуйста, повторите попытку или используйте другой метод разблокировки." + }, + "noPrfCredentialsAvailable": { + "message": "Для разблокировки недоступны passkeys с поддержкой PRF." + }, "additionalStorageGB": { "message": "Дополнительные ГБ хранилища" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "Вы использовали все $GB$ вашего зашифрованного хранилища. Чтобы продолжить хранение файлов, добавьте дополнительное хранилище." + }, + "whoCanView": { + "message": "Кто может просматривать" + }, + "specificPeople": { + "message": "Конкретные пользователи" + }, + "emailVerificationDesc": { + "message": "После того, как вы поделитесь ссылкой на Send, пользователю нужно будет подтвердить свой email кодом, чтобы просмотреть эту Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Введите несколько email, разделяя их запятой." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "При удалении хранилища вы получите пропорциональную сумму на свой счет, которая автоматически пойдет на оплату вашего следующего счета." + }, + "ownerBadgeA11yDescription": { + "message": "Владелец, $OWNER$, показать все элементы, принадлежащие $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "У вас Премиум" + }, + "emailProtected": { + "message": "Email защищен" + }, + "invalidSendPassword": { + "message": "Неверный пароль Send" + }, + "sendPasswordHelperText": { + "message": "Пользователям необходимо будет ввести пароль для просмотра этой Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "perUser": { + "message": "на пользователя" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/si/messages.json b/apps/web/src/locales/si/messages.json index f1d51e69a7b..b6cd6268bce 100644 --- a/apps/web/src/locales/si/messages.json +++ b/apps/web/src/locales/si/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "වි-තැපෑල" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "දුරකථනය" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Select all" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Unselect all" }, @@ -1365,6 +1398,12 @@ "no": { "message": "නැහැ" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/sk/messages.json b/apps/web/src/locales/sk/messages.json index 630083ef335..10e52755a17 100644 --- a/apps/web/src/locales/sk/messages.json +++ b/apps/web/src/locales/sk/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Nie sú ohrozené žiadne kritické aplikácie" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Prehľad o prístupe" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Ohrozenie hesla" }, @@ -250,6 +274,9 @@ "application": { "message": "Aplikácia" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Ohrozených hesiel" }, @@ -586,6 +613,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefón" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Vybrať Všetko" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Zrušiť výber" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Nie" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Poloha" }, @@ -1750,7 +1789,7 @@ "message": "Neexistujú žiadni členovia na zobrazenie." }, "noMembersToExport": { - "message": "There are no members to export." + "message": "Neexistujú žiadni členovia na export." }, "noEventsInList": { "message": "Neexistujú žiadne udalosti na zobrazenie." @@ -1977,7 +2016,7 @@ "description": "The noun form of the word Export" }, "exportVerb": { - "message": "Export", + "message": "Exportovať", "description": "The verb form of the word Export" }, "exportFrom": { @@ -2310,7 +2349,7 @@ "description": "The noun form of the word Import" }, "importVerb": { - "message": "Import", + "message": "Importovať", "description": "The verb form of the word Import" }, "importData": { @@ -2541,7 +2580,7 @@ "message": "Povolené" }, "optionEnabled": { - "message": "Enabled" + "message": "Povolené" }, "restoreAccess": { "message": "Obnoviť prístup" @@ -2641,7 +2680,7 @@ "message": "Kľúč" }, "unnamedKey": { - "message": "Unnamed key" + "message": "Kľúč bez mena" }, "twoStepAuthenticatorEnterCodeV2": { "message": "Overovací kód" @@ -3153,7 +3192,7 @@ "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, "itemRestored": { - "message": "Item has been restored" + "message": "Položka bola obnovená" }, "restartPremium": { "message": "Restart Premium" @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Ďalšia platba" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plán" }, @@ -3306,7 +3348,7 @@ "message": "Launch Cloud Subscription" }, "launchCloudSubscriptionSentenceCase": { - "message": "Launch cloud subscription" + "message": "Spustiť cloud predplatné" }, "storage": { "message": "Ukladací priestor" @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Upraviť zbierku" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Informácie o zbierke" }, @@ -4224,10 +4269,10 @@ } }, "userAcceptedTransfer": { - "message": "Accepted transfer to organization ownership." + "message": "Prijatý presun do vlastníctva organizácie." }, "userDeclinedTransfer": { - "message": "Revoked for declining transfer to organization ownership." + "message": "Odvolane z dôvodu odmietnutia presunu do vlastníctva organizácie." }, "invitedUserId": { "message": "Používateľ $ID$ pozvaný.", @@ -5195,19 +5240,19 @@ "description": "This is a verb. ex. 'Fix The Car'" }, "fixEncryption": { - "message": "Fix encryption" + "message": "Opraviť šifrovanie" }, "fixEncryptionTooltip": { - "message": "This file is using an outdated encryption method." + "message": "Tento súbor používa zastaranú metódu šifrovania." }, "attachmentUpdated": { - "message": "Attachment updated" + "message": "Príloha bola aktualizovaná" }, "oldAttachmentsNeedFixDesc": { "message": "V trezore máte staré prílohy ktoré musia byť opravené pred tým, než budete môcť obnoviť šifrovací kľúč k účtu." }, "itemsTransferred": { - "message": "Items transferred" + "message": "Položky boli prenesené" }, "yourAccountsFingerprint": { "message": "Fráza odtlačku vašeho účtu", @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Obnoviť zvolené" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Obnovená položka" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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": "Vytvoriť nový Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -5617,21 +5664,41 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendCreatedSuccessfully": { - "message": "Send created successfully!", + "message": "Send bol úspešne vytvorený!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, "durationTimeHours": { - "message": "$HOURS$ hours", + "message": "$HOURS$ hodín", "placeholders": { "hours": { "content": "$1", @@ -5640,11 +5707,11 @@ } }, "newTextSend": { - "message": "New Text Send", + "message": "Nový textový Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "newFileSend": { - "message": "New File Send", + "message": "Nový súborový Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editedSend": { @@ -5687,7 +5754,7 @@ "message": "Zrušený prístup" }, "accepted": { - "message": "Accepted" + "message": "Prijaté" }, "sendLink": { "message": "Odkaz na Send", @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Požadovanie vlastníctva údajov organizácie" }, @@ -6348,10 +6446,10 @@ "message": "Zapísaný na obnovu konta" }, "enrolled": { - "message": "Enrolled" + "message": "Zapísaný" }, "notEnrolled": { - "message": "Not enrolled" + "message": "Nezapísaný" }, "withdrawAccountRecovery": { "message": "Odhlásiť sa z obnovy konta" @@ -6877,7 +6975,7 @@ "message": "Časový limit trezoru nie je v povolenom rozsahu." }, "disableExport": { - "message": "Remove export" + "message": "Odstrániť export" }, "disablePersonalVaultExportDescription": { "message": "Zakázať používateľom exportovať údaje zo súkromného trezora." @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Jedna alebo viacero zásad organizácie vám bráni exportovať váš osobný trezor." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Aktivujte nastavenie automatického vypĺňania pri načítaní stránky v rozšírení pre prehliadač pre všetkých súčasných a nových členov." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Vyberte typ SSO" @@ -9546,7 +9644,7 @@ "message": "Vyžaduje sa prihlásenie cez SSO" }, "emailRequiredForSsoLogin": { - "message": "Email is required for SSO" + "message": "Pre SSO je potrebný email" }, "selectedRegionFlag": { "message": "Selected region flag" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Pošlite dáta z denníka udalostí do vašej inštancie Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Nepodarilo sa uložiť integráciu. Prosím skúste to neskôr." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "Pre vykonanie tejto akcie musíte by vlastník organizácie." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Vyberte plán" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden sa pokúsi privlastniť doménu 3 krát počas prvých 72 hodín. Ak sa doménu nepodarilo privlastniť, skontrolujte DNS záznam u svojho hostiteľa a privlastnite manuálne. Doména bude z organizácie odstránená po 7 dňoch ak nie je privlastnená." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nie je privlastnená. Overte si DNS záznam.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Privlastnená" }, - "domainStatusUnderVerification": { - "message": "Overuje sa" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Privlastnite si doménu, aby ste mohli vlastniť členské účty. Stránka s identifikátorom SSO bude pri prihlásení členov s privlastnenými doménami preskočená a správcovia budú môcť členské účty odstrániť." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Toto prihlásenie je v ohrození a chýba mu webová stránka. Pridajte webovú stránku a zmeňte heslo na silnejšie zabezpečenie." }, + "vulnerablePassword": { + "message": "Zraniteľné heslo." + }, + "changeNow": { + "message": "Zmeniť teraz" + }, "missingWebsite": { "message": "Chýbajúca webová stránka" }, @@ -11659,7 +11787,7 @@ "message": "Zrušiť archiváciu" }, "archived": { - "message": "Archived" + "message": "Archivované" }, "unArchiveAndSave": { "message": "Unarchive and save" @@ -11680,7 +11808,7 @@ "message": "Položky boli archivované" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "Položka bola odobraná z archívu" }, "itemUnarchived": { "message": "Položka bola odobraná z archívu" @@ -11695,8 +11823,8 @@ "message": "Archivovať položku", "description": "Verb" }, - "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": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archiveBulkItems": { "message": "Archivovať položky", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Overiť teraz." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Dodatočné úložisko GB" }, @@ -12309,43 +12446,43 @@ } }, "removeMasterPasswordForOrgUserKeyConnector": { - "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." + "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." }, "continueWithLogIn": { - "message": "Continue with log in" + "message": "Pokračujte prihlásením" }, "doNotContinue": { - "message": "Do not continue" + "message": "Nepokračovať" }, "domain": { - "message": "Domain" + "message": "Doména" }, "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áto doména bude ukladať šifrovacie kľúče vášho účtu, takže sa uistite, že jej dôverujete. Ak si nie ste istí, overte si to u správcu." }, "verifyYourOrganization": { - "message": "Verify your organization to log in" + "message": "Na prihlásenie overte organizáciu" }, "organizationVerified": { - "message": "Organization verified" + "message": "Organizácia je overená" }, "domainVerified": { - "message": "Domain verified" + "message": "Doména je overená" }, "leaveOrganizationContent": { - "message": "If you don't verify your organization, your access to the organization will be revoked." + "message": "Ak organizáciu neoveríte, váš prístup k nej bude zrušený." }, "leaveNow": { - "message": "Leave now" + "message": "Opustiť teraz" }, "verifyYourDomainToLogin": { - "message": "Verify your domain to log in" + "message": "Na prihlásenie overte doménu" }, "verifyYourDomainDescription": { - "message": "To continue with log in, verify this domain." + "message": "Na pokračovanie prihlásením, overte túto doménu." }, "confirmKeyConnectorOrganizationUserDescription": { - "message": "To continue with log in, verify the organization and domain." + "message": "Na pokračovanie prihlásením, overte organizáciu a doménu." }, "confirmNoSelectedCriticalApplicationsTitle": { "message": "Nie sú vybrané žiadne kritické aplikácie" @@ -12357,55 +12494,55 @@ "message": "Zlyhalo overenie používateľa." }, "resizeSideNavigation": { - "message": "Resize side navigation" + "message": "Zmeniť veľkosť bočnej navigácie" }, "recoveryDeleteCiphersTitle": { - "message": "Delete unrecoverable vault items" + "message": "Vymazať neobnoviteľné položky trezora" }, "recoveryDeleteCiphersDesc": { - "message": "Some of your vault items could not be recovered. Do you want to delete these unrecoverable items from your vault?" + "message": "Niektoré vaše položky trezora sa nepodarilo obnoviť. Chcete tieto neobnoviteľné položky vymazať z vášho trezora?" }, "recoveryDeleteFoldersTitle": { - "message": "Delete unrecoverable folders" + "message": "Vymazať neobnoviteľné priečinky trezora" }, "recoveryDeleteFoldersDesc": { - "message": "Some of your folders could not be recovered. Do you want to delete these unrecoverable folders from your vault?" + "message": "Niektoré vaše priečinky sa nepodarilo obnoviť. Chcete tieto neobnoviteľné priečinky vymazať z vášho trezora?" }, "recoveryReplacePrivateKeyTitle": { - "message": "Replace encryption key" + "message": "Vymeniť šifrovací kľúč" }, "recoveryReplacePrivateKeyDesc": { - "message": "Your public-key encryption key pair could not be recovered. Do you want to replace your encryption key with a new key pair? This will require you to set up existing emergency-access and organization memberships again." + "message": "Váš verejný a šifrovací pár kľúčov sa nepodarilo obnoviť. Chcete ich nahradiť novým párom kľúčov? Bude to vyžadovať opätovné nastavenie existujúceho núdzového prístupu a členstiev v organizáciach." }, "recoveryStepSyncTitle": { - "message": "Synchronizing data" + "message": "Synchronizácia údajov" }, "recoveryStepPrivateKeyTitle": { - "message": "Verifying encryption key integrity" + "message": "Overuje sa integrita šifrovacieho kľúča" }, "recoveryStepUserInfoTitle": { - "message": "Verifying user information" + "message": "Overujú sa údaje o používateľovi" }, "recoveryStepCipherTitle": { - "message": "Verifying vault item integrity" + "message": "Overuje sa integrita položky trezora" }, "recoveryStepFoldersTitle": { - "message": "Verifying folder integrity" + "message": "Overuje sa integrita priečinka" }, "dataRecoveryTitle": { - "message": "Data Recovery and Diagnostics" + "message": "Obnova Dát a Diagnostika" }, "dataRecoveryDescription": { - "message": "Use the data recovery tool to diagnose and repair issues with your account. After running diagnostics you have the option to save diagnostic logs for support and the option to repair any detected issues." + "message": "Pre diagnostiku a opravu problémov s vašim kontom použite nástroj na obnovu dát. Po spustení diagnostiky budete mat možnosť opraviť odhalené problémy a možnosť uložiť záznamy z priebehu diagnostiky pre podporu." }, "runDiagnostics": { - "message": "Run Diagnostics" + "message": "Spustiť diagnostiku" }, "repairIssues": { - "message": "Repair Issues" + "message": "Opraviť problémy" }, "saveDiagnosticLogs": { - "message": "Save Diagnostic Logs" + "message": "Uložiť záznamy diagnostiky" }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Toto nastavenie spravuje vaša organizácia." @@ -12446,16 +12583,16 @@ "message": "Nastavte metódu odomknutia, aby ste zmenili akciu pri vypršaní časového limitu" }, "leaveConfirmationDialogTitle": { - "message": "Are you sure you want to leave?" + "message": "Naozaj chcete odísť?" }, "leaveConfirmationDialogContentOne": { - "message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features." + "message": "Ak odmietnete, vaše osobné položky zostanú vo vašom účte, ale stratíte prístup k zdieľaným položkám a funkciám organizácie." }, "leaveConfirmationDialogContentTwo": { - "message": "Contact your admin to regain access." + "message": "Ak chcete obnoviť prístup, obráťte sa na správcu." }, "leaveConfirmationDialogConfirmButton": { - "message": "Leave $ORGANIZATION$", + "message": "Opustiť $ORGANIZATION$", "placeholders": { "organization": { "content": "$1", @@ -12464,10 +12601,10 @@ } }, "howToManageMyVault": { - "message": "How do I manage my vault?" + "message": "Ako môžem spravovať svoj trezor?" }, "transferItemsToOrganizationTitle": { - "message": "Transfer items to $ORGANIZATION$", + "message": "Prenos položiek do $ORGANIZATION$", "placeholders": { "organization": { "content": "$1", @@ -12476,7 +12613,7 @@ } }, "transferItemsToOrganizationContent": { - "message": "$ORGANIZATION$ is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items.", + "message": "$ORGANIZATION$ vyžaduje, aby všetky položky boli vo vlastníctve organizácie z dôvodu bezpečnosti a dodržiavania predpisov. Ak chcete previesť vlastníctvo položiek, kliknite na tlačidlo Prijať.", "placeholders": { "organization": { "content": "$1", @@ -12485,22 +12622,22 @@ } }, "acceptTransfer": { - "message": "Accept transfer" + "message": "Prijať prenos" }, "declineAndLeave": { - "message": "Decline and leave" + "message": "Zamietnuť a odísť" }, "whyAmISeeingThis": { - "message": "Why am I seeing this?" + "message": "Prečo to vidím?" }, "youHaveBitwardenPremium": { - "message": "You have Bitwarden Premium" + "message": "Máte Bitwarden Premium" }, "viewAndManagePremiumSubscription": { - "message": "View and manage your Premium subscription" + "message": "Zobraziť a spravovať vaše Premium predplatné" }, "youNeedToUpdateLicenseFile": { - "message": "You'll need to update your license file" + "message": "Musíte aktualizovať licenčný súbor" }, "youNeedToUpdateLicenseFileDate": { "message": "$DATE$.", @@ -12512,16 +12649,16 @@ } }, "uploadLicenseFile": { - "message": "Upload license file" + "message": "Nahrať licenčný súbor" }, "uploadYourLicenseFile": { - "message": "Upload your license file" + "message": "Nahrajte váš licenčný súbor" }, "uploadYourPremiumLicenseFile": { - "message": "Upload your Premium license file" + "message": "Nahrajte váš Premium licenčný súbor" }, "uploadLicenseFileDesc": { - "message": "Your license file name will be similar to: $FILE_NAME$", + "message": "Váš licenčný súbor sa bude volať podobne ako: $FILE_NAME$", "placeholders": { "file_name": { "content": "$1", @@ -12530,28 +12667,28 @@ } }, "alreadyHaveSubscriptionQuestion": { - "message": "Already have a subscription?" + "message": "Už máte predplatné?" }, "alreadyHaveSubscriptionSelfHostedMessage": { - "message": "Open the subscription page on your Bitwarden cloud account and download your license file. Then return to this screen and upload it below." + "message": "Otvorte stránku predplatného vo vašom Bitwarden cloud konte a stiahnite váš licenčný súbor. Potom sa vráťte na tuto obrazovku a nahrajte ho nižšie." }, "viewAllPlans": { - "message": "View all plans" + "message": "Zobraziť všetky druhy predplatného" }, "planDescPremium": { - "message": "Complete online security" + "message": "Úplné online zabezpečenie" }, "updatePayment": { - "message": "Update payment" + "message": "Úpraviť Platbu" }, "weCouldNotProcessYourPayment": { - "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + "message": "Nepodarilo sa nám spracovať platbu. Prosím, aktualizujte vašu platobnú metódu alebo pre pomoc kontaktujte tím podpory." }, "yourSubscriptionHasExpired": { - "message": "Your subscription has expired. Please contact the support team for assistance." + "message": "Vaše predplatné vypršalo. Pre pomoc kontaktujte tím podpory." }, "yourSubscriptionIsScheduledToCancel": { - "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "message": "Vaše predplatné ma naplánovaný koniec na $DATE$. Kedykoľvek predtým ho môžete opätovne obnoviť.", "placeholders": { "date": { "content": "$1", @@ -12560,10 +12697,10 @@ } }, "premiumShareEvenMore": { - "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + "message": "Zdieľajte ešte viac s predplatným Rodiny, alebo dosiahnite výkonnú a dôveryhodnú bezpečnosť s predplatným Tímy alebo Spoločnosť." }, "youHaveAGracePeriod": { - "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "message": "Máte odklad splatnosti v dĺžke $DAYS$ dní od dátumu vypršania platnosti predplatného. Prosím, uhraďte nezaplatené faktúry do $DATE$.", "placeholders": { "days": { "content": "$1", @@ -12576,31 +12713,31 @@ } }, "manageInvoices": { - "message": "Manage invoices" + "message": "Spravovať faktúry" }, "yourNextChargeIsFor": { - "message": "Your next charge is for" + "message": "Vaša najbližšia platba je" }, "dueOn": { - "message": "due on" + "message": "splatná" }, "yourSubscriptionWillBeSuspendedOn": { - "message": "Your subscription will be suspended on" + "message": "Vaše predplatné bude pozastavené dňa" }, "yourSubscriptionWasSuspendedOn": { - "message": "Your subscription was suspended on" + "message": "Vaše predplatné bolo pozastavené dňa" }, "yourSubscriptionWillBeCanceledOn": { - "message": "Your subscription will be canceled on" + "message": "Vaše predplatné bude zrušené dňa" }, "yourSubscriptionWasCanceledOn": { - "message": "Your subscription was canceled on" + "message": "Vaše predplatné bolo zrušené dňa" }, "storageFull": { - "message": "Storage full" + "message": "Úložisko je plné" }, "storageUsedDescription": { - "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "message": "Používate $USED$ z $AVAILABLE$ GB vášho šifrovaného úložiska.", "placeholders": { "used": { "content": "$1", @@ -12613,6 +12750,49 @@ } }, "storageFullDescription": { - "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + "message": "Použili ste všetkých $GB$ GB vášho šifrovaného úložiska. Ak chcete uložiť ďalšie súbory, pridajte viac úložiska." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "Ak odstránite úložisko, dostanete na váš účet proporcionálny kredit ktorý sa automaticky použije pri najbližšej faktúre." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "Máte Premium" + }, + "emailProtected": { + "message": "Email chránený" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/sl/messages.json b/apps/web/src/locales/sl/messages.json index 42b71f74c81..e6dc9d1b2ab 100644 --- a/apps/web/src/locales/sl/messages.json +++ b/apps/web/src/locales/sl/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Analiza dostopa" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Varnostno tveganje gesla" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -332,7 +359,7 @@ } }, "totalMembers": { - "message": "Total members" + "message": "Skupaj članov" }, "atRiskApplications": { "message": "At-risk applications" @@ -456,10 +483,10 @@ "message": "Zapisek" }, "privateNote": { - "message": "Private note" + "message": "Privaten zapisek" }, "note": { - "message": "Note" + "message": "Zapisek" }, "customFields": { "message": "Polja po meri" @@ -586,6 +613,9 @@ "email": { "message": "E-pošta" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -893,7 +923,7 @@ "message": "Zavarovan zapisek" }, "typeNote": { - "message": "Note" + "message": "Zapisek" }, "typeSshKey": { "message": "SSH key" @@ -986,7 +1016,7 @@ "description": "Header for new identity item type" }, "newItemHeaderNote": { - "message": "New Note", + "message": "Nov zapisek", "description": "Header for new note item type" }, "newItemHeaderSshKey": { @@ -1014,7 +1044,7 @@ "description": "Header for edit identity item type" }, "editItemHeaderNote": { - "message": "Edit Note", + "message": "Uredi zapisek", "description": "Header for edit note item type" }, "editItemHeaderSshKey": { @@ -1042,7 +1072,7 @@ "description": "Header for view identity item type" }, "viewItemHeaderNote": { - "message": "View Note", + "message": "Poglej zapisek", "description": "Header for view note item type" }, "viewItemHeaderSshKey": { @@ -1132,7 +1162,7 @@ "message": "Copy website" }, "copyNotes": { - "message": "Copy notes" + "message": "Kopiraj zapisek" }, "copyAddress": { "message": "Copy address" @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Izberi vse" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Odizberi vse" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2306,11 +2345,11 @@ "message": "Orodja" }, "importNoun": { - "message": "Import", + "message": "Uvozi", "description": "The noun form of the word Import" }, "importVerb": { - "message": "Import", + "message": "Uvozi", "description": "The verb form of the word Import" }, "importData": { @@ -2325,7 +2364,7 @@ "description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new item instead. (Optional second half: You may need to wait until your administrator confirms your organization membership.)" }, "onboardingImportDataDetailsLoginLink": { - "message": "new login", + "message": "nova prijava", "description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new login instead. (Optional second half: You may need to wait until your administrator confirms your organization membership.)" }, "onboardingImportDataDetailsPartTwoNoOrgs": { @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3444,7 +3486,7 @@ "message": "General information" }, "organizationName": { - "message": "Organization name" + "message": "Naziv organizacije" }, "accountOwnedBusiness": { "message": "This account is owned by a business." @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -3821,7 +3866,7 @@ "message": "Client owner email" }, "owner": { - "message": "Owner" + "message": "Lastnik" }, "ownerDesc": { "message": "Manage all aspects of your organization, including billing and subscriptions" @@ -4685,7 +4730,7 @@ "message": "My organization" }, "organizationInfo": { - "message": "Organization info" + "message": "Organizacija" }, "deleteOrganization": { "message": "Delete organization" @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Besedilo" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6231,7 +6329,7 @@ "message": "Send request" }, "addANote": { - "message": "Add a note" + "message": "Dodaj zapisek" }, "bitwardenSecretsManager": { "message": "Bitwarden Secrets Manager" @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Več o samodejnem izpolnjevanju" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -8082,7 +8180,7 @@ "message": "Members" }, "reporting": { - "message": "Reporting" + "message": "Poročanje" }, "numberOfUsers": { "message": "Number of users" @@ -9686,7 +9784,7 @@ "message": "Secrets Manager plan price" }, "passwordManager": { - "message": "Password Manager" + "message": "Upravitelj gesel" }, "freeOrganization": { "message": "Free Organization" @@ -9974,7 +10072,7 @@ "message": "Read release blog" }, "adminConsole": { - "message": "Admin Console" + "message": "Nadzorna plošča" }, "providerPortal": { "message": "Provider Portal" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11391,7 +11513,7 @@ "message": "Item removed from favorites" }, "copyNote": { - "message": "Copy note" + "message": "Kopiraj zapisek" }, "organizationNameMaxLength": { "message": "Organization name cannot exceed 50 characters." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -11723,7 +11851,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "generatorNudgeTitle": { - "message": "Quickly create passwords" + "message": "Hitro ustvarite gesla" }, "generatorNudgeBodyOne": { "message": "Easily create strong and unique passwords by clicking on", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/sr_CS/messages.json b/apps/web/src/locales/sr_CS/messages.json index 2eea7284653..341d0047e14 100644 --- a/apps/web/src/locales/sr_CS/messages.json +++ b/apps/web/src/locales/sr_CS/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Pristupi inteligenciji" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Rizik od lozinke" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "Imejl" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Odaberi sve" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Poništi Izbor" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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": "Napravi novo slanje", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/sr_CY/messages.json b/apps/web/src/locales/sr_CY/messages.json index f1c0a85c504..2300d3fe67f 100644 --- a/apps/web/src/locales/sr_CY/messages.json +++ b/apps/web/src/locales/sr_CY/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Нема критичних апликација у ризику" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Приступи интелигенцији" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Ризик од лозинке" }, @@ -250,6 +274,9 @@ "application": { "message": "Апликација" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Лозинке под ризиком" }, @@ -586,6 +613,9 @@ "email": { "message": "Е-пошта" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Телефон" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Изабери све" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Поништи избор" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Локација" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Следеће пуњење" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "План" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Уреди колекцију" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Инфо колекције" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Врати изабрано" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Ставка враћена" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Спровести власништво података о организацији" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Једна или више полиса ваше организације вас спречава да извезете ваш сеф." }, - "activateAutofill": { - "message": "Активирати ауто-пуњење" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Активирајте ауто-пуњење при учитавању странице на додатку прегледача за све постојеће и нове чланове." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Компромитоване или непоуздане веб локације могу да искористе ауто-пуњење при учитавању странице." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Сазнајте више о ауто-пуњење" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Одабрати тип SSO-а" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Није успело сачувавање интеграције. Покушајте поново касније." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Индекс" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Изаберите пакет" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Захтевано" }, - "domainStatusUnderVerification": { - "message": "Под провером" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Захтевајте домен на сопствени рачуни чланова. Страница SSO идентификатора биће прескочена током пријаве за чланове са захтевним доменима и администратори ће моћи да избрише захтевене рачуне." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Ова пријава је ризична и недостаје веб локација. Додајте веб страницу и промените лозинку за јачу сигурност." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Недостаје веб страница" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/sv/messages.json b/apps/web/src/locales/sv/messages.json index 57b58ecf7d7..bc10ec0ed99 100644 --- a/apps/web/src/locales/sv/messages.json +++ b/apps/web/src/locales/sv/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Inga kritiska applikationer i riskzonen" }, + "critical": { + "message": "Kritiska ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Inte kritiska ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Kritisk" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "Inga applikationer matchar dessa filter" + }, "passwordRisk": { "message": "Lösenordsrisk" }, @@ -250,6 +274,9 @@ "application": { "message": "Applikation" }, + "applications": { + "message": "Applikationer" + }, "atRiskPasswords": { "message": "Lösenord i riskzonen" }, @@ -586,6 +613,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "E-post" + }, "phone": { "message": "Telefon" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Markera alla" }, + "deselectAll": { + "message": "Avmarkera alla" + }, "unselectAll": { "message": "Avmarkera alla" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Nej" }, + "noAuth": { + "message": "Vem som helst med länken" + }, + "anyOneWithPassword": { + "message": "Alla som har ett lösenord inställt av dig" + }, "location": { "message": "Plats" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Nästa betalning" }, + "nextChargeDate": { + "message": "Nästa debiteringsdatum" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Redigera samling" }, + "viewCollection": { + "message": "Visa samling" + }, "collectionInfo": { "message": "Samlingsinfo" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Återställ markerade" }, + "archivedItemRestored": { + "message": "Arkiverat objekt återställt" + }, + "archivedItemsRestored": { + "message": "Arkiverade objekt återställda" + }, "restoredItem": { "message": "Återställde objekt" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send skapades!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Kopiera och dela denna Send-länk. Den kan visas av personer som du har angivet nästa $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Kopiera och dela denna Send-länk. Denna Send kommer att vara tillgänglig för alla med länken för nästa $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralisera organisationens ägarskap" + }, + "centralizeDataOwnershipDesc": { + "message": "Alla medlemsobjekt kommer att ägas och hanteras av organisationen. Administratörer och ägare är undantagna. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Läs mer om centraliserat ägarskap", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Fördelar" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Överför enkelt objekt under medlemmens offboarding och succession, vilket garanterar att det inte finns några åtkomstluckor." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Ge alla användare ett dedikerat \"Mina objekt\"-utrymme för att hantera sina egna inloggningar." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Fråga medlemmar att överföra sina objekt" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Läs mer om överföringen" + }, "organizationDataOwnership": { "message": "Genomför äganderätt till organisationsdata" }, @@ -6888,16 +6986,16 @@ "personalVaultExportPolicyInEffect": { "message": "En eller flera organisationspolicyer hindrar dig från att exportera ditt enskilda valv." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Aktivera autofyll" }, - "activateAutofillPolicyDesc": { - "message": "Aktivera inställningen för automatisk ifyllnad vid sidladdning i webbläsartillägget för alla befintliga och nya medlemmar." + "activateAutofillPolicyDescription": { + "message": "Aktivera autofyll på sidladdningsinställningar på webbläsartillägget för alla befintliga och nya medlemmar." }, - "experimentalFeature": { - "message": "Komprometterade eller opålitliga webbplatser kan utnyttja automatisk ifyllning vid sidladdning." + "autofillOnPageLoadExploitWarning": { + "message": "Komprometterade eller ej betrodda webbplatser kan utnyttja automatisk ifyllnad vid sidladdning." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Läs mer om autofyll" }, "selectType": { @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Skicka data om valvhändelser till din Datadog-instans" }, + "huntressEventIntegrationDesc": { + "message": "Skicka händelsedata till din Huntress SIEM-instans" + }, "failedToSaveIntegration": { "message": "Misslyckades med att spara integration. Försök igen senare." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "Du måste vara organisationens ägare för att utföra denna åtgärd." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Välj en plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden kommer att försöka göra anspråk på domänen 3 gånger under de första 72 timmarna. Om domänen inte kan göras anspråk på, kontrollera DNS-posten i din host och gör anspråk manuellt. Domänen kommer att tas bort från din organisation inom 7 dagar om den inte görs anspråk på." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden kommer att försöka göra anspråk på domänen inom 72 timmar. Om domänen inte kan hävdas, kontrollera din DNS-post och anspråk manuellt. Domäner som inte gjorts anspråk på tas bort efter 7 dagar." + }, + "automaticDomainClaimProcess2": { + "message": "När de har gjorts anspråk på kommer befintliga medlemmar med dessa domäner kommer att mailas om " + }, + "accountOwnershipChange": { + "message": "byte av kontoäganderätt" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ inte hävdad. Kontrollera dina DNS-poster.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Ägd" }, - "domainStatusUnderVerification": { - "message": "Under verifiering" + "domainStatusPending": { + "message": "Väntande" }, "claimedDomainsDescription": { "message": "Begär en domän för att äga medlemskonton. SSO-identifierarsidan kommer att hoppas över under inloggningen för medlemmar med namngivna domäner och administratörer kommer att kunna ta bort begärda konton." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Denna inloggning är i riskzonen och saknar en webbplats. Lägg till en webbplats och ändra lösenordet för starkare säkerhet." }, + "vulnerablePassword": { + "message": "Sårbart lösenord." + }, + "changeNow": { + "message": "Ändra nu" + }, "missingWebsite": { "message": "Saknar webbplats" }, @@ -11695,8 +11823,8 @@ "message": "Arkivera objekt", "description": "Verb" }, - "archiveItemConfirmDesc": { - "message": "Arkiverade objekt undantas från allmänna sökresultat och autofyllförslag. Är du säker på att du vill arkivera det här objektet?" + "archiveItemDialogContent": { + "message": "När du har arkiverat kommer detta objekt att uteslutas från sökresultat och förslag till autofyll." }, "archiveBulkItems": { "message": "Arkivera objekt", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verifiera nu." }, + "unlockWithPasskey": { + "message": "Lås upp med lösennyckel" + }, + "prfUnlockFailed": { + "message": "Det gick inte att låsa upp med lösennyckel. Försök igen eller använd en annan upplåsningsmetod." + }, + "noPrfCredentialsAvailable": { + "message": "Inga PRF-aktiverade lösennycklar finns tillgängliga för upplåsning." + }, "additionalStorageGB": { "message": "Ytterligare lagringsplats (GB)" }, @@ -12579,28 +12716,28 @@ "message": "Hantera fakturor" }, "yourNextChargeIsFor": { - "message": "Your next charge is for" + "message": "Din nästa betalning är för" }, "dueOn": { - "message": "due on" + "message": "förfaller den" }, "yourSubscriptionWillBeSuspendedOn": { - "message": "Your subscription will be suspended on" + "message": "Din prenumeration kommer att stängas av den" }, "yourSubscriptionWasSuspendedOn": { - "message": "Your subscription was suspended on" + "message": "Din prenumeration avbröts den" }, "yourSubscriptionWillBeCanceledOn": { - "message": "Your subscription will be canceled on" + "message": "Din prenumeration kommer att avslutas den" }, "yourSubscriptionWasCanceledOn": { - "message": "Your subscription was canceled on" + "message": "Din prenumeration avbröts den" }, "storageFull": { "message": "Lagringen är full" }, "storageUsedDescription": { - "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "message": "Du har använt $USED$ av $AVAILABLE$ GB av din krypterade fillagring.", "placeholders": { "used": { "content": "$1", @@ -12613,6 +12750,49 @@ } }, "storageFullDescription": { - "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + "message": "Du har använt alla $GB$ GB av din krypterade lagring. För att fortsätta lagra filer, lägg till mer lagringsutrymme." + }, + "whoCanView": { + "message": "Vem kan se" + }, + "specificPeople": { + "message": "Specifika personer" + }, + "emailVerificationDesc": { + "message": "Efter att ha delat denna Send-länk kommer individer att behöva verifiera sin e-post med en kod för att visa denna Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Ange flera e-postadresser genom att separera dem med kommatecken." + }, + "emailPlaceholder": { + "message": "användare@bitwarden.com , användare@acme.com" + }, + "whenYouRemoveStorage": { + "message": "När du tar bort lagring kommer du att få en proportionell kontokredit som automatiskt går mot din nästa faktura." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "Du har Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Ogiltigt Send-lösenord" + }, + "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." + }, + "perUser": { + "message": "per användare" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/ta/messages.json b/apps/web/src/locales/ta/messages.json index 9976a7b8287..3189d19e43b 100644 --- a/apps/web/src/locales/ta/messages.json +++ b/apps/web/src/locales/ta/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "முக்கியமான பயன்பாடுகளில் ஆபத்து ஏதும் இல்லை" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "அணுகல் நுண்ணறிவு" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "கடவுச்சொல் ஆபத்து" }, @@ -250,6 +274,9 @@ "application": { "message": "பயன்பாடு" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "ஆபத்தான கடவுச்சொற்கள்" }, @@ -586,6 +613,9 @@ "email": { "message": "மின்னஞ்சல்" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "தொலைபேசி" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "அனைத்தையும் தேர்ந்தெடு" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "தேர்ந்தெடுத்ததை நீக்கு" }, @@ -1365,6 +1398,12 @@ "no": { "message": "இல்லை" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "இடம்" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "சேகரிப்பைத் திருத்து" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "சேகரிப்பு தகவல்" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "தேர்ந்தெடுக்கப்பட்டதை மீட்டமை" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "உருப்படி மீட்டமைக்கப்பட்டது" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "நிறுவன தரவு உரிமையை அமல்படுத்து" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "ஒன்று அல்லது அதற்கு மேற்பட்ட நிறுவன கொள்கைகள் உங்கள் தனிப்பட்ட பெட்டகத்தை ஏற்றுமதி செய்வதைத் தடுக்கின்றன." }, - "activateAutofill": { - "message": "தானாக நிரப்புவதை செயல்படுத்து" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "ஏற்கனவே உள்ள மற்றும் புதிய அனைத்து உறுப்பினர்களுக்கும் உலாவி நீட்டிப்பில் பக்கம் ஏற்றப்படும்போது தானாக நிரப்பும் அமைப்பைச் செயல்படுத்து." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "சந்தேகத்திற்குரிய அல்லது நம்பத்தகாத வலைத்தளங்கள் பக்கம் ஏற்றப்படும்போது தானாக நிரப்புவதைப் பயன்படுத்திக் கொள்ளலாம்." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "தானாக நிரப்புதல் பற்றி மேலும் அறிக" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "SSO வகையைத் தேர்ந்தெடுக்கவும்" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "ஒருங்கிணைப்பைச் சேமிக்கத் தவறிவிட்டது. பின்னர் மீண்டும் முயற்சிக்கவும்." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "குறியீடு" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "ஒரு திட்டத்தைத் தேர்ந்தெடுக்கவும்" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "முதல் 72 மணிநேரத்திற்குள் 3 முறை டொமைனைக் கோர Bitwarden முயற்சிக்கும். டொமைனைக் கோர முடியவில்லையெனில், உங்கள் ஹோஸ்டில் உள்ள DNS பதிவைச் சரிபார்த்து, கைமுறையாகக் கோரவும். டொமைன் கோரப்படாவிட்டால் 7 நாட்களில் உங்கள் அமைப்பிலிருந்து அகற்றப்படும்." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ கோரப்படவில்லை. உங்கள் DNS பதிவுகளைச் சரிபார்க்கவும்.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "கோரப்பட்டது" }, - "domainStatusUnderVerification": { - "message": "சரிபார்ப்பில் உள்ளது" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "உறுப்பினர் கணக்குகளைச் சொந்தமாக்க ஒரு டொமைனைக் கோரவும். கோரப்பட்ட டொமைன்கள் கொண்ட உறுப்பினர்களுக்காக உள்நுழையும் போது SSO அடையாளங்காட்டி பக்கம் தவிர்க்கப்படும், மேலும் நிர்வாகிகள் கோரப்பட்ட கணக்குகளை நீக்க முடியும்." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/te/messages.json b/apps/web/src/locales/te/messages.json index 1ec92241671..2e96d9b844d 100644 --- a/apps/web/src/locales/te/messages.json +++ b/apps/web/src/locales/te/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Select all" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Unselect all" }, @@ -1365,6 +1398,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/th/messages.json b/apps/web/src/locales/th/messages.json index e558202c6ef..810be6dadcf 100644 --- a/apps/web/src/locales/th/messages.json +++ b/apps/web/src/locales/th/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Password Risk" }, @@ -250,6 +274,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +613,9 @@ "email": { "message": "อีเมล" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "โทรศัพท์" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "เลือกทั้งหมด" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "ยกเลิกการเลือกทั้งหมด" }, @@ -1365,6 +1398,12 @@ "no": { "message": "ไม่ใช่" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Restore selected" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Item restored" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Text" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Enforce organization data ownership" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11695,8 +11823,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/tr/messages.json b/apps/web/src/locales/tr/messages.json index 52e2c2ae034..93340b58c79 100644 --- a/apps/web/src/locales/tr/messages.json +++ b/apps/web/src/locales/tr/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Risk altında olan kritik uygulama yok" }, + "critical": { + "message": "Kritik ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Kritik değil ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Kritik" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Parola Riski" }, @@ -250,6 +274,9 @@ "application": { "message": "Uygulama" }, + "applications": { + "message": "Uygulamalar" + }, "atRiskPasswords": { "message": "Riskli parolalar" }, @@ -586,6 +613,9 @@ "email": { "message": "E-posta" }, + "emails": { + "message": "E-postalar" + }, "phone": { "message": "Telefon" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Tümünü seç" }, + "deselectAll": { + "message": "Tüm seçimi kaldır" + }, "unselectAll": { "message": "Seçimi iptal et" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Hayır" }, + "noAuth": { + "message": "Bağlantıya sahip olan herkes" + }, + "anyOneWithPassword": { + "message": "Belirlediğiniz parolaya sahip olan herkes" + }, "location": { "message": "Konum" }, @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Sonraki ödeme" }, + "nextChargeDate": { + "message": "Sonraki ödeme tarihi" + }, "plan": { "message": "Paket" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Koleksiyonu düzenle" }, + "viewCollection": { + "message": "Koleksiyonu görüntüle" + }, "collectionInfo": { "message": "Koleksiyon bilgileri" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Seçilenleri geri yükle" }, + "archivedItemRestored": { + "message": "Arşivlenmiş kayıt geri getirildi" + }, + "archivedItemsRestored": { + "message": "Arşivlenmiş kayıtlar geri getirildi" + }, "restoredItem": { "message": "Kayıt geri yüklendi" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Metin" }, - "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." @@ -5620,13 +5667,33 @@ "message": "Send başarıyla oluşturuldu.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Bu Send bağlantısını kopyalayıp paylaşın. Bu Send'e önümüzdeki $TIME$ boyunca bağlantıya sahip herkes ulaşabilecektir.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Bu Send bağlantısını kopyalayıp paylaşın. Bu Send'e önümüzdeki $TIME$ boyunca bağlantıya ve parolaya sahip herkes ulaşabilecektir.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Bu Send bağlantısını kopyalayıp paylaşın. Belirlediğiniz kişiler bağlantıyı önümüzdeki $TIME$ boyunca kullanabilir.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Kuruluş veri sahipliğini zorunlu kılın" }, @@ -6888,16 +6986,16 @@ "personalVaultExportPolicyInEffect": { "message": "Bir veya daha fazla kuruluş ilkesi, kişisel kasanızı dışa aktarmanızı engelliyor." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Otomatik doldurmayı etkinleştir" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Tüm mevcut ve yeni üyeler için tarayıcı uzantısındaki \"sayfa yüklendiğinde otomatik doldur\" ayarını etkinleştir." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "Ele geçirilmiş veya güvenilmeyen web siteleri sayfa yüklenirken otomatik doldurmayı suistimal edebilir." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Otomatik doldurma hakkında bilgi alın" }, "selectType": { @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Kasa olay verilerini Datadog örneğinize gönderin" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Entegrasyon kaydedilemedi. Lütfen daha sonra tekrar deneyin." }, + "mustBeOrganizationOwnerAdmin": { + "message": "Bu işlemi gerçekleştirmek için kuruluş sahibi veya yönetici olmalısınız." + }, "mustBeOrgOwnerToPerformAction": { "message": "Bu işlemi gerçekleştirmek için kuruluş sahibi olmalısınız." }, @@ -10506,6 +10610,12 @@ "index": { "message": "İndeks" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Bir plan seçin" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden, ilk 72 saat içinde etki alanını 3 kez talep etmeye çalışacaktır. Etki alanı talep edilemezse, barındırıcınızdaki DNS kaydını kontrol edin ve manuel olarak talep edin. Etki alanı talep edilmezse, 7 gün içinde kuruluşunuzdan kaldırılacaktır." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ alınmadı. DNS kayıtlarınızı kontrol edin.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Alındı" }, - "domainStatusUnderVerification": { - "message": "Doğrulama altında" + "domainStatusPending": { + "message": "Beklemede" }, "claimedDomainsDescription": { "message": "Üye hesaplarını sahip olmak için bir alan adı talep edin. Alan adı talep eden üyelerin oturum açma sırasında SSO tanımlayıcı sayfası atlanacak ve yöneticiler talep edilen hesapları silebilecek." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Bu hesap risk altında ve web sitesi eksik. Bir web sitesi ekleyin ve güvenliğinizi artırmak için parolayı değiştirin." }, + "vulnerablePassword": { + "message": "Güvensiz parola." + }, + "changeNow": { + "message": "Şimdi değiştir" + }, "missingWebsite": { "message": "Web sitesi eksik" }, @@ -11695,8 +11823,8 @@ "message": "Kaydı arşivle", "description": "Verb" }, - "archiveItemConfirmDesc": { - "message": "Arşivlenmiş kayıtlar genel arama sonuçları ve otomatik doldurma önerilerinden hariç tutulur. Bu kaydı arşivlemek istediğinizden emin misiniz?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archiveBulkItems": { "message": "Kayıtları arşivle", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Şimdi doğrulayın." }, + "unlockWithPasskey": { + "message": "Kilidi geçiş anahtarıyla aç" + }, + "prfUnlockFailed": { + "message": "Kilit geçiş anahtarıyla açılamadı. Lütfen yeniden deneyin veya başka bir kilit açma yöntemi kullanın." + }, + "noPrfCredentialsAvailable": { + "message": "Kilit açma için PRF uyumlu geçiş anahtarı bulunamadı." + }, "additionalStorageGB": { "message": "Ek depolama alanı GB" }, @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Kim görebilir" + }, + "specificPeople": { + "message": "Belirli kişiler" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "E-posta adreslerini virgülle ayırarak yazın." + }, + "emailPlaceholder": { + "message": "kullanici@bitwarden.com , kullanici@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Sahip, $OWNER$, sahibi $OWNER$ olan tüm kayıtları göster", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "Premium abonesiniz" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Geçersiz Send parolası" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/uk/messages.json b/apps/web/src/locales/uk/messages.json index 39e8bf35444..edcc4d39cb1 100644 --- a/apps/web/src/locales/uk/messages.json +++ b/apps/web/src/locales/uk/messages.json @@ -3,7 +3,7 @@ "message": "Всі програми" }, "activity": { - "message": "Activity" + "message": "Активність" }, "appLogoLabel": { "message": "Логотип Bitwarden" @@ -14,20 +14,44 @@ "noCriticalAppsAtRisk": { "message": "Немає критичних програм із ризиком" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Управління доступом" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Ризиковані паролі" }, "noEditPermissions": { - "message": "You don't have permission to edit this item" + "message": "Вам не дозволено редагувати цей запис" }, "reviewAtRiskPasswords": { "message": "Переглядайте ризиковані паролі в різних програмах (слабкі, викриті, або повторно використані). Виберіть найбільш критичні програми, щоб визначити пріоритети дій щодо безпеки для користувачів, які використовують ризиковані паролі." }, "reviewAtRiskLoginsPrompt": { - "message": "Review at-risk logins" + "message": "Переглянути записи з ризиком" }, "dataLastUpdated": { "message": "Дані востаннє оновлено: $DATE$", @@ -39,7 +63,7 @@ } }, "noReportRan": { - "message": "You have not created a report yet" + "message": "Ви ще не створили звіт" }, "notifiedMembers": { "message": "Сповіщення учасників" @@ -134,7 +158,7 @@ "message": "critical applications marked" }, "countOfCriticalApplications": { - "message": "$COUNT$ critical applications", + "message": "$COUNT$ критичних програм", "placeholders": { "count": { "content": "$1", @@ -143,7 +167,7 @@ } }, "countOfApplicationsAtRisk": { - "message": "$COUNT$ applications at-risk", + "message": "$COUNT$ програм під загрозою", "placeholders": { "count": { "content": "$1", @@ -152,7 +176,7 @@ } }, "countOfAtRiskPasswords": { - "message": "$COUNT$ passwords at-risk", + "message": "$COUNT$ паролів під загрозою", "placeholders": { "count": { "content": "$1", @@ -161,7 +185,7 @@ } }, "newPasswordsAtRisk": { - "message": "$COUNT$ new passwords at-risk", + "message": "$COUNT$ нових паролів під загрозою", "placeholders": { "count": { "content": "$1", @@ -179,13 +203,13 @@ } }, "noDataInOrgTitle": { - "message": "No data found" + "message": "Дані не знайдено" }, "noDataInOrgDescription": { "message": "Import your organization's login data to get started with Access Intelligence. Once you do that, you'll be able to:" }, "feature1Title": { - "message": "Mark applications as critical" + "message": "Позначити програми як критичні" }, "feature1Description": { "message": "This will help you remove risks to your most important applications first." @@ -209,28 +233,28 @@ "message": "You’re ready to start generating reports. Once you generate, you’ll be able to:" }, "noCriticalApplicationsTitle": { - "message": "Ви не відмітили жодного додатку в якості критичного" + "message": "Ви не позначили жодної програми в якості критичної" }, "noCriticalApplicationsDescription": { "message": "Select your most critical applications to prioritize security actions for your users to address at-risk passwords." }, "markCriticalApplications": { - "message": "Вибрати критичні додатки" + "message": "Вибрати критичні програми" }, "markAppAsCritical": { "message": "Позначити програму критичною" }, "markAsCritical": { - "message": "Mark as critical" + "message": "Позначити як критичне" }, "applicationsSelected": { - "message": "applications selected" + "message": "програм обрано" }, "selectApplication": { - "message": "Select application" + "message": "Обрати програму" }, "unselectApplication": { - "message": "Unselect application" + "message": "Скасувати вибір програми" }, "applicationsMarkedAsCriticalSuccess": { "message": "Позначені критичні програми" @@ -250,6 +274,9 @@ "application": { "message": "Програма" }, + "applications": { + "message": "Програми" + }, "atRiskPasswords": { "message": "Ризиковані паролі" }, @@ -275,7 +302,7 @@ "message": "Members of your organization will be assigned a task to change vulnerable passwords. They’ll receive a notification within their Bitwarden browser extension." }, "membersAtRiskCount": { - "message": "$COUNT$ members at-risk", + "message": "$COUNT$ учасників під загрозою", "placeholders": { "count": { "content": "$1", @@ -344,10 +371,10 @@ "message": "Applications needing review" }, "newApplicationsCardTitle": { - "message": "Review new applications" + "message": "Перегляд нових програм" }, "newApplicationsWithCount": { - "message": "$COUNT$ new applications", + "message": "$COUNT$ нових програм", "placeholders": { "count": { "content": "$1", @@ -380,7 +407,7 @@ "message": "Review applications to secure the items most critical to your organization's security" }, "reviewApplications": { - "message": "Review applications" + "message": "Перегляд програм" }, "prioritizeCriticalApplications": { "message": "Prioritize critical applications" @@ -389,7 +416,7 @@ "message": "Select which applications are most critical to your organization. Then, you’ll be able to assign security tasks to members to remove risks." }, "reviewNewApplications": { - "message": "Review new applications" + "message": "Перегляд нових програм" }, "reviewNewAppsDescription": { "message": "Review new applications with vulnerable items and mark those you’d like to monitor closely as critical. Then, you’ll be able to assign security tasks to members to remove risks." @@ -404,7 +431,7 @@ "message": "Application review saved" }, "newApplicationsReviewed": { - "message": "New applications reviewed" + "message": "Переглянуто нові програми" }, "errorSavingReviewStatus": { "message": "Error saving review status" @@ -586,6 +613,9 @@ "email": { "message": "Е-пошта" }, + "emails": { + "message": "Е-пошти" + }, "phone": { "message": "Телефон" }, @@ -974,79 +1004,79 @@ "message": "Переглянути запис" }, "newItemHeaderLogin": { - "message": "New Login", + "message": "Новий запис", "description": "Header for new login item type" }, "newItemHeaderCard": { - "message": "New Card", + "message": "Нова картка", "description": "Header for new card item type" }, "newItemHeaderIdentity": { - "message": "New Identity", + "message": "Нове посвідчення", "description": "Header for new identity item type" }, "newItemHeaderNote": { - "message": "New Note", + "message": "Нова нотатка", "description": "Header for new note item type" }, "newItemHeaderSshKey": { - "message": "New SSH key", + "message": "Новий ключ SSH", "description": "Header for new SSH key item type" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Нове текстове відправлення", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Нове файлове відправлення", "description": "Header for new file send" }, "editItemHeaderLogin": { - "message": "Edit Login", + "message": "Редагувати запис", "description": "Header for edit login item type" }, "editItemHeaderCard": { - "message": "Edit Card", + "message": "Редагувати картку", "description": "Header for edit card item type" }, "editItemHeaderIdentity": { - "message": "Edit Identity", + "message": "Редагувати посвідчення", "description": "Header for edit identity item type" }, "editItemHeaderNote": { - "message": "Edit Note", + "message": "Редагувати нотатку", "description": "Header for edit note item type" }, "editItemHeaderSshKey": { - "message": "Edit SSH key", + "message": "Редагувати ключ SSH", "description": "Header for edit SSH key item type" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Редагувати текстове відправлення", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Редагувати файлове відправлення", "description": "Header for edit file send" }, "viewItemHeaderLogin": { - "message": "View Login", + "message": "Перегляд запису", "description": "Header for view login item type" }, "viewItemHeaderCard": { - "message": "View Card", + "message": "Перегляд картки", "description": "Header for view card item type" }, "viewItemHeaderIdentity": { - "message": "View Identity", + "message": "Перегляд посвідчення", "description": "Header for view identity item type" }, "viewItemHeaderNote": { - "message": "View Note", + "message": "Перегляд нотатки", "description": "Header for view note item type" }, "viewItemHeaderSshKey": { - "message": "View SSH key", + "message": "Перегляд ключа SSH", "description": "Header for view SSH key item type" }, "new": { @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Вибрати все" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Скасувати вибір" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Ні" }, + "noAuth": { + "message": "Будь-хто з посиланням" + }, + "anyOneWithPassword": { + "message": "Будь-хто зі встановленим вами паролем" + }, "location": { "message": "Розташування" }, @@ -1402,7 +1441,7 @@ "message": "Використати єдиний вхід" }, "yourOrganizationRequiresSingleSignOn": { - "message": "Your organization requires single sign-on." + "message": "Ваша організація вимагає єдиний вхід (Sso)." }, "welcomeBack": { "message": "З поверненням" @@ -1690,7 +1729,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", @@ -1708,16 +1747,16 @@ "message": "Немає записів." }, "noItemsInTrash": { - "message": "No items in trash" + "message": "Немає записів у смітнику" }, "noItemsInTrashDesc": { - "message": "Items you delete will appear here and be permanently deleted after 30 days" + "message": "Видалені записи з'являтимуться тут і будуть остаточно видалені через 30 днів" }, "noItemsInVault": { "message": "No items in the vault" }, "emptyVaultDescription": { - "message": "The vault protects more than just your passwords. Store secure logins, IDs, cards and notes securely here." + "message": "Сховище захищає не лише ваші паролі. Безпечно зберігайте дані для входу, посвідчення, картки й нотатки." }, "emptyFavorites": { "message": "You haven't favorited any items" @@ -1844,7 +1883,7 @@ "message": "Код відновлення" }, "invalidRecoveryCode": { - "message": "Invalid recovery code" + "message": "Недійсний код відновлення" }, "authenticatorAppTitle": { "message": "Програма автентифікації" @@ -1973,11 +2012,11 @@ "message": "Ключі шифрування унікальні для кожного облікового запису користувача Bitwarden, тому ви не можете імпортувати зашифрований експорт до іншого облікового запису." }, "exportNoun": { - "message": "Export", + "message": "Експорт", "description": "The noun form of the word Export" }, "exportVerb": { - "message": "Export", + "message": "Експортувати", "description": "The verb form of the word Export" }, "exportFrom": { @@ -2306,11 +2345,11 @@ "message": "Інструменти" }, "importNoun": { - "message": "Import", + "message": "Імпорт", "description": "The noun form of the word Import" }, "importVerb": { - "message": "Import", + "message": "Імпортувати", "description": "The verb form of the word Import" }, "importData": { @@ -2440,7 +2479,7 @@ "message": "Змінити мову інтерфейсу вебсховища." }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Показувати піктограми вебсайтів та отримувати адреси для зміни паролів" }, "default": { "message": "Типово" @@ -2541,7 +2580,7 @@ "message": "Увімкнено" }, "optionEnabled": { - "message": "Enabled" + "message": "Увімкнено" }, "restoreAccess": { "message": "Відновити доступ" @@ -2641,7 +2680,7 @@ "message": "Ключ" }, "unnamedKey": { - "message": "Unnamed key" + "message": "Неназваний ключ" }, "twoStepAuthenticatorEnterCodeV2": { "message": "Код підтвердження" @@ -3076,7 +3115,7 @@ "message": "1 ГБ зашифрованого сховища для файлів." }, "premiumSignUpStorageV2": { - "message": "$SIZE$ encrypted storage for file attachments.", + "message": "$SIZE$ зашифрованого сховища для вкладених файлів.", "placeholders": { "size": { "content": "$1", @@ -3147,16 +3186,16 @@ } }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "Ваша передплата Premium завершилася" }, "premiumSubscriptionEndedDesc": { - "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + "message": "Щоб відновити доступ до архіву, поновіть передплату Premium. Якщо ви редагуєте архівований запис перед поновленням, його буде повернуто назад у ваше сховище." }, "itemRestored": { - "message": "Item has been restored" + "message": "Запис відновлено" }, "restartPremium": { - "message": "Restart Premium" + "message": "Поновити Premium" }, "additionalStorageGb": { "message": "Додаткове сховище (ГБ)" @@ -3279,7 +3318,10 @@ "message": "Наступна оплата" }, "nextChargeHeader": { - "message": "Next Charge" + "message": "Наступна оплата" + }, + "nextChargeDate": { + "message": "Дата наступної оплати" }, "plan": { "message": "Plan" @@ -3288,7 +3330,7 @@ "message": "Подробиці" }, "discount": { - "message": "discount" + "message": "знижка" }, "downloadLicense": { "message": "Завантажити ліцензію" @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Редагувати збірку" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Інформація про збірку" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Відновити вибрані" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, + "archivedItemsRestored": { + "message": "Archived items restored" + }, "restoredItem": { "message": "Запис відновлено" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "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." @@ -5620,18 +5667,38 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, "durationTimeHours": { - "message": "$HOURS$ hours", + "message": "$HOURS$ годин", "placeholders": { "hours": { "content": "$1", @@ -5640,11 +5707,11 @@ } }, "newTextSend": { - "message": "New Text Send", + "message": "Нове текстове відправлення", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "newFileSend": { - "message": "New File Send", + "message": "Нове файлове відправлення", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editedSend": { @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Benefits" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Примусове володіння даними організації" }, @@ -5957,14 +6055,14 @@ "message": "How to turn on automatic user confirmation" }, "autoConfirmExtension1": { - "message": "Open your Bitwarden extension" + "message": "Відкрийте своє розширення Bitwarden" }, "autoConfirmExtension2": { - "message": "Select", + "message": "Обрати", "description": "This is a fragment of a larger sencence. The whole sentence will read: 'Select Turn on'" }, "autoConfirmExtension3": { - "message": " Turn on", + "message": " Увімкнути", "description": "This is a fragment of a larger sencence. The whole sentence will read: 'Select Turn on'" }, "autoConfirmExtensionOpened": { @@ -6768,16 +6866,16 @@ "message": "Ваша організація оновила параметри дешифрування. Встановіть головний пароль для доступу до сховища." }, "sessionTimeoutPolicyTitle": { - "message": "Session timeout" + "message": "Час очікування сеансу" }, "sessionTimeoutPolicyDescription": { "message": "Set a maximum session timeout for all members except owners." }, "maximumAllowedTimeout": { - "message": "Maximum allowed timeout" + "message": "Максимальний час очікування" }, "maximumAllowedTimeoutRequired": { - "message": "Maximum allowed timeout is required." + "message": "Необхідний максимальний час очікування." }, "sessionTimeoutPolicyInvalidTime": { "message": "Time is invalid. Change at least one value." @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Одна чи декілька політик організації не дозволяють вам експортувати особисте сховище." }, - "activateAutofill": { - "message": "Активувати автозаповнення" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Активувати автозаповнення під час завантаження сторінки в налаштуваннях розширення браузера для всіх наявних і нових учасників." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Скомпрометовані або ненадійні вебсайти можуть використати функцію автозаповнення під час завантаження сторінки для завдання шкоди." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Дізнатися більше про автозаповнення" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Оберіть тип SSO" @@ -7255,7 +7353,7 @@ "message": "SSO увімкнено" }, "ssoTurnedOff": { - "message": "SSO turned off" + "message": "SSO вимкнено" }, "emailMustLoginWithSso": { "message": "$EMAIL$ must login with Single Sign-on", @@ -7354,7 +7452,7 @@ "message": "Необхідно додати URL-адресу основного сервера, або принаймні одне користувацьке середовище." }, "selfHostedEnvMustUseHttps": { - "message": "URLs must use HTTPS." + "message": "URL-адреси повинні використовувати HTTPS." }, "apiUrl": { "message": "URL-адреса сервера API" @@ -9546,7 +9644,7 @@ "message": "Потрібно увійти через SSO" }, "emailRequiredForSsoLogin": { - "message": "Email is required for SSO" + "message": "Е-пошта необхідна для SSO" }, "selectedRegionFlag": { "message": "Прапор вибраного регіону" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "You must be the organization owner to perform this action." }, @@ -10504,7 +10608,13 @@ "message": "Name of the repository to ingest into" }, "index": { - "message": "Index" + "message": "Індекс" + }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" }, "selectAPlan": { "message": "Оберіть тарифний план" @@ -11225,7 +11335,7 @@ "message": "Користувача вилучено з організації. Всі пов'язані дані користувача видалено." }, "deletedUserIdEventMessage": { - "message": "Deleted user $ID$", + "message": "Видалений користувач $ID$", "placeholders": { "id": { "content": "$1", @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden намагатиметься заявити домен 3 рази впродовж 72 годин. Якщо не вдасться заявити домен, перевірте DNS-запис у вашого провайдера й заявіть його вручну. Якщо домен не буде заявлено протягом 7 днів, його буде вилучено з вашої організації." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ не заявлено. Перевірте свої DNS-записи.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Заявлено" }, - "domainStatusUnderVerification": { - "message": "Проходить перевірку" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11391,7 +11513,7 @@ "message": "Item removed from favorites" }, "copyNote": { - "message": "Copy note" + "message": "Копіювати нотатку" }, "organizationNameMaxLength": { "message": "Назва організації не може перевищувати 50 символів." @@ -11618,10 +11740,16 @@ "message": "Змінити ризикований пароль" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "message": "Цей запис ризикований і не має адреси вебсайту. Додайте адресу вебсайту та змініть пароль для вдосконалення безпеки." + }, + "vulnerablePassword": { + "message": "Вразливий пароль." + }, + "changeNow": { + "message": "Змінити зараз" }, "missingWebsite": { - "message": "Missing website" + "message": "Немає вебсайту" }, "removeUnlockWithPinPolicyTitle": { "message": "Вилучити розблокування з PIN-кодом" @@ -11648,30 +11776,30 @@ "message": "Search archive" }, "archiveNoun": { - "message": "Archive", + "message": "Архів", "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "Архівувати", "description": "Verb" }, "unArchive": { - "message": "Unarchive" + "message": "Розархівувати" }, "archived": { - "message": "Archived" + "message": "Архівовано" }, "unArchiveAndSave": { - "message": "Unarchive and save" + "message": "Розархівувати та зберегти" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Записи в архіві" }, "noItemsInArchive": { - "message": "No items in archive" + "message": "Немає записів у архіві" }, "noItemsInArchiveDesc": { - "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + "message": "Архівовані записи з'являтимуться тут і будуть виключені з результатів звичайного пошуку та пропозицій автозаповнення." }, "itemWasSentToArchive": { "message": "Item was sent to archive" @@ -11692,14 +11820,14 @@ "message": "Items unarchived" }, "archiveItem": { - "message": "Archive item", + "message": "Архівувати запис", "description": "Verb" }, - "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." }, "archiveBulkItems": { - "message": "Archive items", + "message": "Архівувати записи", "description": "Verb" }, "archiveBulkItemsConfirmDesc": { @@ -11834,7 +11962,7 @@ "description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'If the extension didn't open, you may need to open Bitwarden from the icon [Bitwarden Icon] on the toolbar.'" }, "openExtensionFromToolbarPart2": { - "message": " on the toolbar.", + "message": " з панелі інструментів.", "description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'If the extension didn't open, you may need to open Bitwarden from the icon [Bitwarden Icon] on the toolbar.'" }, "gettingStartedWithBitwardenPart3": { @@ -11871,7 +11999,7 @@ "description": "Error message shown when trying to add credit to a trialing organization without a billing address." }, "aboutThisSetting": { - "message": "About this setting" + "message": "Про ці налаштування" }, "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." @@ -12025,7 +12153,7 @@ "message": "Check input format for typos." }, "exampleTaxIdFormat": { - "message": "Example $CODE$ format: $EXAMPLE$", + "message": "Приклад формату $CODE$: $EXAMPLE$", "placeholders": { "code": { "content": "$1", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "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." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12098,13 +12235,13 @@ "message": "Built-in authenticator" }, "breachMonitoring": { - "message": "Breach monitoring" + "message": "Моніторинг витоків даних" }, "andMoreFeatures": { - "message": "And more!" + "message": "І багато іншого!" }, "secureFileStorage": { - "message": "Secure file storage" + "message": "Захищене сховище файлів" }, "familiesUnlimitedSharing": { "message": "Unlimited sharing - choose who sees what" @@ -12137,7 +12274,7 @@ "message": "Always free" }, "twoSecretsIncluded": { - "message": "2 secrets" + "message": "2 секрети" }, "projectsIncludedV2": { "message": "$COUNT$ project(s)", @@ -12182,7 +12319,7 @@ "message": "Seamless integration" }, "families": { - "message": "Families" + "message": "Родини" }, "upgradeToFamilies": { "message": "Upgrade to Families" @@ -12212,7 +12349,7 @@ "message": "Continue without upgrading" }, "upgradeYourPlan": { - "message": "Upgrade your plan" + "message": "Оновити тарифний план" }, "upgradeNow": { "message": "Upgrade now" @@ -12239,7 +12376,7 @@ "message": "Update your encryption settings" }, "algorithm": { - "message": "Algorithm" + "message": "Алгоритм" }, "encryptionKeySettingsHowShouldWeEncryptYourData": { "message": "Choose how Bitwarden should encrypt your vault data. All options are secure, but stronger methods offer better protection - especially against brute-force attacks. Bitwarden recommends the default setting for most users." @@ -12257,10 +12394,10 @@ "message": "Argon2id offers stronger protection against modern attacks. Best for advanced users with powerful devices." }, "zipPostalCodeLabel": { - "message": "ZIP / Postal code" + "message": "Поштовий індекс" }, "cardNumberLabel": { - "message": "Card number" + "message": "Номер картки" }, "startFreeFamiliesTrial": { "message": "Start free Families trial" @@ -12312,13 +12449,13 @@ "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": "Перейти до входу" }, "doNotContinue": { - "message": "Do not continue" + "message": "Не продовжувати" }, "domain": { - "message": "Domain" + "message": "Домен" }, "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." @@ -12327,16 +12464,16 @@ "message": "Verify your organization to log in" }, "organizationVerified": { - "message": "Organization verified" + "message": "Організацію підтверджено" }, "domainVerified": { - "message": "Domain verified" + "message": "Домен підтверджено" }, "leaveOrganizationContent": { "message": "If you don't verify your organization, your access to the organization will be revoked." }, "leaveNow": { - "message": "Leave now" + "message": "Покинути зараз" }, "verifyYourDomainToLogin": { "message": "Verify your domain to log in" @@ -12446,16 +12583,16 @@ "message": "Set an unlock method to change your timeout action" }, "leaveConfirmationDialogTitle": { - "message": "Are you sure you want to leave?" + "message": "Ви дійсно хочете покинути?" }, "leaveConfirmationDialogContentOne": { - "message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features." + "message": "Відхиливши, ваші особисті записи залишаться у вашому обліковому записі, але ви втратите доступ до спільних записів та функцій організації." }, "leaveConfirmationDialogContentTwo": { - "message": "Contact your admin to regain access." + "message": "Зверніться до свого адміністратора, щоб відновити доступ." }, "leaveConfirmationDialogConfirmButton": { - "message": "Leave $ORGANIZATION$", + "message": "Покинути $ORGANIZATION$", "placeholders": { "organization": { "content": "$1", @@ -12464,10 +12601,10 @@ } }, "howToManageMyVault": { - "message": "How do I manage my vault?" + "message": "Як керувати своїм сховищем?" }, "transferItemsToOrganizationTitle": { - "message": "Transfer items to $ORGANIZATION$", + "message": "Перемістити записи до $ORGANIZATION$", "placeholders": { "organization": { "content": "$1", @@ -12485,13 +12622,13 @@ } }, "acceptTransfer": { - "message": "Accept transfer" + "message": "Схвалити переміщення" }, "declineAndLeave": { - "message": "Decline and leave" + "message": "Відхилити та покинути" }, "whyAmISeeingThis": { - "message": "Why am I seeing this?" + "message": "Чому я це бачу?" }, "youHaveBitwardenPremium": { "message": "You have Bitwarden Premium" @@ -12536,10 +12673,10 @@ "message": "Open the subscription page on your Bitwarden cloud account and download your license file. Then return to this screen and upload it below." }, "viewAllPlans": { - "message": "View all plans" + "message": "Переглянути всі тарифні плани" }, "planDescPremium": { - "message": "Complete online security" + "message": "Повна онлайн-безпека" }, "updatePayment": { "message": "Update payment" @@ -12597,7 +12734,7 @@ "message": "Your subscription was canceled on" }, "storageFull": { - "message": "Storage full" + "message": "Сховище заповнено" }, "storageUsedDescription": { "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Хто може переглядати" + }, + "specificPeople": { + "message": "Певні люди" + }, + "emailVerificationDesc": { + "message": "Після того, як ви поділитеся цим посиланням на відправлення, особам необхідно буде підтвердити свої е-пошти за допомогою коду, щоб переглянути це відправлення." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Введіть декілька адрес е-пошти, розділяючи їх комою." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Е-пошту захищено" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/vi/messages.json b/apps/web/src/locales/vi/messages.json index 346f97f0216..5e468b4ed2a 100644 --- a/apps/web/src/locales/vi/messages.json +++ b/apps/web/src/locales/vi/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "Không có ứng dụng quan trọng nào bị đe dọa" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "Critical" + }, "accessIntelligence": { "message": "Trí tuệ truy cập" }, + "noApplicationsMatchTheseFilters": { + "message": "No applications match these filters" + }, "passwordRisk": { "message": "Mật khẩu rủi ro" }, @@ -250,6 +274,9 @@ "application": { "message": "Ứng dụng" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Mật khẩu có rủi ro cao" }, @@ -586,6 +613,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Điện thoại" }, @@ -1217,6 +1247,9 @@ "selectAll": { "message": "Chọn tất cả" }, + "deselectAll": { + "message": "Deselect all" + }, "unselectAll": { "message": "Bỏ chọn tất cả" }, @@ -1365,6 +1398,12 @@ "no": { "message": "Không" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Vị trí" }, @@ -1973,11 +2012,11 @@ "message": "Khóa mã hóa tài khoản là duy nhất cho mỗi tài khoản Bitwarden, vì vậy bạn không thể nhập tệp xuất được mã hóa vào một tài khoản khác." }, "exportNoun": { - "message": "Export", + "message": "Xuất", "description": "The noun form of the word Export" }, "exportVerb": { - "message": "Export", + "message": "Xuất", "description": "The verb form of the word Export" }, "exportFrom": { @@ -2306,11 +2345,11 @@ "message": "Công cụ" }, "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" }, "importData": { @@ -2541,7 +2580,7 @@ "message": "Kích hoạt" }, "optionEnabled": { - "message": "Enabled" + "message": "Kích hoạt" }, "restoreAccess": { "message": "Khôi phục quyền truy cập" @@ -3153,7 +3192,7 @@ "message": "Để lấy lại quyền truy cập vào lưu trữ của bạn, hãy khởi động lại gói đăng ký Cao cấp. Nếu bạn chỉnh sửa chi tiết cho một mục đã lưu trữ trước khi khởi động lại, mục đó sẽ được chuyển trở lại kho của bạn." }, "itemRestored": { - "message": "Item has been restored" + "message": "Mục đã được khôi phục" }, "restartPremium": { "message": "Khởi động lại gói Cao cấp" @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "Lần thanh toán tiếp theo" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Gói" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "Chỉnh sửa bộ sư tập" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Thông tin bộ sưu tập" }, @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "Khôi phục những mục đã chọn" }, + "archivedItemRestored": { + "message": "Mục lưu trữ đã được khôi phục" + }, + "archivedItemsRestored": { + "message": "Mục lưu trữ đã được khôi phục" + }, "restoredItem": { "message": "Mục đã được khôi phục" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "message": "Văn bản" }, - "sendPasswordDescV3": { - "message": "Thêm mật khẩu tùy chọn để người nhận truy cập 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." @@ -5617,16 +5664,36 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendCreatedSuccessfully": { - "message": "Send created successfully!", + "message": "Đã tạo Send thành công!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5640,11 +5707,11 @@ } }, "newTextSend": { - "message": "New Text Send", + "message": "Send văn bản mới", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "newFileSend": { - "message": "New File Send", + "message": "Send tập tin mới", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editedSend": { @@ -5687,7 +5754,7 @@ "message": "Đã thu hồi" }, "accepted": { - "message": "Accepted" + "message": "Đã chấp nhận" }, "sendLink": { "message": "Liên kết Send", @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "Centralize organization ownership" + }, + "centralizeDataOwnershipDesc": { + "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + }, + "centralizeDataOwnershipContentAnchor": { + "message": "Learn more about centralized ownership", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "Lợi ích" + }, + "centralizeDataOwnershipBenefit1": { + "message": "Gain full visibility into credential health, including shared and unshared items." + }, + "centralizeDataOwnershipBenefit2": { + "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + }, + "centralizeDataOwnershipBenefit3": { + "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + }, + "centralizeDataOwnershipWarningTitle": { + "message": "Prompt members to transfer their items" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + }, + "centralizeDataOwnershipWarningLink": { + "message": "Learn more about the transfer" + }, "organizationDataOwnership": { "message": "Thực thi quyền sở hữu dữ liệu của tổ chức" }, @@ -6888,17 +6986,17 @@ "personalVaultExportPolicyInEffect": { "message": "Các chính sách của tổ chức ngăn cản bạn xuất kho lưu trữ cá nhân của mình." }, - "activateAutofill": { - "message": "Bật tính năng tự động điền" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Bật tính năng tự động điền khi tải trang trên tiện ích mở rộng trình duyệt cho tất cả thành viên hiện tại và mới." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Các trang web bị xâm phạm hoặc không đáng tin cậy có thể khai thác tính năng tự động điền khi tải trang." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Tìm hiểu thêm về tự động điền" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Chọn loại SSO" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "Gửi dữ liệu sự kiện kho bảo mật đến phiên bản Datadog của bạn" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Không thể lưu tích hợp. Vui lòng thử lại sau." }, + "mustBeOrganizationOwnerAdmin": { + "message": "You must be an Organization Owner or Admin to perform this action." + }, "mustBeOrgOwnerToPerformAction": { "message": "Bạn phải là chủ sở hữu tổ chức để thực hiện hành động này." }, @@ -10506,6 +10610,12 @@ "index": { "message": "Mục lục" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Chọn một gói" }, @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden sẽ thử xác nhận tên miền 3 lần trong 72 giờ đầu tiên. Nếu tên miền không thể được xác nhận, hãy kiểm tra bản ghi DNS trong máy chủ của bạn và tự xác nhận. Tên miền sẽ bị xóa khỏi tổ chức của bạn trong 7 ngày nếu không được xác nhận." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ chưa được xác nhận. Vui lòng kiểm tra bản ghi DNS của bạn.", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "Đã xác nhận" }, - "domainStatusUnderVerification": { - "message": "Đang trong quá trình xác minh" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Xác nhận một tên miền để sở hữu tài khoản của thành viên. Trang định danh SSO sẽ bị bỏ qua trong quá trình đăng nhập cho các thành viên có tên miền đã xác nhận và quản trị viên sẽ có thể xóa các tài khoản đã xác nhận." @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Mục đăng nhập này có nguy cơ bị lộ và thiếu trang web. Thêm trang web và thay đổi mật khẩu để tăng cường bảo mật." }, + "vulnerablePassword": { + "message": "Mật khẩu dễ bị tấn công." + }, + "changeNow": { + "message": "Thay đổi ngay" + }, "missingWebsite": { "message": "Thiếu trang web" }, @@ -11659,10 +11787,10 @@ "message": "Hủy lưu trữ" }, "archived": { - "message": "Archived" + "message": "Đã lưu trữ" }, "unArchiveAndSave": { - "message": "Unarchive and save" + "message": "Bỏ lưu trữ và lưu" }, "itemsInArchive": { "message": "Các mục trong kho lưu trữ" @@ -11680,7 +11808,7 @@ "message": "Các mục đã được chuyển vào lưu trữ" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "Mục đã được bỏ lưu trữ" }, "itemUnarchived": { "message": "Mục đã được bỏ lưu trữ" @@ -11695,8 +11823,8 @@ "message": "Lưu trữ mục", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Lưu trữ các mục", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "Xác minh ngay." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "GB lưu trữ bổ sung" }, @@ -12309,40 +12446,40 @@ } }, "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." @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "ownerBadgeA11yDescription": { + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "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." + }, + "perUser": { + "message": "per user" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json index 600cf866bd7..b479ae94f36 100644 --- a/apps/web/src/locales/zh_CN/messages.json +++ b/apps/web/src/locales/zh_CN/messages.json @@ -14,9 +14,33 @@ "noCriticalAppsAtRisk": { "message": "没有关键应用程序存在风险" }, + "critical": { + "message": "关键 ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "非关键 ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "关键" + }, "accessIntelligence": { "message": "Access Intelligence" }, + "noApplicationsMatchTheseFilters": { + "message": "没有匹配这些筛选规则的应用程序" + }, "passwordRisk": { "message": "密码风险" }, @@ -233,7 +257,7 @@ "message": "取消选择应用程序" }, "applicationsMarkedAsCriticalSuccess": { - "message": "标记为关键的应用程序" + "message": "应用程序标记为关键" }, "criticalApplicationsMarkedSuccess": { "message": "$COUNT$ 个应用程序标记为关键", @@ -250,6 +274,9 @@ "application": { "message": "应用程序" }, + "applications": { + "message": "应用程序" + }, "atRiskPasswords": { "message": "存在风险的密码" }, @@ -586,6 +613,9 @@ "email": { "message": "电子邮箱" }, + "emails": { + "message": "电子邮箱" + }, "phone": { "message": "电话" }, @@ -796,7 +826,7 @@ } }, "passwordSafe": { - "message": "没有在已知的数据泄露中发现此密码,它暂时比较安全。" + "message": "在任何已知的数据泄露中均未发现此密码。它暂时比较安全。" }, "save": { "message": "保存" @@ -1070,7 +1100,7 @@ "message": "其他" }, "share": { - "message": "分享" + "message": "共享" }, "moveToOrganization": { "message": "移动到组织" @@ -1217,6 +1247,9 @@ "selectAll": { "message": "全选" }, + "deselectAll": { + "message": "取消全选" + }, "unselectAll": { "message": "取消全选" }, @@ -1365,6 +1398,12 @@ "no": { "message": "否" }, + "noAuth": { + "message": "拥有此链接的任何人" + }, + "anyOneWithPassword": { + "message": "拥有您设置的密码的任何人" + }, "location": { "message": "位置" }, @@ -1375,7 +1414,7 @@ "message": "使用设备登录" }, "loginWithDeviceEnabledNote": { - "message": "必须在 Bitwarden App 的设置中启用设备登录。需要其他选项吗?" + "message": "必须在 Bitwarden App 的设置中设置设备登录。需要其他选项吗?" }, "needAnotherOptionV1": { "message": "需要其他选项吗?" @@ -1829,7 +1868,7 @@ "message": "登录不可用" }, "noTwoStepProviders": { - "message": "此账户已启用两步登录,但此浏览器不支持任何已配置的两步登录提供程序。" + "message": "此账户已设置两步登录,但此浏览器不支持任何已配置的两步登录提供程序。" }, "noTwoStepProviders2": { "message": "请使用受支持的网页浏览器(例如 Chrome),和/或添加其他跨网页浏览器支持更好的提供程序(例如验证器 App)。" @@ -2140,7 +2179,7 @@ } }, "loggedOutWarning": { - "message": "继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "changePasswordWarning": { "message": "更改密码后,您需要使用新密码登录。在其他设备上的活动会话将在一小时内注销。" @@ -2179,7 +2218,7 @@ "message": "加密密钥设置" }, "kdfIterations": { - "message": "KDF 迭代" + "message": "KDF 迭代次数" }, "kdfIterationsDesc": { "message": "更高的 KDF 迭代可以帮助保护您的主密码免遭攻击者的暴力破解。建议 $VALUE$ 或更高。", @@ -2191,7 +2230,7 @@ } }, "kdfIterationsWarning": { - "message": "如果将 KDF 迭代设置得太高,可能导致在相对老旧的设备上登录(以及解锁)Bitwarden 时性能不佳。建议您以 $INCREMENT$ 的增量值递增,然后测试您的所有设备。", + "message": "如果将 KDF 迭代次数设置得太高,可能导致在相对老旧的设备上登录(以及解锁)Bitwarden 时性能不佳。我们建议您以 $INCREMENT$ 的增量值递增,然后测试您的所有设备。", "placeholders": { "increment": { "content": "$1", @@ -2204,13 +2243,13 @@ "description": "Memory refers to computer memory (RAM). MB is short for megabytes." }, "argon2Warning": { - "message": "如果将 KDF 迭代、内存占用和并行设置得太高,可能导致在相对老旧的设备上登录(以及解锁)Bitwarden 时性能不佳。我们建议以小量递增的方式更改这些设置,然后测试您的所有设备。" + "message": "如果将 KDF 迭代次数、内存和并行线程数设置得太高,可能导致在相对老旧的设备上登录(以及解锁)Bitwarden 时性能不佳。我们建议以小量递增的方式更改这些设置,然后测试您的所有设备。" }, "kdfParallelism": { - "message": "KDF 并行性" + "message": "KDF 并行线程数" }, "argon2Desc": { - "message": "更高的 KDF 迭代、内存占用和并行可以帮助保护您的主密码免遭攻击者的暴力破解。" + "message": "更高的 KDF 迭代次数、内存和并行线程数可以帮助保护您的主密码免遭攻击者的暴力破解。" }, "encKeySettingsChanged": { "message": "加密密钥设置已保存" @@ -2225,7 +2264,7 @@ "message": "您是否担心自己的账户在其他设备上登录过?继续下面的操作以取消对之前使用过的所有计算机或设备的授权。如果您以前使用过公共计算机或不小心曾将密码保存在不属于您的设备上,则建议执行此安全步骤。此步骤还将清除所有以前记住的两步登录会话。" }, "deauthorizeSessionsWarning": { - "message": "继续操作还将使您退出当前会话,并要求您重新登录。如果有设置两步登录,也需要重新验证。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "继续操作还将使您注销当前会话,并要求您重新登录。如果有设置两步登录,也需要重新验证。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "newDeviceLoginProtection": { "message": "新设备登录" @@ -2470,7 +2509,7 @@ "message": "新增自定义域名" }, "newCustomDomainDesc": { - "message": "输入用逗号分隔的域名列表。只支持「基础」域名,不要输入子域名。例如,输入「google.com」而不是「www.google.com」。您也可以输入「androidapp://package.name」以将 Android App 与其他网站域名关联。" + "message": "输入使用逗号分隔的域名列表。只允许「基础」域名,不要输入子域名。例如,输入「google.com」而不是「www.google.com」。您也可以输入「androidapp://package.name」以将 Android App 与其他网站域名关联。" }, "customDomainX": { "message": "自定义域名 $INDEX$", @@ -2671,7 +2710,7 @@ "message": "保存表单。" }, "twoFactorYubikeyWarning": { - "message": "由于平台限制,YubiKey 不能在所有 Bitwarden 应用程序上使用。您应该启用另一个两步登录提供程序,以便在无法使用 YubiKey 时可以访问您的账户。支持的平台:" + "message": "由于平台限制,YubiKey 不能在所有 Bitwarden 应用程序上使用。您应该设置其他两步登录提供程序,以便在无法使用 YubiKey 时可以访问您的账户。支持的平台:" }, "twoFactorYubikeySupportUsb": { "message": "具有可使用 YubiKey 的 USB 端口的设备上的网页密码库、桌面应用程序、CLI 以及浏览器扩展。" @@ -2689,7 +2728,7 @@ } }, "u2fkeyX": { - "message": "U2F Key $INDEX$", + "message": "U2F 密钥 $INDEX$", "placeholders": { "index": { "content": "$1", @@ -2710,7 +2749,7 @@ "message": "NFC 支持" }, "twoFactorYubikeySupportsNfc": { - "message": "我的一把密钥支持 NFC。" + "message": "我的某个密钥支持 NFC。" }, "twoFactorYubikeySupportsNfcDesc": { "message": "如果您的某个 YubiKey 支持 NFC(例如 YubiKey NEO),移动设备在检测到 NFC 可用时将提示您。" @@ -2719,7 +2758,7 @@ "message": "YubiKey 已更新" }, "disableAllKeys": { - "message": "禁用全部密钥" + "message": "停用全部密钥" }, "twoFactorDuoDesc": { "message": "输入 Duo 管理面板提供的 Bitwarden 应用程序信息。" @@ -2773,7 +2812,7 @@ "message": "保存表单。" }, "twoFactorU2fWarning": { - "message": "由于平台限制,FIDO U2F 不能在所有 Bitwarden 应用程序上使用。您应该启用另一个两步登录提供程序,以便在无法使用 FIDO U2F 时可以访问您的账户。支持的平台:" + "message": "由于平台限制,FIDO U2F 不能在所有 Bitwarden 应用程序上使用。您应该设置其他两步登录提供程序,以便在无法使用 FIDO U2F 时可以访问您的账户。支持的平台:" }, "twoFactorU2fSupportWeb": { "message": "桌面/笔记本电脑上支持 U2F 的浏览器(启用了 FIDO U2F 的 Chrome、Opera、Vivaldi 或 Firefox)中的网页密码库和浏览器扩展。" @@ -2782,7 +2821,7 @@ "message": "等待您触摸安全密钥上的按钮" }, "twoFactorU2fClickSave": { - "message": "单击下面的「保存」按钮,以启用此安全密钥用于两步登录。" + "message": "单击下面的「保存」按钮,以激活此安全密钥用于两步登录。" }, "twoFactorU2fProblemReadingTryAgain": { "message": "读取安全密钥时出现问题。请重试。" @@ -2791,7 +2830,7 @@ "message": "您的 Bitwarden 两步登录恢复代码" }, "twoFactorRecoveryNoCode": { - "message": "您尚未设置任何两步登录提供程序。在启用了一个两步登录提供程序后,请返回这里检查恢复代码。" + "message": "您尚未设置任何两步登录提供程序。设置两步登录提供程序后,返回这里查看您的恢复代码。" }, "printCode": { "message": "打印代码", @@ -2837,7 +2876,7 @@ "message": "未激活两步登录" }, "inactive2faReportDesc": { - "message": "两步登录为您的账户增加了一层保护。使用 Bitwarden Authenticator 或其他方式为这些账户开启两步登录。" + "message": "两步登录为您的账户增加了一层保护。使用 Bitwarden Authenticator 或其他方式为这些账户设置两步登录。" }, "inactive2faFound": { "message": "发现未启用两步登录的登录项目" @@ -2979,7 +3018,7 @@ "message": "检查泄漏情况" }, "breachUsernameNotFound": { - "message": "没有在已知的数据泄露中发现 $USERNAME$。", + "message": "在任何已知的数据泄露中均未发现 $USERNAME$。", "placeholders": { "username": { "content": "$1", @@ -2992,7 +3031,7 @@ "description": "ex. Good News, No Breached Accounts Found!" }, "breachUsernameFound": { - "message": "$USERNAME$ 在不同的在线数据泄漏中找到 $COUNT$ 次。", + "message": "在 $COUNT$ 个不同的在线数据泄露中发现了 $USERNAME$。", "placeholders": { "username": { "content": "$1", @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "下一次收费" }, + "nextChargeDate": { + "message": "下一次收费日期" + }, "plan": { "message": "方案" }, @@ -3491,7 +3533,7 @@ "description": "Free as in 'free beer'." }, "planDescFree": { - "message": "适用于测试或与 $COUNT$ 位其他用户共享的个人用户。", + "message": "适用于测试或个人用户与 $COUNT$ 位其他用户共享。", "placeholders": { "count": { "content": "$1", @@ -3769,6 +3811,9 @@ "editCollection": { "message": "编辑集合" }, + "viewCollection": { + "message": "查看集合" + }, "collectionInfo": { "message": "集合信息" }, @@ -3788,10 +3833,10 @@ } }, "inviteUserDesc": { - "message": "在下面输入 Bitwarden 账户电子邮箱地址,以邀请新用户加入您的组织。如果他们没有 Bitwarden 账户,将会提示他们创建一个。" + "message": "在下面输入他们的 Bitwarden 账户电子邮箱地址,以邀请新用户加入您的组织。如果他们还没有 Bitwarden 账户,将提示他们创建一个新账户。" }, "inviteMultipleEmailDesc": { - "message": "通过逗号分隔,最多输入 $COUNT$ 个电子邮箱。", + "message": "最多输入 $COUNT$ 个电子邮箱(使用逗号分隔)。", "placeholders": { "count": { "content": "$1", @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "恢复所选" }, + "archivedItemRestored": { + "message": "归档项目已恢复" + }, + "archivedItemsRestored": { + "message": "归档项目已恢复" + }, "restoredItem": { "message": "项目已恢复" }, @@ -5425,7 +5476,7 @@ "message": "项目已恢复" }, "restoredItemId": { - "message": "恢复了项目 $ID$", + "message": "项目 $ID$ 已恢复", "placeholders": { "id": { "content": "$1", @@ -5571,7 +5622,7 @@ "message": "组织的所有者和管理员不受此策略的约束。" }, "limitSendViews": { - "message": "查看次数限制" + "message": "限制查看次数" }, "limitSendViewsHint": { "message": "达到限额后,任何人无法查看此 Send。", @@ -5600,10 +5651,6 @@ "sendTypeText": { "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." @@ -5620,13 +5667,33 @@ "message": "Send 创建成功!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "复制并分享此 Send 链接。您指定的人员可在接下来的 $TIME$ 内查看此 Send。", + "sendCreatedDescriptionV2": { + "message": "复制并分享此 Send 链接。在接下来的 $TIME$ 内,拥有此链接的任何人都可以访问此 Send。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "复制并分享此 Send 链接。在接下来的 $TIME$ 内,任何拥有此链接以及您设置的密码的人都可以访问此 Send。", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { + "message": "复制并分享此 Send 链接。在接下来的 $TIME$ 内,您指定的人员可查看此 Send。", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" } } }, @@ -5738,7 +5805,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendHiddenByDefault": { - "message": "此 Send 默认隐藏。您可使用下方的按钮切换其可见性。", + "message": "此 Send 默认隐藏。您可以使用下方的按钮切换其可见性。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "downloadAttachments": { @@ -5795,7 +5862,7 @@ "message": "编辑紧急联系人" }, "inviteEmergencyContactDesc": { - "message": "通过在下面输入他们的 Bitwarden 账户电子邮箱地址来邀请新的紧急联系人。如果他们还没有 Bitwarden 账户,将提示他们创建一个新账户。" + "message": "在下面输入他们的 Bitwarden 账户电子邮箱地址,以邀请新的紧急联系人。如果他们还没有 Bitwarden 账户,将提示他们创建一个新账户。" }, "emergencyAccessRecoveryInitiated": { "message": "紧急访问已发起" @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "集中化组织所有权" + }, + "centralizeDataOwnershipDesc": { + "message": "所有成员项目都将由组织拥有和管理。管理员和所有者不受此约束。" + }, + "centralizeDataOwnershipContentAnchor": { + "message": "进一步了解集中化所有权", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "优点" + }, + "centralizeDataOwnershipBenefit1": { + "message": "全面掌握凭据的健康状况,包括已共享和未共享的项目。" + }, + "centralizeDataOwnershipBenefit2": { + "message": "在成员离职或岗位交接时,轻松转移项目,确保不会出现访问空档。" + }, + "centralizeDataOwnershipBenefit3": { + "message": "为所有用户提供一个专用的「我的项目」空间,用于管理他们自己的登录信息。" + }, + "centralizeDataOwnershipWarningTitle": { + "message": "提示成员转移他们的项目" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "如果成员的个人密码库中有项目,将提示他们选择将项目转移到组织中,或者选择退出。如果选择退出,其访问权限将被撤销,但可以随时恢复。" + }, + "centralizeDataOwnershipWarningLink": { + "message": "进一步了解转移" + }, "organizationDataOwnership": { "message": "强制组织数据所有权" }, @@ -6411,7 +6509,7 @@ "message": "重置密码" }, "resetPasswordLoggedOutWarning": { - "message": "继续操作会将 $NAME$ 登出当前会话,并要求他们重新登录。在其他设备上的活动会话可能继续活动长达一个小时。", + "message": "继续操作将使 $NAME$ 注销当前会话,并要求他们重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。", "placeholders": { "name": { "content": "$1", @@ -6420,7 +6518,7 @@ } }, "emergencyAccessLoggedOutWarning": { - "message": "继续操作会将 $NAME$ 登出当前会话,并要求他们重新登录。在其他设备上的活动会话可能继续活动长达一个小时。", + "message": "继续操作将使 $NAME$ 注销当前会话,并要求他们重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。", "placeholders": { "name": { "content": "$1", @@ -6594,7 +6692,7 @@ "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "contactCSToAvoidDataLossPart2": { - "message": "以避免额外的数据丢失。", + "message": "以避免进一步的数据丢失。", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "accountRecoveryManageUsers": { @@ -6635,7 +6733,7 @@ "message": "服务用户可以访问和管理所有客户组织。" }, "providerInviteUserDesc": { - "message": "通过在下面输入他们的 Bitwarden 账户电子邮箱地址,以邀请新用户加入您的提供商。如果他们还没有 Bitwarden 账户,将提示他们创建一个新账户。" + "message": "在下面输入他们的 Bitwarden 账户电子邮箱地址,以邀请新用户加入您的提供商。如果他们还没有 Bitwarden 账户,将提示他们创建一个新账户。" }, "joinProvider": { "message": "加入提供商" @@ -6744,13 +6842,13 @@ "message": "您的主密码不符合本组织的要求。更改您的主密码以继续。" }, "updateMasterPasswordWarning": { - "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,必须立即更新您的主密码。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "masterPasswordInvalidWarning": { - "message": "您的主密码不符合此组织的策略要求。要加入此组织,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码不符合此组织的策略要求。要加入此组织,必须立即更新您的主密码。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "updateWeakMasterPasswordWarning": { - "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "automaticAppLoginWithSSO": { "message": "使用 SSO 自动登录" @@ -6888,16 +6986,16 @@ "personalVaultExportPolicyInEffect": { "message": "一个或多个组织策略阻止您导出个人密码库。" }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "激活自动填充" }, - "activateAutofillPolicyDesc": { - "message": "为所有现有成员和新成员激活浏览器扩展上的页面加载时的自动填充设置。" + "activateAutofillPolicyDescription": { + "message": "为所有现有成员和新成员激活浏览器扩展上的页面加载时自动填充设置。" }, - "experimentalFeature": { - "message": "被入侵或不受信任的网站可能恶意利用页面加载时的自动填充功能。" + "autofillOnPageLoadExploitWarning": { + "message": "被攻破或不受信任的网站可能会利用页面加载时的自动填充功能。" }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "进一步了解自动填充" }, "selectType": { @@ -7249,7 +7347,7 @@ "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Connect login with SSO to your self-hosted decryption key server. Using this option, members won’t need to use their master passwords to decrypt vault data. The require SSO authentication and single organization policies are required to set up Key Connector decryption. Contact Bitwarden Support for set up assistance.'" }, "keyConnectorPolicyRestriction": { - "message": "「SSO 登录和 Key Connector 解密」已启用。此策略仅适用于所有者和管理员。" + "message": "「SSO 登录和 Key Connector 解密」已激活。此策略仅适用于所有者和管理员。" }, "enabledSso": { "message": "SSO 已启用" @@ -7267,7 +7365,7 @@ } }, "enabledKeyConnector": { - "message": "Key Connector 已启用" + "message": "Key Connector 已激活" }, "disabledKeyConnector": { "message": "Key Connector 已停用" @@ -9968,7 +10066,7 @@ "message": "免费 1 年" }, "newWebApp": { - "message": "欢迎使用全新改进的网页 App。进一步了解有关变更的信息。" + "message": "欢迎使用全新改进的网页 App。进一步了解发生了哪些变化。" }, "releaseBlog": { "message": "阅读发行博客" @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "将密码库事件数据发送到您的 Datadog 实例" }, + "huntressEventIntegrationDesc": { + "message": "将事件数据发送到您的 Huntress SIEM 实例" + }, "failedToSaveIntegration": { "message": "保存集成失败。请稍后再试。" }, + "mustBeOrganizationOwnerAdmin": { + "message": "您必须是组织所有者或管理员才能执行此操作。" + }, "mustBeOrgOwnerToPerformAction": { "message": "您必须是组织所有者才能执行此操作。" }, @@ -10506,6 +10610,12 @@ "index": { "message": "索引" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "选择一个方案" }, @@ -11130,7 +11240,7 @@ "message": "如果您想自动勾选表单复选框(例如记住电子邮箱),请使用复选框型字段" }, "linkedHelpText": { - "message": "当您处理特定网站的自动填充问题时,请使用链接型字段" + "message": "当您遇到特定网站的自动填充问题时,请使用链接型字段。" }, "linkedLabelHelpText": { "message": "输入字段的 html ID、名称、aria-label 或占位符。" @@ -11234,7 +11344,7 @@ } }, "userLeftOrganization": { - "message": "用户 $ID$ 离开了组织", + "message": "用户 $ID$ 退出了组织", "placeholders": { "id": { "content": "$1", @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden 将在最初的 72 小时内尝试声明域名 3 次。如果此域名无法声明,请检查您主机中的 DNS 记录并手动声明。如果此域名在 7 天内未声明,它将被从您的组织中移除。" }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden 将在 72 小时内尝试声明此域名。如果此域名无法声明,请验证您的 DNS 记录并手动声明。未声明的域名将在 7 天后被移除。" + }, + "automaticDomainClaimProcess2": { + "message": "声明后,使用已声明域名的现有成员将收到关于" + }, + "accountOwnershipChange": { + "message": "账户所有权变更" + }, + "automaticDomainClaimProcessEnd": { + "message": "的电子邮件通知。" + }, "domainNotClaimed": { "message": "$DOMAIN$ 无法声明。请检查您的 DNS 记录。", "placeholders": { @@ -11332,11 +11454,11 @@ "domainStatusClaimed": { "message": "已声明" }, - "domainStatusUnderVerification": { - "message": "验证中" + "domainStatusPending": { + "message": "处理中" }, "claimedDomainsDescription": { - "message": "声明域名以拥有成员账户。已声明域名的成员登录时将跳过 SSO 标识符页面,管理员也可以删除已声明的账户。" + "message": "声明域名以拥有成员账户。使用已声明域名的成员登录时将跳过 SSO 标识符页面,管理员也可以删除已声明的账户。" }, "invalidDomainNameClaimMessage": { "message": "输入的格式无效。格式:mydomain.com。子域名需要单独的条目进行声明。" @@ -11615,11 +11737,17 @@ "message": "未信任的加密密钥" }, "changeAtRiskPassword": { - "message": "更改有风险的密码" + "message": "更改存在风险的密码" }, "changeAtRiskPasswordAndAddWebsite": { "message": "此登录存在风险且缺少网站。请添加网站并更改密码以增强安全性。" }, + "vulnerablePassword": { + "message": "易受攻击的密码。" + }, + "changeNow": { + "message": "立即更改" + }, "missingWebsite": { "message": "缺少网站" }, @@ -11695,8 +11823,8 @@ "message": "归档项目", "description": "Verb" }, - "archiveItemConfirmDesc": { - "message": "已归档的项目将被排除在一般搜索结果和自动填充建议之外。确定要归档此项目吗?" + "archiveItemDialogContent": { + "message": "归档后,此项目将被排除在一般搜索结果和自动填充建议之外。" }, "archiveBulkItems": { "message": "归档项目", @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "立即验证。" }, + "unlockWithPasskey": { + "message": "使用通行密钥解锁" + }, + "prfUnlockFailed": { + "message": "使用通行密钥解锁失败。请重试或使用其他解锁方式。" + }, + "noPrfCredentialsAvailable": { + "message": "没有可用于解锁的 PRF 通行密钥。" + }, "additionalStorageGB": { "message": "附加存储 GB" }, @@ -12263,7 +12400,7 @@ "message": "卡号" }, "startFreeFamiliesTrial": { - "message": "开始免费家庭版试用" + "message": "开始家庭版免费试用" }, "blockClaimedDomainAccountCreation": { "message": "阻止使用已声明的域名创建账户" @@ -12614,5 +12751,48 @@ }, "storageFullDescription": { "message": "您已使用了全部的 $GB$ GB 加密存储空间。要继续存储文件,请添加更多存储空间。" + }, + "whoCanView": { + "message": "谁可以查看" + }, + "specificPeople": { + "message": "指定人员" + }, + "emailVerificationDesc": { + "message": "分享此 Send 链接后,个人需要使用验证码验证他们的电子邮箱才能查看此 Send。" + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "输入多个电子邮箱(使用逗号分隔)。" + }, + "emailPlaceholder": { + "message": "user@bitwarden.com, user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "当您移除存储空间时,您将收到一笔按比例计算的账户信用额度,其将用于自动抵扣您的下一笔费用。" + }, + "ownerBadgeA11yDescription": { + "message": "所有者,$OWNER$,显示 $OWNER$ 拥有的所有项目", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "您拥有高级版" + }, + "emailProtected": { + "message": "电子邮箱保护" + }, + "invalidSendPassword": { + "message": "无效的 Send 密码" + }, + "sendPasswordHelperText": { + "message": "个人需要输入密码才能查看此 Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "perUser": { + "message": "每位用户" } -} \ No newline at end of file +} diff --git a/apps/web/src/locales/zh_TW/messages.json b/apps/web/src/locales/zh_TW/messages.json index af3f44617ac..b80112b4d39 100644 --- a/apps/web/src/locales/zh_TW/messages.json +++ b/apps/web/src/locales/zh_TW/messages.json @@ -6,31 +6,55 @@ "message": "活動" }, "appLogoLabel": { - "message": "Bitwarden 圖示" + "message": "Bitwarden logo" }, "criticalApplications": { - "message": "重要應用程式" + "message": "關鍵應用程式" }, "noCriticalAppsAtRisk": { - "message": "沒有關鍵應用程式處於風險中" + "message": "目前沒有關鍵應用程式存在風險" + }, + "critical": { + "message": "關鍵 ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "非關鍵 ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge": { + "message": "關鍵" }, "accessIntelligence": { - "message": "存取資訊" + "message": "Access Intelligence" + }, + "noApplicationsMatchTheseFilters": { + "message": "沒有任何應用程式符合這些篩選條件" }, "passwordRisk": { "message": "密碼風險" }, "noEditPermissions": { - "message": "你沒有權限編輯這個項目" + "message": "您沒有權限編輯此項目" }, "reviewAtRiskPasswords": { - "message": "檢視全部應用中具有風險的密碼 (弱、被暴露或重複使用)。選擇最重要的應用程式並優先採取安全措施,幫助使用者解決具有風險的密碼。" + "message": "檢視各項應用程式中具有風險的密碼(包含強度不足、已外洩或重複使用的密碼)。請選取最關鍵的應用程式,以便優先採取安全措施,引導使用者處理風險密碼。" }, "reviewAtRiskLoginsPrompt": { - "message": "檢視有風險的登入資訊" + "message": "檢視高風險登入" }, "dataLastUpdated": { - "message": "上次資料更新日期:$DATE$", + "message": "資料最後更新於:$DATE$", "placeholders": { "date": { "content": "$1", @@ -42,7 +66,7 @@ "message": "您尚未建立報告" }, "notifiedMembers": { - "message": "已被通知的成員" + "message": "已通知成員" }, "revokeMembers": { "message": "撤銷成員" @@ -63,7 +87,7 @@ } }, "createNewLoginItem": { - "message": "新增登入項目" + "message": "建立新的登入項目" }, "percentageCompleted": { "message": "完成 $PERCENT$%", @@ -91,7 +115,7 @@ "message": "密碼變更進度" }, "assignMembersTasksToMonitorProgress": { - "message": "指派成員任務以監控進度" + "message": "指派任務給成員以監控進度" }, "onceYouReviewApplications": { "message": "當您審查應用程式並將其標記為關鍵後,可指派任務給成員以變更其密碼。" @@ -122,7 +146,7 @@ } }, "criticalApplicationsWithCount": { - "message": "重要應用程式($COUNT$)", + "message": "關鍵應用程式 ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -131,7 +155,7 @@ } }, "criticalApplicationsMarked": { - "message": "已將應用程式標記為關鍵" + "message": "已標記為關鍵應用程式" }, "countOfCriticalApplications": { "message": "$COUNT$ 個關鍵應用程式", @@ -170,7 +194,7 @@ } }, "notifiedMembersWithCount": { - "message": "已被通知的成員($COUNT$)", + "message": "已通知成員 ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -182,13 +206,13 @@ "message": "找不到資料" }, "noDataInOrgDescription": { - "message": "匯入您組織的登入資料以開始使用存取智慧功能。完成後,您將能夠:" + "message": "匯入組織的登入資料即可開始使用 Access Intelligence。完成後,您將能夠:" }, "feature1Title": { "message": "將應用程式標記為關鍵" }, "feature1Description": { - "message": "這將協助您優先消除最重要應用程式的風險。" + "message": "這將協助您優先消除關鍵應用程式的風險。" }, "feature2Title": { "message": "協助成員提升其安全性" @@ -197,7 +221,7 @@ "message": "指派有風險的成員執行指導式安全任務以更新憑證。" }, "feature3Title": { - "message": "監控進展" + "message": "追蹤進度" }, "feature3Description": { "message": "追蹤隨時間變化的狀況以顯示安全性改善。" @@ -215,13 +239,13 @@ "message": "選擇您最關鍵的應用程式,以優先處理安全行動,讓使用者解決有風險的密碼。" }, "markCriticalApplications": { - "message": "選擇重要應用程式" + "message": "選擇關鍵應用程式" }, "markAppAsCritical": { - "message": "標註應用程式為重要" + "message": "將應用程式標記為關鍵" }, "markAsCritical": { - "message": "標註應用程式為重要" + "message": "標記為關鍵" }, "applicationsSelected": { "message": "已選擇的應用程式" @@ -233,7 +257,7 @@ "message": "取消選擇應用程式" }, "applicationsMarkedAsCriticalSuccess": { - "message": "被標註重要的應用程式" + "message": "已標記為關鍵的應用程式" }, "criticalApplicationsMarkedSuccess": { "message": "已將 $COUNT$ 個應用程式標記為關鍵", @@ -250,6 +274,9 @@ "application": { "message": "應用程式" }, + "applications": { + "message": "應用程式" + }, "atRiskPasswords": { "message": "具有風險的密碼" }, @@ -302,7 +329,7 @@ } }, "atRiskMemberDescription": { - "message": "這些成員正以薄弱、已外洩或重複使用的密碼登入關鍵應用程式。" + "message": "這些成員正以強度不足、已外洩或重複使用的密碼登入關鍵應用程式。" }, "atRiskMembersDescriptionNone": { "message": "目前沒有成員使用弱密碼、外洩密碼或重複密碼登入應用程式。" @@ -377,7 +404,7 @@ } }, "reviewApplicationsToSecureItems": { - "message": "審查應用程式以保護對組織安全最重要的項目" + "message": "檢視應用程式,以確保對組織安全最關鍵的項目受到保護" }, "reviewApplications": { "message": "審核認領" @@ -477,7 +504,7 @@ "message": "身分" }, "contactInfo": { - "message": "聯繫資訊" + "message": "聯絡資訊" }, "cardDetails": { "message": "支付卡詳細資料" @@ -514,16 +541,16 @@ } }, "websiteAdded": { - "message": "網站已添加" + "message": "網站已新增" }, "addWebsite": { - "message": "添加網站" + "message": "新增網站" }, "deleteWebsite": { "message": "刪除網站" }, "defaultLabel": { - "message": "預設 ($VALUE$)", + "message": "預設($VALUE$)", "description": "A label that indicates the default value for a field with the current default value in parentheses.", "placeholders": { "value": { @@ -533,7 +560,7 @@ } }, "showMatchDetection": { - "message": "顯示偵測到的吻合 $WEBSITE$", + "message": "顯示偵測到相符的 $WEBSITE$", "placeholders": { "website": { "content": "$1", @@ -542,7 +569,7 @@ } }, "hideMatchDetection": { - "message": "隱藏偵測到的吻合 $WEBSITE$", + "message": "隱藏偵測到相符的 $WEBSITE$", "placeholders": { "website": { "content": "$1", @@ -560,7 +587,7 @@ "message": "發卡組織" }, "expiration": { - "message": "逾期" + "message": "到期日" }, "securityCode": { "message": "安全碼 (CVV)" @@ -575,17 +602,20 @@ "message": "公司" }, "ssn": { - "message": "社會保險號碼" + "message": "社會安全號碼" }, "passportNumber": { "message": "護照號碼" }, "licenseNumber": { - "message": "許可證號碼" + "message": "駕照號碼" }, "email": { "message": "電子郵件" }, + "emails": { + "message": "電子郵件" + }, "phone": { "message": "電話號碼" }, @@ -650,10 +680,10 @@ "message": "如果您已續卡,請更新支付卡資訊" }, "expirationMonth": { - "message": "逾期月份" + "message": "到期月份" }, "expirationYear": { - "message": "逾期年份" + "message": "到期年份" }, "authenticatorKeyTotp": { "message": "驗證器金鑰 (TOTP)" @@ -668,7 +698,7 @@ "message": "Bitwarden 可以儲存並填入兩步驟驗證碼。選擇相機圖示來截取此網站的驗證器QR code,或手動複製金鑰並貼上到此欄位。" }, "learnMoreAboutAuthenticators": { - "message": "了解更多驗證程式" + "message": "瞭解更多關於驗證器的資訊" }, "folder": { "message": "資料夾" @@ -705,7 +735,7 @@ "message": "未指派" }, "noneFolder": { - "message": "預設資料夾", + "message": "無資料夾", "description": "This is the folder for uncategorized items" }, "selfOwnershipLabel": { @@ -766,11 +796,11 @@ "description": "A programming term, also known as 'RegEx'." }, "matchDetection": { - "message": "一致性偵測", + "message": "相符偵測", "description": "URI match detection for auto-fill." }, "defaultMatchDetection": { - "message": "預設一致性偵測", + "message": "預設相符偵測", "description": "Default URI match detection for auto-fill." }, "never": { @@ -1153,7 +1183,7 @@ "message": "複製護照號碼" }, "copyLicenseNumber": { - "message": "複製許可證號碼" + "message": "複製駕照號碼" }, "copyPrivateKey": { "message": "複製私密金鑰" @@ -1217,6 +1247,9 @@ "selectAll": { "message": "全選" }, + "deselectAll": { + "message": "取消全選" + }, "unselectAll": { "message": "取消全選" }, @@ -1365,6 +1398,12 @@ "no": { "message": "否" }, + "noAuth": { + "message": "任何持有連結的人" + }, + "anyOneWithPassword": { + "message": "任何持有您設定之密碼的人" + }, "location": { "message": "位置" }, @@ -1384,7 +1423,7 @@ "message": "使用主密碼登入" }, "readingPasskeyLoading": { - "message": "正在讀取通行金鑰..." + "message": "正在讀取密碼金鑰…" }, "readingPasskeyLoadingInfo": { "message": "保持此視窗打開,然後按照瀏覽器的提示進行操作。" @@ -1656,7 +1695,7 @@ "message": "發生了未預期的錯誤。" }, "expirationDateError": { - "message": "請選擇一個未來的逾期日期。" + "message": "請選擇一個未來的到期日。" }, "emailAddress": { "message": "電子郵件地址" @@ -1750,7 +1789,7 @@ "message": "沒有可列出的成員。" }, "noMembersToExport": { - "message": "There are no members to export." + "message": "沒有可匯出的成員。" }, "noEventsInList": { "message": "沒有可列出的事件。" @@ -1925,7 +1964,7 @@ } }, "deleteSelectedConfirmation": { - "message": "您確定要繼續嗎?" + "message": "您確定要繼續嗎?" }, "moveSelectedItemsDesc": { "message": "選擇要將這 $COUNT$ 個項目移動至哪個資料夾。", @@ -2541,7 +2580,7 @@ "message": "已啟用" }, "optionEnabled": { - "message": "Enabled" + "message": "已啟用" }, "restoreAccess": { "message": "還原存取權限" @@ -2791,7 +2830,7 @@ "message": "您的 Bitwarden 兩步驟登入復原碼" }, "twoFactorRecoveryNoCode": { - "message": "您尚未啟用任何兩步驟登入方式。等你啟用兩步驟登入方式後,您可回來這裡取得復原碼。" + "message": "您目前尚未啟用任何兩步驟登入方式。啟用後,即可回到此處取得復原碼。" }, "printCode": { "message": "列印代碼", @@ -3153,7 +3192,7 @@ "message": "若要重新存取您的封存項目,請重新啟用進階版訂閱。若您在重新啟用前編輯封存項目的詳細資料,它將會被移回您的密碼庫。" }, "itemRestored": { - "message": "Item has been restored" + "message": "項目已還原" }, "restartPremium": { "message": "重新啟用進階版" @@ -3165,7 +3204,7 @@ "message": "# GB 額外儲存空間" }, "additionalStorageIntervalDesc": { - "message": "您的方案擁有 $SIZE$ 的加密儲存空間。您也可以用每 GB $PRICE$ / $INTERVAL$ 購買額外的儲存空間。", + "message": "您的方案提供 $SIZE$ 的加密儲存空間。如需額外儲存空間,可依每 GB 每 $INTERVAL$ $PRICE$ 加購。", "placeholders": { "size": { "content": "$1", @@ -3252,19 +3291,19 @@ "message": "待取消" }, "subscriptionPendingCanceled": { - "message": "此訂閱在目前計費周期结束前已標記為取消。" + "message": "此訂閱已標記為將於目前計費週期結束時取消。" }, "reinstateSubscription": { "message": "恢復訂閱" }, "reinstateConfirmation": { - "message": "您是否要移除待處理的取消要求,重新開始您的訂閱?" + "message": "確定要撤回取消申請並恢復訂閱嗎?" }, "reinstated": { "message": "已重新開始訂閱。" }, "cancelConfirmation": { - "message": "您確定要取消訂閱嗎?在目前計費周期結束之後,您將無法使用所有訂閲功能。" + "message": "您確定要取消訂閱嗎?在本次計費週期結束後,您將無法再使用此訂閱的所有功能。" }, "canceledSubscription": { "message": "訂閱已取消" @@ -3281,6 +3320,9 @@ "nextChargeHeader": { "message": "下一次收費" }, + "nextChargeDate": { + "message": "下次扣款日期" + }, "plan": { "message": "方案" }, @@ -3769,6 +3811,9 @@ "editCollection": { "message": "編輯集合" }, + "viewCollection": { + "message": "檢視集合" + }, "collectionInfo": { "message": "集合資訊" }, @@ -3845,7 +3890,7 @@ "message": "全部" }, "addAccess": { - "message": "添加存取權限" + "message": "新增存取權限" }, "addAccessFilter": { "message": "新增存取過濾器" @@ -4504,7 +4549,7 @@ "message": "更新瀏覽器" }, "generatingYourAccessIntelligence": { - "message": "正在產生您的存取智慧分析…" + "message": "正在產生您的 Access Intelligence……" }, "fetchingMemberData": { "message": "正在擷取成員資料…" @@ -5418,6 +5463,12 @@ "restoreSelected": { "message": "還原所選" }, + "archivedItemRestored": { + "message": "已還原封存項目" + }, + "archivedItemsRestored": { + "message": "已還原封存項目" + }, "restoredItem": { "message": "項目已還原" }, @@ -5600,10 +5651,6 @@ "sendTypeText": { "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." @@ -5617,21 +5664,41 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendCreatedSuccessfully": { - "message": "Send created successfully!", + "message": "Send 建立成功!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "複製並分享此 Send 連結。任何擁有此連結的人,都可在接下來的 $TIME$ 內存取該 Send。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionPassword": { + "message": "複製並分享此 Send 連結。任何擁有此連結與您所設定密碼的人,都可在接下來的 $TIME$ 內存取該 Send。", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { + "message": "複製並分享此 Send 連結。在接下來的 $TIME$ 內,只有您指定的人可以檢視。", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" } } }, "durationTimeHours": { - "message": "$HOURS$ hours", + "message": "$HOURS$ 小時", "placeholders": { "hours": { "content": "$1", @@ -5640,11 +5707,11 @@ } }, "newTextSend": { - "message": "New Text Send", + "message": "新增文字 Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "newFileSend": { - "message": "New File Send", + "message": "新增檔案 Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editedSend": { @@ -5687,7 +5754,7 @@ "message": "已撤銷" }, "accepted": { - "message": "Accepted" + "message": "已接受" }, "sendLink": { "message": "Send 連結", @@ -5909,6 +5976,37 @@ } } }, + "centralizeDataOwnership": { + "message": "集中化組織所有權" + }, + "centralizeDataOwnershipDesc": { + "message": "所有成員的項目將由組織統一擁有並管理。管理員與擁有者不受此限制。" + }, + "centralizeDataOwnershipContentAnchor": { + "message": "進一步了解集中化所有權", + "description": "This will be used as a hyperlink" + }, + "benefits": { + "message": "優點" + }, + "centralizeDataOwnershipBenefit1": { + "message": "全面掌握憑證的安全狀態,包括已共用與未共用的項目。" + }, + "centralizeDataOwnershipBenefit2": { + "message": "在成員離職或交接時輕鬆轉移項目,確保存取權不中斷。" + }, + "centralizeDataOwnershipBenefit3": { + "message": "為所有使用者提供專屬的「我的項目」空間,以管理個人登入資料。" + }, + "centralizeDataOwnershipWarningTitle": { + "message": "提示成員轉移其項目" + }, + "centralizeDataOwnershipWarningDesc": { + "message": "若成員在其個人密碼庫中仍有項目,系統將提示其選擇轉移至組織或離開。若選擇離開,其存取權將被撤銷,但可隨時恢復。" + }, + "centralizeDataOwnershipWarningLink": { + "message": "進一步了解轉移流程" + }, "organizationDataOwnership": { "message": "強制組織資料所有權" }, @@ -6055,16 +6153,16 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "uriMatchDetectionPolicy": { - "message": "預設的 URI 一致性偵測方式" + "message": "預設 URI 相符偵測" }, "uriMatchDetectionPolicyDesc": { - "message": "決定何時建議登入項目進行自動填入。管理員與擁有者不受此原則限制。" + "message": "用於決定何時提供登入項目的自動填入建議。系統管理員與擁有者不受此原則限制。" }, "uriMatchDetectionOptionsLabel": { - "message": "預設的 URI 一致性偵測方式" + "message": "預設 URI 相符偵測" }, "invalidUriMatchDefaultPolicySetting": { - "message": "請選擇有效的 URI 比對偵測選項。", + "message": "請選擇有效的 URI 相符偵測選項。", "description": "Error message displayed when a user attempts to save URI match detection policy settings with an invalid selection." }, "modifiedPolicyId": { @@ -6348,10 +6446,10 @@ "message": "已注冊帳戶復原" }, "enrolled": { - "message": "Enrolled" + "message": "已加入" }, "notEnrolled": { - "message": "Not enrolled" + "message": "未加入" }, "withdrawAccountRecovery": { "message": "撤銷帳戶復原" @@ -6888,16 +6986,16 @@ "personalVaultExportPolicyInEffect": { "message": "一個或多個組織原則禁止您匯出個人密碼庫。" }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "啓用自動填入" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "為所有現有的和新的成員,啓用瀏覽器擴充套件上的頁面載入自動填入設定。" }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "被入侵或不被信任的網站,可能會濫用頁面載入的自動填入功能。" }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "進一步瞭解「自動填入」功能" }, "selectType": { @@ -9875,7 +9973,7 @@ "description": "Label indicating the most common import formats" }, "uriMatchDefaultStrategyHint": { - "message": "URI 匹配偵測是 Bitwarden 用來識別自動填入建議的方式。", + "message": "URI 相符偵測是 Bitwarden 用來判定自動填入建議的方式。", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, "regExAdvancedOptionWarning": { @@ -9887,7 +9985,7 @@ "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { - "message": "深入了解匹配偵測", + "message": "深入了解相符偵測", "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { @@ -10395,9 +10493,15 @@ "datadogEventIntegrationDesc": { "message": "將密碼庫事件資料傳送至你的 Datadog 執行個體" }, + "huntressEventIntegrationDesc": { + "message": "將事件資料傳送至您的 SIEM 執行個體" + }, "failedToSaveIntegration": { "message": "整合設定儲存失敗。請稍後再試。" }, + "mustBeOrganizationOwnerAdmin": { + "message": "必須具備組織擁有者或管理員身分,才能執行此動作。" + }, "mustBeOrgOwnerToPerformAction": { "message": "必須是組織擁有者才能執行此動作。" }, @@ -10506,6 +10610,12 @@ "index": { "message": "索引" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "選擇一個計劃" }, @@ -10889,7 +10999,7 @@ "message": "深入瞭解緊急存取" }, "learnMoreAboutMatchDetection": { - "message": "深入瞭解符合項目偵測" + "message": "瞭解更多關於相符偵測的資訊" }, "learnMoreAboutMasterPasswordReprompt": { "message": "深入瞭解主密碼再次提示" @@ -11320,6 +11430,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden 會在前 72 小時內嘗試宣告該網域 3 次。若無法宣告,請檢查主機上的 DNS 紀錄並手動宣告。若 7 天內仍未宣告,該網域將自你的組織移除。" }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden 將在 72 小時內嘗試宣告該網域。若無法成功宣告,請驗證您的 DNS 記錄並手動宣告。未被宣告的網域將於 7 天後移除。" + }, + "automaticDomainClaimProcess2": { + "message": "宣告完成後,系統將透過電子郵件通知使用已宣告網域的現有成員,關於" + }, + "accountOwnershipChange": { + "message": "帳號所有權變更" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "尚未宣告 $DOMAIN$。請檢查你的 DNS 紀錄。", "placeholders": { @@ -11332,8 +11454,8 @@ "domainStatusClaimed": { "message": "已宣告" }, - "domainStatusUnderVerification": { - "message": "驗證中" + "domainStatusPending": { + "message": "待處理" }, "claimedDomainsDescription": { "message": "宣告網域以取得其成員帳號的管理權。擁有已宣告網域的成員在登入時會略過 SSO 識別頁面,且管理員將可刪除已宣告的帳號。" @@ -11620,6 +11742,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "此登入資訊存在風險,且缺少網站。請新增網站並變更密碼以提升安全性。" }, + "vulnerablePassword": { + "message": "有安全疑慮的密碼。" + }, + "changeNow": { + "message": "立即變更" + }, "missingWebsite": { "message": "缺少網站" }, @@ -11659,7 +11787,7 @@ "message": "取消封存" }, "archived": { - "message": "Archived" + "message": "已封存" }, "unArchiveAndSave": { "message": "取消封存並儲存" @@ -11680,7 +11808,7 @@ "message": "項目已移至封存" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "已取消封存項目" }, "itemUnarchived": { "message": "項目取消封存" @@ -11695,8 +11823,8 @@ "message": "封存項目", "description": "Verb" }, - "archiveItemConfirmDesc": { - "message": "封存的項目將不會出現在一般搜尋結果或自動填入建議中。確定要封存此項目嗎?" + "archiveItemDialogContent": { + "message": "封存後,此項目將不會顯示在搜尋結果與自動填入建議中。" }, "archiveBulkItems": { "message": "封存項目", @@ -11830,11 +11958,11 @@ "description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'" }, "openExtensionFromToolbarPart1": { - "message": "如果擴充套件沒有開啟,您可能需要從圖示開啟 Bitwarden ", + "message": "若擴充套件沒有開啟,您可能需要透過工具列上的圖示 ", "description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'If the extension didn't open, you may need to open Bitwarden from the icon [Bitwarden Icon] on the toolbar.'" }, "openExtensionFromToolbarPart2": { - "message": " 在工具列上。", + "message": "來開啟 Bitwarden。", "description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'If the extension didn't open, you may need to open Bitwarden from the icon [Bitwarden Icon] on the toolbar.'" }, "gettingStartedWithBitwardenPart3": { @@ -12049,6 +12177,15 @@ "verifyNow": { "message": "立即驗證" }, + "unlockWithPasskey": { + "message": "使用通行金鑰解鎖" + }, + "prfUnlockFailed": { + "message": "使用通行金鑰解鎖失敗。請再試一次或改用其他解鎖方式。" + }, + "noPrfCredentialsAvailable": { + "message": "沒有可用的支援 PRF 的通行金鑰可用於解鎖。" + }, "additionalStorageGB": { "message": "額外儲存空間 (GB)" }, @@ -12275,7 +12412,7 @@ "message": "在啟用此原則前,必須先宣告網域。" }, "unlockMethodNeededToChangeTimeoutActionDesc": { - "message": "設定一個解鎖方式來變更您的密碼庫逾時動作。" + "message": "設定解鎖方式以變更您的密碼庫逾時行為。" }, "vaultTimeoutPolicyAffectingOptions": { "message": "企業政策已套用至您的逾時選項中" @@ -12357,7 +12494,7 @@ "message": "使用者驗證失敗。" }, "resizeSideNavigation": { - "message": "Resize side navigation" + "message": "調整側邊欄大小" }, "recoveryDeleteCiphersTitle": { "message": "刪除無法復原的密碼庫項目" @@ -12443,7 +12580,7 @@ "message": "於瀏覽器重新整理時" }, "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": { - "message": "設定一個解鎖方式來變更您的密碼庫逾時動作。" + "message": "設定解鎖方式,以變更逾時後的行為" }, "leaveConfirmationDialogTitle": { "message": "確定要離開嗎?" @@ -12542,16 +12679,16 @@ "message": "完整的線上安全防護" }, "updatePayment": { - "message": "Update payment" + "message": "更新付款方式" }, "weCouldNotProcessYourPayment": { - "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + "message": "我們無法處理您的付款。請更新付款方式,或聯絡支援團隊以取得協助。" }, "yourSubscriptionHasExpired": { - "message": "Your subscription has expired. Please contact the support team for assistance." + "message": "您的訂閱已到期。請聯絡支援團隊以取得協助。" }, "yourSubscriptionIsScheduledToCancel": { - "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "message": "您的訂閱預計將於 $DATE$ 取消。您可在此之前隨時恢復訂閱。", "placeholders": { "date": { "content": "$1", @@ -12560,10 +12697,10 @@ } }, "premiumShareEvenMore": { - "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + "message": "透過家庭方案分享更多內容,或使用 Teams 或 Enterprise 享有強大且值得信賴的密碼安全防護。" }, "youHaveAGracePeriod": { - "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "message": "自訂閱到期日起,你有 $DAYS$ 天的寬限期可維持訂閱。請於 $SUSPENSION_DATE$ 前結清逾期發票。", "placeholders": { "days": { "content": "$1", @@ -12576,31 +12713,31 @@ } }, "manageInvoices": { - "message": "Manage invoices" + "message": "管理帳單" }, "yourNextChargeIsFor": { - "message": "Your next charge is for" + "message": "您的下一筆收費項目為" }, "dueOn": { - "message": "due on" + "message": "到期日" }, "yourSubscriptionWillBeSuspendedOn": { - "message": "Your subscription will be suspended on" + "message": "您的訂閱將於以下日期暫停" }, "yourSubscriptionWasSuspendedOn": { - "message": "Your subscription was suspended on" + "message": "您的訂閱已於以下日期暫停" }, "yourSubscriptionWillBeCanceledOn": { - "message": "Your subscription will be canceled on" + "message": "您的訂閱將於以下日期取消" }, "yourSubscriptionWasCanceledOn": { - "message": "Your subscription was canceled on" + "message": "您的訂閱已於以下日期取消" }, "storageFull": { - "message": "Storage full" + "message": "儲存空間已滿" }, "storageUsedDescription": { - "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "message": "您已使用 $AVAILABLE$ GB 加密檔案儲存空間中的 $USED$ GB。", "placeholders": { "used": { "content": "$1", @@ -12613,6 +12750,49 @@ } }, "storageFullDescription": { - "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + "message": "您已用完全部 $GB$ GB 的加密儲存空間。如需繼續儲存檔案,請增加儲存空間。" + }, + "whoCanView": { + "message": "誰可以檢視" + }, + "specificPeople": { + "message": "特定人員" + }, + "emailVerificationDesc": { + "message": "分享此 Send 連結後,收件者需使用驗證碼驗證其電子郵件,才能檢視此 Send。" + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "請以逗號分隔輸入多個電子郵件地址。" + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, + "whenYouRemoveStorage": { + "message": "當您移除儲存空間時,將會獲得按比例計算的帳戶抵扣金額,並自動套用至下一期帳單。" + }, + "ownerBadgeA11yDescription": { + "message": "擁有者:$OWNER$,顯示所有由 $OWNER$ 擁有的項目", + "placeholders": { + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "youHavePremium": { + "message": "您已擁有進階版" + }, + "emailProtected": { + "message": "電子郵件已受保護" + }, + "invalidSendPassword": { + "message": "Send 密碼無效" + }, + "sendPasswordHelperText": { + "message": "對方必須輸入密碼才能檢視此 Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "perUser": { + "message": "每位使用者" } -} \ No newline at end of file +} diff --git a/apps/web/tsconfig.build.json b/apps/web/tsconfig.build.json index 273cddb21d2..c1e7a88f4a8 100644 --- a/apps/web/tsconfig.build.json +++ b/apps/web/tsconfig.build.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig.json", "files": ["src/polyfills.ts", "src/main.ts", "src/theme.ts"], - "include": ["src/connectors/*.ts"] + "include": ["src/connectors/*.ts", "src/connectors/platform/*.ts"] } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index fd655b0a56b..6bfa9c8703b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -4,5 +4,10 @@ "strictTemplates": true }, "files": ["src/polyfills.ts", "src/main.ts", "src/theme.ts"], - "include": ["src/connectors/*.ts", "src/**/*.stories.ts", "src/**/*.spec.ts"] + "include": [ + "src/connectors/*.ts", + "src/connectors/platform/*.ts", + "src/**/*.stories.ts", + "src/**/*.spec.ts" + ] } diff --git a/apps/web/webpack.base.js b/apps/web/webpack.base.js index cc17b3b7cfd..42e4eec684c 100644 --- a/apps/web/webpack.base.js +++ b/apps/web/webpack.base.js @@ -113,6 +113,7 @@ module.exports.buildConfig = function buildConfig(params) { }, { test: /\.[cm]?js$/, + exclude: /\.wasm\.js$/, use: [ { loader: "babel-loader", @@ -166,6 +167,11 @@ module.exports.buildConfig = function buildConfig(params) { filename: "duo-redirect-connector.html", chunks: ["connectors/duo-redirect", "styles"], }), + new HtmlWebpackPlugin({ + template: path.resolve(__dirname, "src/connectors/platform/proxy-cookie-redirect.html"), + filename: "proxy-cookie-redirect-connector.html", + chunks: ["connectors/platform/proxy-cookie-redirect", "styles"], + }), new HtmlWebpackPlugin({ template: path.resolve(__dirname, "src/404.html"), filename: "404.html", @@ -319,7 +325,7 @@ module.exports.buildConfig = function buildConfig(params) { https://*.paypal.com https://www.paypalobjects.com https://q.stripe.com - https://haveibeenpwned.com + https://logos.haveibeenpwned.com ;media-src 'self' https://assets.bitwarden.com @@ -403,6 +409,10 @@ module.exports.buildConfig = function buildConfig(params) { "connectors/sso": path.resolve(__dirname, "src/connectors/sso.ts"), "connectors/duo-redirect": path.resolve(__dirname, "src/connectors/duo-redirect.ts"), "connectors/redirect": path.resolve(__dirname, "src/connectors/redirect.ts"), + "connectors/platform/proxy-cookie-redirect": path.resolve( + __dirname, + "src/connectors/platform/proxy-cookie-redirect.ts", + ), styles: [ path.resolve(__dirname, "src/scss/styles.scss"), path.resolve(__dirname, "src/scss/tailwind.css"), diff --git a/bitwarden_license/bit-cli/src/bit-serve-configurator.ts b/bitwarden_license/bit-cli/src/bit-serve-configurator.ts index 71df651d9d0..5a00dccb59b 100644 --- a/bitwarden_license/bit-cli/src/bit-serve-configurator.ts +++ b/bitwarden_license/bit-cli/src/bit-serve-configurator.ts @@ -1,4 +1,4 @@ -import * as koaRouter from "@koa/router"; +import { Router } from "@koa/router"; import { OssServeConfigurator } from "@bitwarden/cli/oss-serve-configurator"; @@ -16,7 +16,7 @@ export class BitServeConfigurator extends OssServeConfigurator { super(serviceContainer); } - override async configureRouter(router: koaRouter): Promise { + override async configureRouter(router: Router): Promise { // Register OSS endpoints await super.configureRouter(router); @@ -24,7 +24,7 @@ export class BitServeConfigurator extends OssServeConfigurator { this.serveDeviceApprovals(router); } - private serveDeviceApprovals(router: koaRouter) { + private serveDeviceApprovals(router: Router) { router.get("/device-approval/:organizationId", async (ctx, next) => { if (await this.errorIfLocked(ctx.response)) { await next(); diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts index 275ff82e9bd..2f3a2634129 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts @@ -3,15 +3,21 @@ import { OrganizationIntegrationServiceName } from "../organization-integration- export class HecConfiguration implements OrgIntegrationConfiguration { uri: string; - scheme = "Bearer"; + scheme: string; token: string; service?: string; bw_serviceName: OrganizationIntegrationServiceName; - constructor(uri: string, token: string, bw_serviceName: OrganizationIntegrationServiceName) { + constructor( + uri: string, + token: string, + bw_serviceName: OrganizationIntegrationServiceName, + scheme: string = "Bearer", + ) { this.uri = uri; this.token = token; this.bw_serviceName = bw_serviceName; + this.scheme = scheme; } toString(): string { diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.spec.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.spec.ts new file mode 100644 index 00000000000..6d7fad66f0e --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.spec.ts @@ -0,0 +1,338 @@ +import { DatadogConfiguration } from "./configuration/datadog-configuration"; +import { HecConfiguration } from "./configuration/hec-configuration"; +import { OrgIntegrationBuilder } from "./integration-builder"; +import { DatadogTemplate } from "./integration-configuration-config/configuration-template/datadog-template"; +import { HecTemplate } from "./integration-configuration-config/configuration-template/hec-template"; +import { OrganizationIntegrationServiceName } from "./organization-integration-service-type"; +import { OrganizationIntegrationType } from "./organization-integration-type"; + +describe("OrgIntegrationBuilder", () => { + describe("buildHecConfiguration", () => { + const testUri = "https://hec.example.com:8088/services/collector"; + const testToken = "test-token"; + + it("should create HecConfiguration with correct values", () => { + const config = OrgIntegrationBuilder.buildHecConfiguration( + testUri, + testToken, + OrganizationIntegrationServiceName.Huntress, + ); + + expect(config).toBeInstanceOf(HecConfiguration); + expect((config as HecConfiguration).uri).toBe(testUri); + expect((config as HecConfiguration).token).toBe(testToken); + expect(config.bw_serviceName).toBe(OrganizationIntegrationServiceName.Huntress); + }); + + it("should use default Bearer scheme", () => { + const config = OrgIntegrationBuilder.buildHecConfiguration( + testUri, + testToken, + OrganizationIntegrationServiceName.Huntress, + ); + + expect((config as HecConfiguration).scheme).toBe("Bearer"); + }); + + it("should use custom scheme when provided", () => { + const config = OrgIntegrationBuilder.buildHecConfiguration( + testUri, + testToken, + OrganizationIntegrationServiceName.CrowdStrike, + "Splunk", + ); + + expect((config as HecConfiguration).scheme).toBe("Splunk"); + }); + + it("should work with CrowdStrike service name", () => { + const config = OrgIntegrationBuilder.buildHecConfiguration( + testUri, + testToken, + OrganizationIntegrationServiceName.CrowdStrike, + ); + + expect(config.bw_serviceName).toBe(OrganizationIntegrationServiceName.CrowdStrike); + }); + }); + + describe("buildHecTemplate", () => { + it("should create HecTemplate with correct values", () => { + const template = OrgIntegrationBuilder.buildHecTemplate( + "main", + OrganizationIntegrationServiceName.Huntress, + ); + + expect(template).toBeInstanceOf(HecTemplate); + expect((template as HecTemplate).index).toBe("main"); + expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Huntress); + }); + + it("should handle empty index", () => { + const template = OrgIntegrationBuilder.buildHecTemplate( + "", + OrganizationIntegrationServiceName.Huntress, + ); + + expect((template as HecTemplate).index).toBe(""); + }); + }); + + describe("buildDataDogConfiguration", () => { + const testUri = "https://http-intake.logs.datadoghq.com/api/v2/logs"; + const testApiKey = "test-api-key"; + + it("should create DatadogConfiguration with correct values", () => { + const config = OrgIntegrationBuilder.buildDataDogConfiguration(testUri, testApiKey); + + expect(config).toBeInstanceOf(DatadogConfiguration); + expect((config as DatadogConfiguration).uri).toBe(testUri); + expect((config as DatadogConfiguration).apiKey).toBe(testApiKey); + }); + + it("should always use Datadog service name", () => { + const config = OrgIntegrationBuilder.buildDataDogConfiguration(testUri, testApiKey); + + expect(config.bw_serviceName).toBe(OrganizationIntegrationServiceName.Datadog); + }); + }); + + describe("buildDataDogTemplate", () => { + it("should create DatadogTemplate with correct service name", () => { + const template = OrgIntegrationBuilder.buildDataDogTemplate( + OrganizationIntegrationServiceName.Datadog, + ); + + expect(template).toBeInstanceOf(DatadogTemplate); + expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Datadog); + }); + }); + + describe("buildConfiguration", () => { + describe("HEC type", () => { + it("should build HecConfiguration from JSON string", () => { + const json = JSON.stringify({ + Uri: "https://hec.example.com", + Token: "test-token", + Scheme: "Bearer", + bw_serviceName: OrganizationIntegrationServiceName.Huntress, + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + expect(config).toBeInstanceOf(HecConfiguration); + expect((config as HecConfiguration).uri).toBe("https://hec.example.com"); + expect((config as HecConfiguration).token).toBe("test-token"); + expect((config as HecConfiguration).scheme).toBe("Bearer"); + }); + + it("should normalize PascalCase properties to camelCase", () => { + const json = JSON.stringify({ + Uri: "https://hec.example.com", + Token: "test-token", + Scheme: "Splunk", + bw_serviceName: OrganizationIntegrationServiceName.CrowdStrike, + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + expect((config as HecConfiguration).uri).toBe("https://hec.example.com"); + expect((config as HecConfiguration).token).toBe("test-token"); + expect((config as HecConfiguration).scheme).toBe("Splunk"); + }); + }); + + describe("Datadog type", () => { + it("should build DatadogConfiguration from JSON string", () => { + const json = JSON.stringify({ + Uri: "https://datadoghq.com/api", + ApiKey: "dd-api-key", + bw_serviceName: OrganizationIntegrationServiceName.Datadog, + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Datadog, + json, + ); + + expect(config).toBeInstanceOf(DatadogConfiguration); + expect((config as DatadogConfiguration).uri).toBe("https://datadoghq.com/api"); + expect((config as DatadogConfiguration).apiKey).toBe("dd-api-key"); + }); + }); + + describe("error handling", () => { + it("should throw for unsupported integration type", () => { + const json = JSON.stringify({ uri: "test" }); + + expect(() => + OrgIntegrationBuilder.buildConfiguration(999 as OrganizationIntegrationType, json), + ).toThrow("Unsupported integration type: 999"); + }); + + it("should throw for invalid JSON", () => { + expect(() => + OrgIntegrationBuilder.buildConfiguration(OrganizationIntegrationType.Hec, "invalid-json"), + ).toThrow("Invalid integration configuration: JSON parse error"); + }); + + it("should handle empty JSON string by using empty object", () => { + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + "", + ); + + expect(config).toBeInstanceOf(HecConfiguration); + }); + + it("should handle undefined values in JSON", () => { + const json = JSON.stringify({}); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + expect(config).toBeInstanceOf(HecConfiguration); + expect((config as HecConfiguration).uri).toBeUndefined(); + }); + }); + }); + + describe("buildTemplate", () => { + describe("HEC type", () => { + it("should build HecTemplate from JSON string", () => { + const json = JSON.stringify({ + index: "main", + bw_serviceName: OrganizationIntegrationServiceName.Huntress, + }); + + const template = OrgIntegrationBuilder.buildTemplate(OrganizationIntegrationType.Hec, json); + + expect(template).toBeInstanceOf(HecTemplate); + expect((template as HecTemplate).index).toBe("main"); + expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Huntress); + }); + + it("should normalize PascalCase properties", () => { + const json = JSON.stringify({ + Index: "security", + bw_serviceName: OrganizationIntegrationServiceName.CrowdStrike, + }); + + const template = OrgIntegrationBuilder.buildTemplate(OrganizationIntegrationType.Hec, json); + + expect((template as HecTemplate).index).toBe("security"); + }); + }); + + describe("Datadog type", () => { + it("should build DatadogTemplate from JSON string", () => { + const json = JSON.stringify({ + bw_serviceName: OrganizationIntegrationServiceName.Datadog, + }); + + const template = OrgIntegrationBuilder.buildTemplate( + OrganizationIntegrationType.Datadog, + json, + ); + + expect(template).toBeInstanceOf(DatadogTemplate); + expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Datadog); + }); + }); + + describe("error handling", () => { + it("should throw for unsupported integration type", () => { + const json = JSON.stringify({ index: "test" }); + + expect(() => + OrgIntegrationBuilder.buildTemplate(999 as OrganizationIntegrationType, json), + ).toThrow("Unsupported integration type: 999"); + }); + + it("should throw for invalid JSON", () => { + expect(() => + OrgIntegrationBuilder.buildTemplate(OrganizationIntegrationType.Hec, "invalid-json"), + ).toThrow("Invalid integration configuration: JSON parse error"); + }); + }); + }); + + describe("property case normalization", () => { + it("should convert first character to lowercase", () => { + const json = JSON.stringify({ + Uri: "https://example.com", + Token: "token", + Scheme: "Bearer", + bw_serviceName: "Huntress", + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + // Verify the properties were normalized (accessed via camelCase) + expect((config as HecConfiguration).uri).toBe("https://example.com"); + expect((config as HecConfiguration).token).toBe("token"); + }); + + it("should handle nested objects", () => { + // Using Datadog type which has nested enrichment_details + const json = JSON.stringify({ + Uri: "https://datadoghq.com", + ApiKey: "key", + NestedObject: { + InnerProperty: "value", + }, + }); + + // This tests that nested properties are also normalized + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Datadog, + json, + ); + + expect(config).toBeInstanceOf(DatadogConfiguration); + }); + + it("should handle arrays", () => { + const json = JSON.stringify({ + Uri: "https://example.com", + Token: "token", + Items: [{ Name: "item1" }, { Name: "item2" }], + bw_serviceName: "Huntress", + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + expect(config).toBeInstanceOf(HecConfiguration); + }); + + it("should preserve properties that start with lowercase", () => { + const json = JSON.stringify({ + uri: "https://example.com", + token: "token", + bw_serviceName: "Huntress", + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + expect((config as HecConfiguration).uri).toBe("https://example.com"); + expect((config as HecConfiguration).token).toBe("token"); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts index db682d58db4..e95f1f0ddf6 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts @@ -21,6 +21,11 @@ export interface OrgIntegrationTemplate { toString(): string; } +export const Schemas = { + Bearer: "Bearer", + Splunk: "Splunk", +} as const; + /** * Builder class for creating organization integration configurations and templates */ @@ -29,8 +34,9 @@ export class OrgIntegrationBuilder { uri: string, token: string, bw_serviceName: OrganizationIntegrationServiceName, + scheme: string = Schemas.Bearer, ): OrgIntegrationConfiguration { - return new HecConfiguration(uri, token, bw_serviceName); + return new HecConfiguration(uri, token, bw_serviceName, scheme); } static buildHecTemplate( @@ -57,7 +63,12 @@ export class OrgIntegrationBuilder { switch (type) { case OrganizationIntegrationType.Hec: { const hecConfig = this.convertToJson(configuration); - return this.buildHecConfiguration(hecConfig.uri, hecConfig.token, hecConfig.bw_serviceName); + return this.buildHecConfiguration( + hecConfig.uri, + hecConfig.token, + hecConfig.bw_serviceName, + hecConfig.scheme, + ); } case OrganizationIntegrationType.Datadog: { const datadogConfig = this.convertToJson(configuration); diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts index 27d71f29e59..3c0cf3b9b35 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts @@ -2,8 +2,6 @@ import { OrgIntegrationTemplate } from "../../integration-builder"; import { OrganizationIntegrationServiceName } from "../../organization-integration-service-type"; export class HecTemplate implements OrgIntegrationTemplate { - event = "#EventMessage#"; - source = "Bitwarden"; index: string; bw_serviceName: OrganizationIntegrationServiceName; @@ -12,12 +10,46 @@ export class HecTemplate implements OrgIntegrationTemplate { this.bw_serviceName = service; } - toString(): string { - return JSON.stringify({ - Event: this.event, - Source: this.source, - Index: this.index, + private toJSON() { + const template: Record = { bw_serviceName: this.bw_serviceName, - }); + source: "bitwarden", + service: "event-logs", + event: { + object: "event", + type: "#Type#", + itemId: "#CipherId#", + collectionId: "#CollectionId#", + groupId: "#GroupId#", + policyId: "#PolicyId#", + memberId: "#UserId#", + actingUserId: "#ActingUserId#", + installationId: "#InstallationId#", + date: "#DateIso8601#", + device: "#DeviceType#", + ipAddress: "#IpAddress#", + secretId: "#SecretId#", + projectId: "#ProjectId#", + serviceAccountId: "#ServiceAccountId#", + actingUserName: "#ActingUserName#", + actingUserEmail: "#ActingUserEmail#", + actingUserType: "#ActingUserType#", + userName: "#UserName#", + userEmail: "#UserEmail#", + userType: "#UserType#", + groupName: "#GroupName#", + }, + }; + + // Only include index if it's provided + if (this.index && this.index.trim() !== "") { + template.index = this.index; + } + + return template; + } + + toString(): string { + return JSON.stringify(this.toJSON()); } } diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts index 9634ad7249a..5c4b851e7b1 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts @@ -1,6 +1,7 @@ export const OrganizationIntegrationServiceName = Object.freeze({ CrowdStrike: "CrowdStrike", Datadog: "Datadog", + Huntress: "Huntress", } as const); export type OrganizationIntegrationServiceName = diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api/member-details.api.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api/member-details.api.ts new file mode 100644 index 00000000000..7d43382bd1a --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api/member-details.api.ts @@ -0,0 +1,33 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { MemberDetailsData } from "../data/member-details.data"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { MemberDetails } from "../domain/member-details"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { MemberDetailsView } from "../view/member-details.view"; + +/** + * Converts a MemberDetails API response + * + * - See {@link MemberDetails} for domain model + * - See {@link MemberDetailsData} for data model + * - See {@link MemberDetailsView} from View Model + */ +export class MemberDetailsApi extends BaseResponse { + userGuid: string = ""; + userName: string = ""; + email: string = ""; + cipherId: string = ""; + + constructor(data: any = null) { + super(data); + if (data == null) { + return; + } + this.userGuid = this.getResponseProperty("userGuid"); + this.userName = this.getResponseProperty("userName"); + this.email = this.getResponseProperty("email"); + this.cipherId = this.getResponseProperty("cipherId"); + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api/risk-insights-application.api.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api/risk-insights-application.api.ts new file mode 100644 index 00000000000..9ba86d59815 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api/risk-insights-application.api.ts @@ -0,0 +1,32 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsApplicationData } from "../data/risk-insights-application.data"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsApplication } from "../domain/risk-insights-application"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsApplicationView } from "../view/risk-insights-application.view"; + +/** + * Converts a RiskInsightsApplication API response + * + * - See {@link RiskInsightsApplication} for domain model + * - See {@link RiskInsightsApplicationData} for data model + * - See {@link RiskInsightsApplicationView} from View Model + */ +export class RiskInsightsApplicationApi extends BaseResponse { + applicationName: string = ""; + isCritical: boolean = false; + reviewedDate: string | undefined; + + constructor(data: any) { + super(data); + if (data == null) { + return; + } + + this.applicationName = this.getResponseProperty("applicationName"); + this.isCritical = this.getResponseProperty("isCritical") ?? false; + this.reviewedDate = this.getResponseProperty("reviewedDate"); + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api/risk-insights-report.api.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api/risk-insights-report.api.ts new file mode 100644 index 00000000000..0a606feb2a1 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api/risk-insights-report.api.ts @@ -0,0 +1,53 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsReportData } from "../data/risk-insights-report.data"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsReport } from "../domain/risk-insights-report"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsReportView } from "../view/risk-insights-report.view"; + +import { MemberDetailsApi } from "./member-details.api"; + +/** + * Converts a RiskInsightsReport API response + * + * - See {@link RiskInsightsReport} for domain model + * - See {@link RiskInsightsReportData} for data model + * - See {@link RiskInsightsReportView} from View Model + */ +export class RiskInsightsReportApi extends BaseResponse { + applicationName: string = ""; + passwordCount: number = 0; + atRiskPasswordCount: number = 0; + atRiskCipherIds: string[] = []; + memberCount: number = 0; + atRiskMemberCount: number = 0; + memberDetails: MemberDetailsApi[] = []; + atRiskMemberDetails: MemberDetailsApi[] = []; + cipherIds: string[] = []; + + constructor(data: any) { + super(data); + if (data == null) { + return; + } + + this.applicationName = this.getResponseProperty("applicationName"); + this.passwordCount = this.getResponseProperty("passwordCount"); + this.atRiskPasswordCount = this.getResponseProperty("atRiskPasswordCount"); + this.atRiskCipherIds = this.getResponseProperty("atRiskCipherIds"); + this.memberCount = this.getResponseProperty("memberCount"); + this.atRiskMemberCount = this.getResponseProperty("atRiskMemberCount"); + this.cipherIds = this.getResponseProperty("cipherIds"); + + const memberDetails = this.getResponseProperty("memberDetails"); + if (memberDetails != null) { + this.memberDetails = memberDetails.map((f: any) => new MemberDetailsApi(f)); + } + const atRiskMemberDetails = this.getResponseProperty("atRiskMemberDetails"); + if (atRiskMemberDetails != null) { + this.atRiskMemberDetails = atRiskMemberDetails.map((f: any) => new MemberDetailsApi(f)); + } + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api/risk-insights-summary.api.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api/risk-insights-summary.api.ts new file mode 100644 index 00000000000..06d175f6371 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api/risk-insights-summary.api.ts @@ -0,0 +1,42 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsSummaryData } from "../data/risk-insights-summary.data"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsSummary } from "../domain/risk-insights-summary"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsSummaryView } from "../view/risk-insights-summary.view"; + +/** + * Converts a RiskInsightsSummary API response + * + * - See {@link RiskInsightsSummary} for domain model + * - See {@link RiskInsightsSummaryData} for data model + * - See {@link RiskInsightsSummaryView} from View Model + */ +export class RiskInsightsSummaryApi extends BaseResponse { + totalMemberCount: number = 0; + totalApplicationCount: number = 0; + totalAtRiskMemberCount: number = 0; + totalAtRiskApplicationCount: number = 0; + totalCriticalApplicationCount: number = 0; + totalCriticalMemberCount: number = 0; + totalCriticalAtRiskMemberCount: number = 0; + totalCriticalAtRiskApplicationCount: number = 0; + + constructor(data: any) { + super(data); + + this.totalMemberCount = this.getResponseProperty("totalMemberCount") || 0; + this.totalApplicationCount = this.getResponseProperty("totalApplicationCount") || 0; + this.totalAtRiskMemberCount = this.getResponseProperty("totalAtRiskMemberCount") || 0; + this.totalAtRiskApplicationCount = this.getResponseProperty("totalAtRiskApplicationCount") || 0; + this.totalCriticalApplicationCount = + this.getResponseProperty("totalCriticalApplicationCount") || 0; + this.totalCriticalMemberCount = this.getResponseProperty("totalCriticalMemberCount") || 0; + this.totalCriticalAtRiskMemberCount = + this.getResponseProperty("totalCriticalAtRiskMemberCount") || 0; + this.totalCriticalAtRiskApplicationCount = + this.getResponseProperty("totalCriticalAtRiskApplicationCount") || 0; + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api/risk-insights.api.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api/risk-insights.api.ts new file mode 100644 index 00000000000..30ea5c745a2 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api/risk-insights.api.ts @@ -0,0 +1,56 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsData } from "../data/risk-insights.data"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsights } from "../domain/risk-insights"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsView } from "../view/risk-insights.view"; + +/** + * Converts a RiskInsights API response + * + * - See {@link RiskInsights} for domain model + * - See {@link RiskInsightsData} for data model + * - See {@link RiskInsightsView} from View Model + */ +// [TODO] To replace GetRiskInsightsReportResponse +export class RiskInsightsApi extends BaseResponse { + id: string = ""; + organizationId: string = ""; + reports: string = ""; + applications: string = ""; + summary: string = ""; + creationDate: string = ""; + contentEncryptionKey: string = ""; + + constructor(data: any = null) { + super(data); + if (data == null) { + return; + } + + this.id = this.getResponseProperty("id"); + this.organizationId = this.getResponseProperty("organizationId"); + this.creationDate = this.getResponseProperty("creationDate"); + this.reports = this.getResponseProperty("reportData"); + this.applications = this.getResponseProperty("applicationData"); + this.summary = this.getResponseProperty("summaryData"); + this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey"); + + // Use when individual values are encrypted + // const summary = this.getResponseProperty("summaryData"); + // if (summary != null) { + // this.summary = new RiskInsightsSummaryApi(summary); + // } + + // const reports = this.getResponseProperty("reportData"); + // if (reports != null) { + // this.reports = reports.map((r: any) => new RiskInsightsReportApi(r)); + // } + // const applications = this.getResponseProperty("applicationData"); + // if (applications != null) { + // this.applications = applications.map((f: any) => new RiskInsightsApplicationApi(f)); + // } + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/data/member-details.data.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/data/member-details.data.ts new file mode 100644 index 00000000000..16657b8151a --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/data/member-details.data.ts @@ -0,0 +1,30 @@ +import { MemberDetailsApi } from "../api/member-details.api"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { MemberDetails } from "../domain/member-details"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { MemberDetailsView } from "../view/member-details.view"; + +/** + * Serializable data model for member details in risk insights report + * + * - See {@link MemberDetails} for domain model + * - See {@link MemberDetailsApi} for API model + * - See {@link MemberDetailsView} from View Model + */ +export class MemberDetailsData { + userGuid: string = ""; + userName: string = ""; + email: string = ""; + cipherId: string = ""; + + constructor(data?: MemberDetailsApi) { + if (data == null) { + return; + } + + this.userGuid = data.userGuid; + this.userName = data.userName; + this.email = data.email; + this.cipherId = data.cipherId; + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/data/risk-insights-application.data.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/data/risk-insights-application.data.ts new file mode 100644 index 00000000000..849f83b521a --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/data/risk-insights-application.data.ts @@ -0,0 +1,29 @@ +import { RiskInsightsApplicationApi } from "../api/risk-insights-application.api"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsApplication } from "../domain/risk-insights-application"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsApplicationView } from "../view/risk-insights-application.view"; + +/** + * Serializable data model for Application data in risk insights report + * + * - See {@link RiskInsightsApplication} for domain model + * - See {@link RiskInsightsApplicationApi} for API model + * - See {@link RiskInsightsApplicationView} from View Model + */ + +export class RiskInsightsApplicationData { + applicationName: string = ""; + isCritical: boolean = false; + reviewedDate: string | undefined; + + constructor(data?: RiskInsightsApplicationApi) { + if (data == null) { + return; + } + + this.applicationName = data.applicationName; + this.isCritical = data.isCritical; + this.reviewedDate = data.reviewedDate; + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/data/risk-insights-report.data.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/data/risk-insights-report.data.ts new file mode 100644 index 00000000000..6a5ee2a86fa --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/data/risk-insights-report.data.ts @@ -0,0 +1,48 @@ +import { RiskInsightsReportApi } from "../api/risk-insights-report.api"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsReport } from "../domain/risk-insights-report"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsReportView } from "../view/risk-insights-report.view"; + +import { MemberDetailsData } from "./member-details.data"; + +/** + * Serializable data model for generated report in risk insights report + * + * - See {@link RiskInsightsReport} for domain model + * - See {@link RiskInsightsReportApi} for API model + * - See {@link RiskInsightsReportView} from View Model + */ +export class RiskInsightsReportData { + applicationName: string = ""; + passwordCount: number = 0; + atRiskPasswordCount: number = 0; + atRiskCipherIds: string[] = []; + memberCount: number = 0; + atRiskMemberCount: number = 0; + memberDetails: MemberDetailsData[] = []; + atRiskMemberDetails: MemberDetailsData[] = []; + cipherIds: string[] = []; + + constructor(data?: RiskInsightsReportApi) { + if (data == null) { + return; + } + this.applicationName = data.applicationName; + this.passwordCount = data.passwordCount; + this.atRiskPasswordCount = data.atRiskPasswordCount; + this.atRiskCipherIds = data.atRiskCipherIds; + this.memberCount = data.memberCount; + this.atRiskMemberCount = data.atRiskMemberCount; + this.memberDetails = data.memberDetails; + this.atRiskMemberDetails = data.atRiskMemberDetails; + this.cipherIds = data.cipherIds; + + if (data.memberDetails != null) { + this.memberDetails = data.memberDetails.map((m) => new MemberDetailsData(m)); + } + if (data.atRiskMemberDetails != null) { + this.atRiskMemberDetails = data.atRiskMemberDetails.map((m) => new MemberDetailsData(m)); + } + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/data/risk-insights-summary.data.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/data/risk-insights-summary.data.ts new file mode 100644 index 00000000000..895d0acd0ff --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/data/risk-insights-summary.data.ts @@ -0,0 +1,38 @@ +import { RiskInsightsSummaryApi } from "../api/risk-insights-summary.api"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsSummary } from "../domain/risk-insights-summary"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsSummaryView } from "../view/risk-insights-summary.view"; + +/** + * Serializable data model for report summary in risk insights report + * + * - See {@link RiskInsightsSummary} for domain model + * - See {@link RiskInsightsSummaryApi} for API model + * - See {@link RiskInsightsSummaryView} from View Model + */ +export class RiskInsightsSummaryData { + totalMemberCount: number = 0; + totalApplicationCount: number = 0; + totalAtRiskMemberCount: number = 0; + totalAtRiskApplicationCount: number = 0; + totalCriticalApplicationCount: number = 0; + totalCriticalMemberCount: number = 0; + totalCriticalAtRiskMemberCount: number = 0; + totalCriticalAtRiskApplicationCount: number = 0; + + constructor(data?: RiskInsightsSummaryApi) { + if (data == null) { + return; + } + + this.totalMemberCount = data.totalMemberCount; + this.totalApplicationCount = data.totalApplicationCount; + this.totalAtRiskMemberCount = data.totalAtRiskMemberCount; + this.totalAtRiskApplicationCount = data.totalAtRiskApplicationCount; + this.totalCriticalApplicationCount = data.totalCriticalApplicationCount; + this.totalCriticalMemberCount = data.totalCriticalMemberCount; + this.totalCriticalAtRiskMemberCount = data.totalCriticalAtRiskMemberCount; + this.totalCriticalAtRiskApplicationCount = data.totalCriticalAtRiskApplicationCount; + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/data/risk-insights.data.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/data/risk-insights.data.ts new file mode 100644 index 00000000000..5d83ebc3dfe --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/data/risk-insights.data.ts @@ -0,0 +1,49 @@ +import { RiskInsightsApi } from "../api/risk-insights.api"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsights } from "../domain/risk-insights"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsView } from "../view/risk-insights.view"; + +/** + * Serializable data model for member details in risk insights report + * + * - See {@link RiskInsights} for domain model + * - See {@link RiskInsightsApi} for API model + * - See {@link RiskInsightsView} from View Model + */ +export class RiskInsightsData { + id: string = ""; + organizationId: string = ""; + reports: string = ""; + applications: string = ""; + summary: string = ""; + // [TODO] Update types when individual values are encrypted instead of the entire object + // reports: RiskInsightsReportData[]; // Previously ApplicationHealthReportDetail Data type + // applications: RiskInsightsApplicationsData[]; // Previously OrganizationReportApplication Data type + // summary: RiskInsightsSummaryData; // Previously OrganizationReportSummary Data type + creationDate: string = ""; + contentEncryptionKey: string = ""; + + constructor(response?: RiskInsightsApi) { + if (response == null) { + return; + } + + this.id = response.id; + this.organizationId = response.organizationId; + this.reports = response.reports; + this.applications = response.applications; + this.summary = response.summary; + this.creationDate = response.creationDate; + this.contentEncryptionKey = response.contentEncryptionKey; + + // [TODO] Update types when individual values are encrypted instead of the entire object + // this.summary = new RiskInsightsSummaryData(response.summaryData); + // if (response.reports != null) { + // this.reports = response.reports.map((r) => new RiskInsightsReportData(r)); + // } + // if (response.applications != null) { + // this.applications = response.applications.map((a) => new RiskInsightsApplicationData(a)); + // } + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/domain/member-details.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/domain/member-details.ts new file mode 100644 index 00000000000..b5704c8f27d --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/domain/member-details.ts @@ -0,0 +1,34 @@ +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import Domain from "@bitwarden/common/platform/models/domain/domain-base"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { MemberDetailsApi } from "../api/member-details.api"; +import { MemberDetailsData } from "../data/member-details.data"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { MemberDetailsView } from "../view/member-details.view"; + +/** + * Domain model for Member Details in Risk Insights containing encrypted properties + * + * - See {@link MemberDetailsApi} for API model + * - See {@link MemberDetailsData} for data model + * - See {@link MemberDetailsView} from View Model + */ +export class MemberDetails extends Domain { + userGuid: EncString = new EncString(""); + userName: EncString = new EncString(""); + email: EncString = new EncString(""); + cipherId: EncString = new EncString(""); + + constructor(data?: MemberDetailsData) { + super(); + if (data == null) { + return; + } + + this.userGuid = new EncString(data.userGuid); + this.userName = new EncString(data.userName); + this.email = new EncString(data.email); + this.cipherId = new EncString(data.cipherId); + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/domain/risk-insights-application.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/domain/risk-insights-application.ts new file mode 100644 index 00000000000..b224a63f0ae --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/domain/risk-insights-application.ts @@ -0,0 +1,31 @@ +import Domain from "@bitwarden/common/platform/models/domain/domain-base"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsApplicationApi } from "../api/risk-insights-application.api"; +import { RiskInsightsApplicationData } from "../data/risk-insights-application.data"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsApplicationView } from "../view/risk-insights-application.view"; + +/** + * Domain model for Application data in Risk Insights containing encrypted properties + * + * - See {@link RiskInsightsApplicationApi} for API model + * - See {@link RiskInsightsApplicationData} for data model + * - See {@link RiskInsightsApplicationView} from View Model + */ +export class RiskInsightsApplication extends Domain { + applicationName: string = ""; // TODO: Encrypt? + isCritical: boolean = false; + reviewedDate?: Date; + + constructor(obj?: RiskInsightsApplicationData) { + super(); + if (obj == null) { + return; + } + + this.applicationName = obj.applicationName; + this.isCritical = obj.isCritical; + this.reviewedDate = obj.reviewedDate ? new Date(obj.reviewedDate) : undefined; + } +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/domain/risk-insights-report.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/domain/risk-insights-report.ts new file mode 100644 index 00000000000..16ee5307b4c --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/domain/risk-insights-report.ts @@ -0,0 +1,59 @@ +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import Domain from "@bitwarden/common/platform/models/domain/domain-base"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsReportApi } from "../api/risk-insights-report.api"; +import { RiskInsightsReportData } from "../data/risk-insights-report.data"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsReportView } from "../view/risk-insights-report.view"; + +import { MemberDetails } from "./member-details"; + +/** + * Domain model for generated report data in Risk Insights containing encrypted properties + * + * - See {@link RiskInsightsReportApi} for API model + * - See {@link RiskInsightsReportData} for data model + * - See {@link RiskInsightsReportView} from View Model + */ +export class RiskInsightsReport extends Domain { + applicationName: EncString = new EncString(""); + passwordCount: EncString = new EncString(""); + atRiskPasswordCount: EncString = new EncString(""); + atRiskCipherIds: string[] = []; + memberCount: EncString = new EncString(""); + atRiskMemberCount: EncString = new EncString(""); + memberDetails: MemberDetails[] = []; + atRiskMemberDetails: MemberDetails[] = []; + cipherIds: string[] = []; + + constructor(obj?: RiskInsightsReportData) { + super(); + if (obj == null) { + return; + } + this.applicationName = new EncString(obj.applicationName); + this.passwordCount = new EncString(obj.passwordCount); + this.atRiskPasswordCount = new EncString(obj.atRiskPasswordCount); + this.atRiskCipherIds = obj.atRiskCipherIds; + this.memberCount = new EncString(obj.memberCount); + this.atRiskMemberCount = new EncString(obj.atRiskMemberCount); + this.cipherIds = obj.cipherIds; + + if (obj.memberDetails != null) { + this.memberDetails = obj.memberDetails.map((m) => new MemberDetails(m)); + } + if (obj.atRiskMemberDetails != null) { + this.atRiskMemberDetails = obj.atRiskMemberDetails.map((m) => new MemberDetails(m)); + } + } + + // [TODO] Domain level methods + // static fromJSON(): RiskInsightsReport {} + // decrypt(): RiskInsightsReportView {} + // toData(): RiskInsightsReportData {} + + // [TODO] SDK Mapping + // toSdkRiskInsightsReport(): SdkRiskInsightsReport {} + // static fromSdkRiskInsightsReport(obj?: SdkRiskInsightsReport): RiskInsightsReport | undefined {} +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/domain/risk-insights-summary.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/domain/risk-insights-summary.ts new file mode 100644 index 00000000000..712c488d54e --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/domain/risk-insights-summary.ts @@ -0,0 +1,50 @@ +import Domain from "@bitwarden/common/platform/models/domain/domain-base"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsSummaryApi } from "../api/risk-insights-summary.api"; +import { RiskInsightsSummaryData } from "../data/risk-insights-summary.data"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsSummaryView } from "../view/risk-insights-summary.view"; + +/** + * Domain model for Member Details in Risk Insights containing encrypted properties + * + * - See {@link RiskInsightsSummaryApi} for API model + * - See {@link RiskInsightsSummaryData} for data model + * - See {@link RiskInsightsSummaryView} from View Model + */ +export class RiskInsightsSummary extends Domain { + totalMemberCount: number = 0; + totalApplicationCount: number = 0; + totalAtRiskMemberCount: number = 0; + totalAtRiskApplicationCount: number = 0; + totalCriticalApplicationCount: number = 0; + totalCriticalMemberCount: number = 0; + totalCriticalAtRiskMemberCount: number = 0; + totalCriticalAtRiskApplicationCount: number = 0; + + constructor(obj?: RiskInsightsSummaryData) { + super(); + if (obj == null) { + return; + } + + this.totalMemberCount = obj.totalMemberCount; + this.totalApplicationCount = obj.totalApplicationCount; + this.totalAtRiskMemberCount = obj.totalAtRiskMemberCount; + this.totalAtRiskApplicationCount = obj.totalAtRiskApplicationCount; + this.totalCriticalApplicationCount = obj.totalCriticalApplicationCount; + this.totalCriticalMemberCount = obj.totalCriticalMemberCount; + this.totalCriticalAtRiskMemberCount = obj.totalCriticalAtRiskMemberCount; + this.totalCriticalAtRiskApplicationCount = obj.totalCriticalAtRiskApplicationCount; + } + + // [TODO] Domain level methods + // static fromJSON(): RiskInsightsSummary {} + // decrypt(): RiskInsightsSummaryView {} + // toData(): RiskInsightsSummaryData {} + + // [TODO] SDK Mapping + // toSdkRiskInsightsReport(): SdkRiskInsightsReport {} + // static fromSdkRiskInsightsReport(obj?: SdkRiskInsightsReport): RiskInsightsReport | undefined {} +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/domain/risk-insights.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/domain/risk-insights.ts new file mode 100644 index 00000000000..fe1a277d62f --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/domain/risk-insights.ts @@ -0,0 +1,49 @@ +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import Domain from "@bitwarden/common/platform/models/domain/domain-base"; +import { conditionalEncString } from "@bitwarden/common/vault/utils/domain-utils"; + +import { RiskInsightsData } from "../data/risk-insights.data"; + +export class RiskInsights extends Domain { + id: string = ""; + organizationId: string = ""; + reports: EncString = new EncString(""); + applications: EncString = new EncString(""); + summary: EncString = new EncString(""); + creationDate: Date; + contentEncryptionKey?: EncString; + + constructor(obj?: RiskInsightsData) { + super(); + if (obj == null) { + this.creationDate = new Date(); + return; + } + this.id = obj.id; + this.organizationId = obj.organizationId; + this.reports = conditionalEncString(obj.reports) ?? new EncString(""); + this.applications = conditionalEncString(obj.applications) ?? new EncString(""); + this.summary = conditionalEncString(obj.summary) ?? new EncString(""); + this.creationDate = new Date(obj.creationDate); + this.contentEncryptionKey = conditionalEncString(obj.contentEncryptionKey); + + // Example usage when individual keys are encrypted instead of the entire object + // this.summary = new RiskInsightsSummary(obj.summary); + + // if (obj.reports != null) { + // this.reports = obj.reports.map((r) => new RiskInsightsReport(r)); + // } + // if (obj.applications != null) { + // this.applications = obj.applications.map((a) => new RiskInsightsApplication(a)); + // } + } + + // [TODO] Domain level methods + // static fromJSON(obj: Jsonify): RiskInsights {} + // decrypt() RiskInsightsView {} + // toData(): RiskInsightsData {} + + // [TODO] SDK Mapping + // toSdkRiskInsights(): SdkRiskInsights {} + // static fromSdkRiskInsights(obj?: SdkRiskInsights): RiskInsights | undefined {} +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/view/member-details.view.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/view/member-details.view.ts new file mode 100644 index 00000000000..7d4434a8a7e --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/view/member-details.view.ts @@ -0,0 +1,43 @@ +import { Jsonify } from "type-fest"; + +import { View } from "@bitwarden/common/models/view/view"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { MemberDetailsApi } from "../api/member-details.api"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { MemberDetailsData } from "../data/member-details.data"; +import { MemberDetails } from "../domain/member-details"; + +/** + * View model for Member Details in Risk Insights containing decrypted properties + * + * - See {@link MemberDetails} for domain model + * - See {@link MemberDetailsData} for data model + * - See {@link MemberDetailsApi} for API model + */ +export class MemberDetailsView implements View { + userGuid: string = ""; + userName: string = ""; + email: string = ""; + cipherId: string = ""; + + constructor(m?: MemberDetails) { + if (m == null) { + return; + } + } + + toJSON() { + return this; + } + + static fromJSON( + obj: Partial> | undefined, + ): MemberDetailsView | undefined { + return Object.assign(new MemberDetailsView(), obj); + } + + // [TODO] SDK Mapping + // toSdkMemberDetailsView(): SdkMemberDetailsView {} + // static fromMemberDetailsView(obj?: SdkMemberDetailsView): MemberDetailsView | undefined {} +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/view/risk-insights-application.view.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/view/risk-insights-application.view.ts new file mode 100644 index 00000000000..b79b735d458 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/view/risk-insights-application.view.ts @@ -0,0 +1,45 @@ +import { View } from "@bitwarden/common/models/view/view"; +import { DeepJsonify } from "@bitwarden/common/types/deep-jsonify"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsApplicationApi } from "../api/risk-insights-application.api"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsApplicationData } from "../data/risk-insights-application.data"; +import { RiskInsightsApplication } from "../domain/risk-insights-application"; + +/** + * View model for Application data in Risk Insights containing decrypted properties + * + * - See {@link RiskInsightsApplication} for domain model + * - See {@link RiskInsightsApplicationData} for data model + * - See {@link RiskInsightsApplicationApi} for API model + */ +export class RiskInsightsApplicationView implements View { + applicationName: string = ""; + isCritical = false; + reviewedDate?: Date; + + constructor(a?: RiskInsightsApplication) { + if (a == null) { + return; + } + + this.applicationName = a.applicationName; + this.isCritical = a.isCritical; + this.reviewedDate = a.reviewedDate; + } + + toJSON() { + return this; + } + + static fromJSON( + obj: Partial>, + ): RiskInsightsApplicationView { + return Object.assign(new RiskInsightsApplicationView(), obj); + } + + // [TODO] SDK Mapping + // toSdkRiskInsightsApplicationView(): SdkRiskInsightsApplicationView {} + // static fromRiskInsightsApplicationView(obj?: SdkRiskInsightsApplicationView): RiskInsightsApplicationView | undefined {} +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/view/risk-insights-report.view.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/view/risk-insights-report.view.ts new file mode 100644 index 00000000000..c4deb7fadd4 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/view/risk-insights-report.view.ts @@ -0,0 +1,64 @@ +import { View } from "@bitwarden/common/models/view/view"; +import { DeepJsonify } from "@bitwarden/common/types/deep-jsonify"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsReportApi } from "../api/risk-insights-report.api"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsReportData } from "../data/risk-insights-report.data"; +import { RiskInsightsReport } from "../domain/risk-insights-report"; + +import { MemberDetailsView } from "./member-details.view"; + +/** + * View model for Member Details in Risk Insights containing decrypted properties + * + * - See {@link RiskInsightsReport} for domain model + * - See {@link RiskInsightsReportData} for data model + * - See {@link RiskInsightsReportApi} for API model + */ +export class RiskInsightsReportView implements View { + applicationName: string = ""; + passwordCount: number = 0; + atRiskPasswordCount: number = 0; + atRiskCipherIds: string[] = []; + memberCount: number = 0; + atRiskMemberCount: number = 0; + memberDetails: MemberDetailsView[] = []; + atRiskMemberDetails: MemberDetailsView[] = []; + cipherIds: string[] = []; + + constructor(r?: RiskInsightsReport) { + if (r == null) { + return; + } + } + + toJSON() { + return this; + } + + static fromJSON( + obj: Partial> | undefined, + ): RiskInsightsReportView { + if (obj == undefined) { + return new RiskInsightsReportView(); + } + + const view = Object.assign(new RiskInsightsReportView(), obj) as RiskInsightsReportView; + + view.memberDetails = + obj.memberDetails + ?.map((m: any) => MemberDetailsView.fromJSON(m)) + .filter((m): m is MemberDetailsView => m !== undefined) ?? []; + view.atRiskMemberDetails = + obj.atRiskMemberDetails + ?.map((m: any) => MemberDetailsView.fromJSON(m)) + .filter((m): m is MemberDetailsView => m !== undefined) ?? []; + + return view; + } + + // [TODO] SDK Mapping + // toSdkRiskInsightsReportView(): SdkRiskInsightsReportView {} + // static fromRiskInsightsReportView(obj?: SdkRiskInsightsReportView): RiskInsightsReportView | undefined {} +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/view/risk-insights-summary.view.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/view/risk-insights-summary.view.ts new file mode 100644 index 00000000000..98fe76938c4 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/view/risk-insights-summary.view.ts @@ -0,0 +1,53 @@ +import { View } from "@bitwarden/common/models/view/view"; +import { DeepJsonify } from "@bitwarden/common/types/deep-jsonify"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsSummaryApi } from "../api/risk-insights-summary.api"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsSummaryData } from "../data/risk-insights-summary.data"; +import { RiskInsightsSummary } from "../domain/risk-insights-summary"; + +/** + * View model for Report Summary in Risk Insights containing decrypted properties + * + * - See {@link RiskInsightsSummary} for domain model + * - See {@link RiskInsightsSummaryData} for data model + * - See {@link RiskInsightsSummaryApi} for API model + */ +export class RiskInsightsSummaryView implements View { + totalMemberCount: number = 0; + totalApplicationCount: number = 0; + totalAtRiskMemberCount: number = 0; + totalAtRiskApplicationCount: number = 0; + totalCriticalApplicationCount: number = 0; + totalCriticalMemberCount: number = 0; + totalCriticalAtRiskMemberCount: number = 0; + totalCriticalAtRiskApplicationCount: number = 0; + + constructor(obj?: RiskInsightsSummary) { + if (obj == null) { + return; + } + + this.totalMemberCount = obj.totalMemberCount; + this.totalApplicationCount = obj.totalApplicationCount; + this.totalAtRiskMemberCount = obj.totalAtRiskMemberCount; + this.totalAtRiskApplicationCount = obj.totalAtRiskApplicationCount; + this.totalCriticalApplicationCount = obj.totalCriticalApplicationCount; + this.totalCriticalMemberCount = obj.totalCriticalMemberCount; + this.totalCriticalAtRiskMemberCount = obj.totalCriticalAtRiskMemberCount; + this.totalCriticalAtRiskApplicationCount = obj.totalCriticalAtRiskApplicationCount; + } + + toJSON() { + return this; + } + + static fromJSON(obj: Partial>): RiskInsightsSummaryView { + return Object.assign(new RiskInsightsSummaryView(), obj); + } + + // [TODO] SDK Mapping + // toSdkRiskInsightsSummaryView(): SdkRiskInsightsSummaryView {} + // static fromRiskInsightsSummaryView(obj?: SdkRiskInsightsSummaryView): RiskInsightsSummaryView | undefined {} +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/view/risk-insights.view.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/view/risk-insights.view.ts new file mode 100644 index 00000000000..7ce1fd28046 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/view/risk-insights.view.ts @@ -0,0 +1,65 @@ +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { View } from "@bitwarden/common/models/view/view"; +import { DeepJsonify } from "@bitwarden/common/types/deep-jsonify"; +import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsApi } from "../api/risk-insights.api"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RiskInsightsData } from "../data/risk-insights.data"; +import { RiskInsights } from "../domain/risk-insights"; + +import { RiskInsightsApplicationView } from "./risk-insights-application.view"; +import { RiskInsightsReportView } from "./risk-insights-report.view"; +import { RiskInsightsSummaryView } from "./risk-insights-summary.view"; + +/** + * View model for Member Details in Risk Insights containing decrypted properties + * + * - See {@link RiskInsights} for domain model + * - See {@link RiskInsightsData} for data model + * - See {@link RiskInsightsApi} for API model + */ +export class RiskInsightsView implements View { + id: OrganizationReportId = "" as OrganizationReportId; + organizationId: OrganizationId = "" as OrganizationId; + reports: RiskInsightsReportView[] = []; + applications: RiskInsightsApplicationView[] = []; + summary = new RiskInsightsSummaryView(); + creationDate: Date; + contentEncryptionKey?: EncString; + + constructor(report?: RiskInsights) { + if (!report) { + this.creationDate = new Date(); + return; + } + + this.id = report.id as OrganizationReportId; + this.organizationId = report.organizationId as OrganizationId; + this.creationDate = report.creationDate; + this.contentEncryptionKey = report.contentEncryptionKey; + } + + toJSON() { + return this; + } + + static fromJSON(obj: Partial> | null): RiskInsightsView { + if (obj == undefined) { + return new RiskInsightsView(); + } + + const view = Object.assign(new RiskInsightsView(), obj) as RiskInsightsView; + + view.reports = obj.reports?.map((report) => RiskInsightsReportView.fromJSON(report)) ?? []; + view.applications = obj.applications?.map((a) => RiskInsightsApplicationView.fromJSON(a)) ?? []; + view.summary = RiskInsightsSummaryView.fromJSON(obj.summary ?? {}); + + return view; + } + + // [TODO] SDK Mapping + // toSdkRiskInsightsView(): SdkRiskInsightsView {} + // static fromRiskInsightsView(obj?: SdkRiskInsightsView): RiskInsightsView | undefined {} +} diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html index a2b231ffd48..80e76acac1d 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html @@ -10,22 +10,34 @@ {{ "claimDomain" | i18n }} - - {{ data.orgDomain.domainName }} - - - {{ "domainStatusUnderVerification" | i18n }} + {{ "domainStatusPending" | i18n }} {{ "domainStatusClaimed" | i18n }}
+
+

{{ "automaticDomainClaimProcess1" | i18n }}

+

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

+
{{ "domainName" | i18n }} - {{ "claimDomainNameInputHint" | i18n }} @@ -40,14 +52,6 @@ (click)="copyDnsTxt()" > - - - {{ "automaticDomainClaimProcess" | i18n }} -
+ + +
+ + + {{ "all" | i18n }} + + {{ allCount }} + + + + {{ "invited" | i18n }} + + {{ invitedCount }} + + + + {{ "needsConfirmation" | i18n }} + + {{ acceptedCount }} + + + +
+ + + + {{ "loading" | i18n }} + + + +

{{ "noMembersInList" | i18n }}

+ + + {{ "providerUsersNeedConfirmed" | i18n }} + + + + + + + + + + {{ "name" | i18n }} + {{ "role" | i18n }} + + + + + + + + + + + + + + + + +
+ +
+
+ + + {{ "invited" | i18n }} + + + {{ "needsConfirmation" | i18n }} + + + {{ "revoked" | i18n }} + +
+
+ {{ user.email }} +
+
+
+ + + {{ "providerAdmin" | i18n }} + {{ "serviceUser" | i18n }} + + + + + + + + + + + +
+
+
+
+
diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts new file mode 100644 index 00000000000..e581bf458d2 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts @@ -0,0 +1,346 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { combineLatest, firstValueFrom, lastValueFrom, switchMap } from "rxjs"; +import { first, map } from "rxjs/operators"; + +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { ProviderUserStatusType, ProviderUserType } from "@bitwarden/common/admin-console/enums"; +import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request"; +import { ProviderUserConfirmRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-confirm.request"; +import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { assertNonNullish } from "@bitwarden/common/auth/utils"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { ProviderId } from "@bitwarden/common/types/guid"; +import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; +import { BaseMembersComponent } from "@bitwarden/web-vault/app/admin-console/common/base-members.component"; +import { + CloudBulkReinviteLimit, + MaxCheckedCount, + peopleFilter, + PeopleTableDataSource, +} from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source"; +import { openEntityEventsDialog } from "@bitwarden/web-vault/app/admin-console/organizations/manage/entity-events.component"; +import { BulkStatusComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-status.component"; +import { MemberActionResult } from "@bitwarden/web-vault/app/admin-console/organizations/members/services/member-actions/member-actions.service"; + +import { + AddEditMemberDialogComponent, + AddEditMemberDialogParams, + AddEditMemberDialogResultType, +} from "./dialogs/add-edit-member-dialog.component"; +import { BulkConfirmDialogComponent } from "./dialogs/bulk-confirm-dialog.component"; +import { BulkRemoveDialogComponent } from "./dialogs/bulk-remove-dialog.component"; + +type ProviderUser = ProviderUserUserDetailsResponse; + +class MembersTableDataSource extends PeopleTableDataSource { + protected statusType = ProviderUserStatusType; +} + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + templateUrl: "deprecated_members.component.html", + standalone: false, +}) +export class MembersComponent extends BaseMembersComponent { + accessEvents = false; + dataSource: MembersTableDataSource; + loading = true; + providerId: string; + rowHeight = 70; + rowHeightClass = `tw-h-[70px]`; + status: ProviderUserStatusType = null; + + userStatusType = ProviderUserStatusType; + userType = ProviderUserType; + + constructor( + apiService: ApiService, + keyService: KeyService, + dialogService: DialogService, + i18nService: I18nService, + logService: LogService, + organizationManagementPreferencesService: OrganizationManagementPreferencesService, + toastService: ToastService, + userNamePipe: UserNamePipe, + validationService: ValidationService, + private encryptService: EncryptService, + private activatedRoute: ActivatedRoute, + private providerService: ProviderService, + private router: Router, + private accountService: AccountService, + private environmentService: EnvironmentService, + ) { + super( + apiService, + i18nService, + keyService, + validationService, + logService, + userNamePipe, + dialogService, + organizationManagementPreferencesService, + toastService, + ); + + this.dataSource = new MembersTableDataSource(this.environmentService); + + combineLatest([ + this.activatedRoute.parent.params, + this.activatedRoute.queryParams.pipe(first()), + ]) + .pipe( + switchMap(async ([urlParams, queryParams]) => { + this.searchControl.setValue(queryParams.search); + this.dataSource.filter = peopleFilter(queryParams.search, null); + + this.providerId = urlParams.providerId; + const provider = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.providerService.get$(this.providerId, userId)), + ), + ); + + if (!provider || !provider.canManageUsers) { + return await this.router.navigate(["../"], { relativeTo: this.activatedRoute }); + } + this.accessEvents = provider.useEvents; + await this.load(); + + if (queryParams.viewEvents != null) { + const user = this.dataSource.data.find((user) => user.id === queryParams.viewEvents); + if (user && user.status === ProviderUserStatusType.Confirmed) { + this.openEventsDialog(user); + } + } + }), + takeUntilDestroyed(), + ) + .subscribe(); + } + + async bulkConfirm(): Promise { + if (this.actionPromise != null) { + return; + } + + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, { + data: { + providerId: this.providerId, + users: users, + }, + }); + + await lastValueFrom(dialogRef.closed); + await this.load(); + } + + async bulkReinvite(): Promise { + if (this.actionPromise != null) { + return; + } + + let users: ProviderUser[]; + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + users = this.dataSource.getCheckedUsersInVisibleOrder(); + } else { + users = this.dataSource.getCheckedUsers(); + } + + const allInvitedUsers = users.filter((user) => user.status === ProviderUserStatusType.Invited); + + // Capture the original count BEFORE enforcing the limit + const originalInvitedCount = allInvitedUsers.length; + + // When feature flag is enabled, limit invited users and uncheck the excess + let checkedInvitedUsers: ProviderUser[]; + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + checkedInvitedUsers = this.dataSource.limitAndUncheckExcess( + allInvitedUsers, + CloudBulkReinviteLimit, + ); + } else { + checkedInvitedUsers = allInvitedUsers; + } + + if (checkedInvitedUsers.length <= 0) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("noSelectedUsersApplicable"), + }); + return; + } + + try { + // When feature flag is enabled, show toast instead of dialog + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + await this.apiService.postManyProviderUserReinvite( + this.providerId, + new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)), + ); + + const selectedCount = originalInvitedCount; + const invitedCount = checkedInvitedUsers.length; + + if (selectedCount > CloudBulkReinviteLimit) { + const excludedCount = selectedCount - CloudBulkReinviteLimit; + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t( + "bulkReinviteLimitedSuccessToast", + CloudBulkReinviteLimit.toLocaleString(), + selectedCount.toLocaleString(), + excludedCount.toLocaleString(), + ), + }); + } else { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), + }); + } + } else { + // Feature flag disabled - show legacy dialog + const request = this.apiService.postManyProviderUserReinvite( + this.providerId, + new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)), + ); + + const dialogRef = BulkStatusComponent.open(this.dialogService, { + data: { + users: users, + filteredUsers: checkedInvitedUsers, + request, + successfulMessage: this.i18nService.t("bulkReinviteMessage"), + }, + }); + await lastValueFrom(dialogRef.closed); + } + } catch (error) { + this.validationService.showError(error); + } + } + + async invite() { + await this.edit(null); + } + + async bulkRemove(): Promise { + if (this.actionPromise != null) { + return; + } + + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, { + data: { + providerId: this.providerId, + users: users, + }, + }); + + await lastValueFrom(dialogRef.closed); + await this.load(); + } + + async confirmUser(user: ProviderUser, publicKey: Uint8Array): Promise { + try { + const providerKey = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.keyService.providerKeys$(userId)), + map((providerKeys) => providerKeys?.[this.providerId as ProviderId] ?? null), + ), + ); + assertNonNullish(providerKey, "Provider key not found"); + + const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey); + const request = new ProviderUserConfirmRequest(key.encryptedString); + await this.apiService.postProviderUserConfirm(this.providerId, user.id, request); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + removeUser = async (id: string): Promise => { + try { + await this.apiService.deleteProviderUser(this.providerId, id); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }; + + edit = async (user: ProviderUser | null): Promise => { + const data: AddEditMemberDialogParams = { + providerId: this.providerId, + user, + }; + + const dialogRef = AddEditMemberDialogComponent.open(this.dialogService, { + data, + }); + + const result = await lastValueFrom(dialogRef.closed); + + switch (result) { + case AddEditMemberDialogResultType.Saved: + case AddEditMemberDialogResultType.Deleted: + await this.load(); + break; + } + }; + + openEventsDialog = (user: ProviderUser): DialogRef => + openEntityEventsDialog(this.dialogService, { + data: { + name: this.userNamePipe.transform(user), + providerId: this.providerId, + entityId: user.id, + showUser: false, + entity: "user", + }, + }); + + getUsers = (): Promise> => + this.apiService.getProviderUsers(this.providerId); + + reinviteUser = async (id: string): Promise => { + try { + await this.apiService.postProviderUserReinvite(this.providerId, id); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }; + + get selectedInvitedCount(): number { + return this.dataSource + .getCheckedUsers() + .filter((member) => member.status === this.userStatusType.Invited).length; + } + + get isSingleInvite(): boolean { + return this.selectedInvitedCount === 1; + } +} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.ts index 635aaf16b3f..1579e0409d1 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.ts @@ -3,6 +3,7 @@ import { Component, Inject } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; import { ProviderUserInviteRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-invite.request"; @@ -15,14 +16,11 @@ import { DialogService, ToastService, } from "@bitwarden/components"; +import { ProviderUser } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source"; export type AddEditMemberDialogParams = { providerId: string; - user?: { - id: string; - name: string; - type: ProviderUserType; - }; + user?: ProviderUser; }; // FIXME: update to use a const object instead of a typescript enum @@ -59,6 +57,7 @@ export class AddEditMemberDialogComponent { private dialogService: DialogService, private i18nService: I18nService, private toastService: ToastService, + private userNamePipe: UserNamePipe, ) { this.editing = this.loading = this.dialogParams.user != null; if (this.editing) { @@ -78,8 +77,10 @@ export class AddEditMemberDialogComponent { return; } + const userName = this.userNamePipe.transform(this.dialogParams.user); + const confirmed = await this.dialogService.openSimpleDialog({ - title: this.dialogParams.user.name, + title: userName, content: { key: "removeUserConfirmation" }, type: "warning", }); @@ -96,7 +97,7 @@ export class AddEditMemberDialogComponent { this.toastService.showToast({ variant: "success", title: null, - message: this.i18nService.t("removedUserId", this.dialogParams.user.name), + message: this.i18nService.t("removedUserId", userName), }); this.dialogRef.close(AddEditMemberDialogResultType.Deleted); @@ -118,13 +119,12 @@ export class AddEditMemberDialogComponent { await this.apiService.postProviderUserInvite(this.dialogParams.providerId, request); } + const userName = this.editing ? this.userNamePipe.transform(this.dialogParams.user) : undefined; + this.toastService.showToast({ variant: "success", title: null, - message: this.i18nService.t( - this.editing ? "editedUserId" : "invitedUsers", - this.dialogParams.user?.name, - ), + message: this.i18nService.t(this.editing ? "editedUserId" : "invitedUsers", userName), }); this.dialogRef.close(AddEditMemberDialogResultType.Saved); diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts index 7ade77ed01b..84bd6988f0b 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts @@ -36,6 +36,7 @@ type BulkConfirmDialogParams = { @Component({ templateUrl: "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.html", + selector: "provider-bulk-comfirm-dialog", standalone: false, }) export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts index 29b50f71c1b..c044b9379c5 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts @@ -21,6 +21,7 @@ type BulkRemoveDialogParams = { @Component({ templateUrl: "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.html", + selector: "provider-bulk-remove-dialog", standalone: false, }) export class BulkRemoveDialogComponent extends BaseBulkRemoveComponent { diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/events.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/events.component.html index 11d6dff0ebc..070505a53b2 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/events.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/events.component.html @@ -94,7 +94,12 @@ [bitAction]="loadMoreEvents" *ngIf="continuationToken" > - + {{ "loadMore" | i18n }}
diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html index e0b29dffeb8..8d1e48a7338 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html @@ -1,7 +1,14 @@ +@let providerId = providerId$ | async; +@let bulkMenuOptions = bulkMenuOptions$ | async; +@let showConfirmBanner = showConfirmBanner$ | async; +@let dataSource = this.dataSource(); +@let isProcessing = this.isProcessing(); +@let isSingleInvite = isSingleInvite$ | async; + - @@ -13,28 +20,28 @@ (selectedChange)="statusToggle.next($event)" [attr.aria-label]="'memberStatusFilter' | i18n" > - + {{ "all" | i18n }} - - {{ allCount }} - + @if (dataSource.activeUserCount; as allCount) { + {{ allCount }} + } {{ "invited" | i18n }} - - {{ invitedCount }} - + @if (dataSource.invitedUserCount; as invitedCount) { + {{ invitedCount }} + } {{ "needsConfirmation" | i18n }} - - {{ acceptedCount }} - + @if (dataSource.acceptedUserCount; as acceptedCount) { + {{ acceptedCount }} + }
- +@if (!firstLoaded()) { {{ "loading" | i18n }} - - - -

{{ "noMembersInList" | i18n }}

- - - {{ "providerUsersNeedConfirmed" | i18n }} - +} @else { + @if (!dataSource.filteredData?.length) { +

{{ "noMembersInList" | i18n }}

+ } + @if (dataSource.filteredData?.length) { + @if (showConfirmBanner) { + + {{ "providerUsersNeedConfirmed" | i18n }} + + } @@ -82,27 +86,33 @@ label="{{ 'options' | i18n }}" > + @if (bulkMenuOptions.showBulkReinviteUsers) { + + } + @if (bulkMenuOptions.showBulkConfirmUsers) { + + } - - - - {{ "invited" | i18n }} - - - {{ "needsConfirmation" | i18n }} - - - {{ "revoked" | i18n }} - -
-
- {{ user.email }} + @if (user.status === userStatusType.Invited) { + + {{ "invited" | i18n }} + + } + @if (user.status === userStatusType.Accepted) { + + {{ "needsConfirmation" | i18n }} + + } + @if (user.status === userStatusType.Revoked) { + + {{ "revoked" | i18n }} + + }
+ @if (user.name) { +
+ {{ user.email }} +
+ }
- {{ "providerAdmin" | i18n }} - {{ "serviceUser" | i18n }} + @if (user.type === userType.ProviderAdmin) { + {{ "providerAdmin" | i18n }} + } + @if (user.type === userType.ServiceUser) { + {{ "serviceUser" | i18n }} + } + @if (user.status === userStatusType.Invited) { + + } + @if (user.status === userStatusType.Accepted) { + + } + @if (accessEvents && user.status === userStatusType.Confirmed) { + + } - - - + + +
+ + + + @if (emptyTableExplanation()) { +
+ {{ emptyTableExplanation() }} +
+ } +
+} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.spec.ts new file mode 100644 index 00000000000..b600d6086f6 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.spec.ts @@ -0,0 +1,242 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ReactiveFormsModule } from "@angular/forms"; +import { ActivatedRoute } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { + DrawerDetails, + DrawerType, + MemberDetails, + ReportStatus, + RiskInsightsDataService, +} from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { RiskInsightsEnrichedData } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-data-service.types"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CipherId } from "@bitwarden/common/types/guid"; +import { TableDataSource, ToastService } from "@bitwarden/components"; + +import { ApplicationTableDataSource } from "../shared/app-table-row-scrollable.component"; + +import { ApplicationsComponent } from "./applications.component"; + +// Helper type to access protected members in tests +type ComponentWithProtectedMembers = ApplicationsComponent & { + dataSource: TableDataSource; +}; + +describe("ApplicationsComponent", () => { + let component: ApplicationsComponent; + let fixture: ComponentFixture; + let mockI18nService: MockProxy; + let mockFileDownloadService: MockProxy; + let mockLogService: MockProxy; + let mockToastService: MockProxy; + let mockDataService: MockProxy; + + const reportStatus$ = new BehaviorSubject(ReportStatus.Complete); + const enrichedReportData$ = new BehaviorSubject(null); + const criticalReportResults$ = new BehaviorSubject(null); + const drawerDetails$ = new BehaviorSubject({ + open: false, + invokerId: "", + activeDrawerType: DrawerType.None, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: null, + }); + + beforeEach(async () => { + mockI18nService = mock(); + mockFileDownloadService = mock(); + mockLogService = mock(); + mockToastService = mock(); + mockDataService = mock(); + + mockI18nService.t.mockImplementation((key: string) => key); + + Object.defineProperty(mockDataService, "reportStatus$", { get: () => reportStatus$ }); + Object.defineProperty(mockDataService, "enrichedReportData$", { + get: () => enrichedReportData$, + }); + Object.defineProperty(mockDataService, "criticalReportResults$", { + get: () => criticalReportResults$, + }); + Object.defineProperty(mockDataService, "drawerDetails$", { get: () => drawerDetails$ }); + + await TestBed.configureTestingModule({ + imports: [ApplicationsComponent, ReactiveFormsModule], + providers: [ + { provide: I18nService, useValue: mockI18nService }, + { provide: FileDownloadService, useValue: mockFileDownloadService }, + { provide: LogService, useValue: mockLogService }, + { provide: ToastService, useValue: mockToastService }, + { provide: RiskInsightsDataService, useValue: mockDataService }, + { + provide: ActivatedRoute, + useValue: { snapshot: { paramMap: { get: (): string | null => null } } }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ApplicationsComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("downloadApplicationsCSV", () => { + const mockApplicationData: ApplicationTableDataSource[] = [ + { + applicationName: "GitHub", + passwordCount: 10, + atRiskPasswordCount: 3, + memberCount: 5, + atRiskMemberCount: 2, + isMarkedAsCritical: true, + atRiskCipherIds: ["cipher1" as CipherId], + memberDetails: [] as MemberDetails[], + atRiskMemberDetails: [] as MemberDetails[], + cipherIds: ["cipher1" as CipherId], + iconCipher: undefined, + }, + { + applicationName: "Slack", + passwordCount: 8, + atRiskPasswordCount: 1, + memberCount: 4, + atRiskMemberCount: 1, + isMarkedAsCritical: false, + atRiskCipherIds: ["cipher2" as CipherId], + memberDetails: [] as MemberDetails[], + atRiskMemberDetails: [] as MemberDetails[], + cipherIds: ["cipher2" as CipherId], + iconCipher: undefined, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should download CSV with correct data when filteredData has items", () => { + // Set up the data source with mock data + (component as ComponentWithProtectedMembers).dataSource = new TableDataSource(); + (component as ComponentWithProtectedMembers).dataSource.data = mockApplicationData; + + component.downloadApplicationsCSV(); + + expect(mockFileDownloadService.download).toHaveBeenCalledTimes(1); + expect(mockFileDownloadService.download).toHaveBeenCalledWith({ + fileName: expect.stringContaining("applications"), + blobData: expect.any(String), + blobOptions: { type: "text/plain" }, + }); + }); + + it("should not download when filteredData is empty", () => { + (component as ComponentWithProtectedMembers).dataSource = new TableDataSource(); + (component as ComponentWithProtectedMembers).dataSource.data = []; + + component.downloadApplicationsCSV(); + + expect(mockFileDownloadService.download).not.toHaveBeenCalled(); + }); + + it("should use translated column headers in CSV", () => { + (component as ComponentWithProtectedMembers).dataSource = new TableDataSource(); + (component as ComponentWithProtectedMembers).dataSource.data = mockApplicationData; + + component.downloadApplicationsCSV(); + + expect(mockI18nService.t).toHaveBeenCalledWith("application"); + expect(mockI18nService.t).toHaveBeenCalledWith("atRiskPasswords"); + expect(mockI18nService.t).toHaveBeenCalledWith("totalPasswords"); + expect(mockI18nService.t).toHaveBeenCalledWith("atRiskMembers"); + expect(mockI18nService.t).toHaveBeenCalledWith("totalMembers"); + expect(mockI18nService.t).toHaveBeenCalledWith("criticalBadge"); + }); + + it("should translate isMarkedAsCritical to 'yes' when true", () => { + (component as ComponentWithProtectedMembers).dataSource = new TableDataSource(); + (component as ComponentWithProtectedMembers).dataSource.data = [mockApplicationData[0]]; // Critical app + + component.downloadApplicationsCSV(); + + expect(mockI18nService.t).toHaveBeenCalledWith("yes"); + }); + + it("should translate isMarkedAsCritical to 'no' when false", () => { + (component as ComponentWithProtectedMembers).dataSource = new TableDataSource(); + (component as ComponentWithProtectedMembers).dataSource.data = [mockApplicationData[1]]; // Non-critical app + + component.downloadApplicationsCSV(); + + expect(mockI18nService.t).toHaveBeenCalledWith("no"); + }); + + it("should include correct application data in CSV export", () => { + (component as ComponentWithProtectedMembers).dataSource = new TableDataSource(); + (component as ComponentWithProtectedMembers).dataSource.data = [mockApplicationData[0]]; + + let capturedBlobData: string = ""; + mockFileDownloadService.download.mockImplementation((options) => { + capturedBlobData = options.blobData as string; + }); + + component.downloadApplicationsCSV(); + + // Verify the CSV contains the application data + expect(capturedBlobData).toContain("GitHub"); + expect(capturedBlobData).toContain("10"); // passwordCount + expect(capturedBlobData).toContain("3"); // atRiskPasswordCount + expect(capturedBlobData).toContain("5"); // memberCount + expect(capturedBlobData).toContain("2"); // atRiskMemberCount + }); + + it("should log error when download fails", () => { + (component as ComponentWithProtectedMembers).dataSource = new TableDataSource(); + (component as ComponentWithProtectedMembers).dataSource.data = mockApplicationData; + + const testError = new Error("Download failed"); + mockFileDownloadService.download.mockImplementation(() => { + throw testError; + }); + + component.downloadApplicationsCSV(); + + expect(mockLogService.error).toHaveBeenCalledWith( + "Failed to download applications CSV", + testError, + ); + }); + + it("should only export filtered data when filter is applied", () => { + (component as ComponentWithProtectedMembers).dataSource = new TableDataSource(); + (component as ComponentWithProtectedMembers).dataSource.data = mockApplicationData; + // Apply a filter that only matches "GitHub" + (component as ComponentWithProtectedMembers).dataSource.filter = ( + app: (typeof mockApplicationData)[0], + ) => app.applicationName === "GitHub"; + + let capturedBlobData: string = ""; + mockFileDownloadService.download.mockImplementation((options) => { + capturedBlobData = options.blobData as string; + }); + + component.downloadApplicationsCSV(); + + // Verify only GitHub is in the export (not Slack) + expect(capturedBlobData).toContain("GitHub"); + expect(capturedBlobData).not.toContain("Slack"); + }); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts new file mode 100644 index 00000000000..b5fae36bb2e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts @@ -0,0 +1,278 @@ +import { + Component, + DestroyRef, + inject, + OnInit, + ChangeDetectionStrategy, + signal, + computed, +} from "@angular/core"; +import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { ActivatedRoute } from "@angular/router"; +import { combineLatest, debounceTime, startWith } from "rxjs"; + +import { Security } from "@bitwarden/assets/svg"; +import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers"; +import { + OrganizationReportSummary, + ReportStatus, +} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { + ButtonModule, + IconButtonModule, + LinkModule, + NoItemsModule, + SearchModule, + TableDataSource, + ToastService, + TypographyModule, + ChipSelectComponent, +} from "@bitwarden/components"; +import { ExportHelper } from "@bitwarden/vault-export-core"; +import { exportToCSV } from "@bitwarden/web-vault/app/dirt/reports/report-utils"; +import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; + +import { AppTableRowScrollableM11Component } from "../shared/app-table-row-scrollable-m11.component"; +import { ApplicationTableDataSource } from "../shared/app-table-row-scrollable.component"; +import { ReportLoadingComponent } from "../shared/report-loading.component"; + +export const ApplicationFilterOption = { + All: "all", + Critical: "critical", + NonCritical: "nonCritical", +} as const; + +export type ApplicationFilterOption = + (typeof ApplicationFilterOption)[keyof typeof ApplicationFilterOption]; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "dirt-applications", + templateUrl: "./applications.component.html", + imports: [ + ReportLoadingComponent, + HeaderModule, + LinkModule, + SearchModule, + PipesModule, + NoItemsModule, + SharedModule, + AppTableRowScrollableM11Component, + IconButtonModule, + TypographyModule, + ButtonModule, + ReactiveFormsModule, + ChipSelectComponent, + ], +}) +export class ApplicationsComponent implements OnInit { + destroyRef = inject(DestroyRef); + private fileDownloadService = inject(FileDownloadService); + private logService = inject(LogService); + + protected ReportStatusEnum = ReportStatus; + protected noItemsIcon = Security; + + // Standard properties + protected readonly dataSource = new TableDataSource(); + protected readonly searchControl = new FormControl("", { nonNullable: true }); + + // Template driven properties + protected readonly selectedUrls = signal(new Set()); + protected readonly markingAsCritical = signal(false); + protected readonly applicationSummary = signal(createNewSummaryData()); + protected readonly criticalApplicationsCount = signal(0); + protected readonly totalApplicationsCount = signal(0); + protected readonly nonCriticalApplicationsCount = computed(() => { + return this.totalApplicationsCount() - this.criticalApplicationsCount(); + }); + + // filter related properties + protected readonly selectedFilter = signal(ApplicationFilterOption.All); + protected selectedFilterObservable = toObservable(this.selectedFilter); + protected readonly ApplicationFilterOption = ApplicationFilterOption; + protected readonly filterOptions = computed(() => [ + { + label: this.i18nService.t("critical", this.criticalApplicationsCount()), + value: ApplicationFilterOption.Critical, + icon: " ", + }, + { + label: this.i18nService.t("notCritical", this.nonCriticalApplicationsCount()), + value: ApplicationFilterOption.NonCritical, + icon: " ", + }, + ]); + protected readonly emptyTableExplanation = signal(""); + + constructor( + protected i18nService: I18nService, + protected activatedRoute: ActivatedRoute, + protected toastService: ToastService, + protected dataService: RiskInsightsDataService, + ) {} + + async ngOnInit() { + this.dataService.enrichedReportData$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (report) => { + if (report != null) { + this.applicationSummary.set(report.summaryData); + + // Map the report data to include the iconCipher for each application + const tableDataWithIcon = report.reportData.map((app) => ({ + ...app, + iconCipher: + app.cipherIds.length > 0 + ? this.dataService.getCipherIcon(app.cipherIds[0]) + : undefined, + })); + this.dataSource.data = tableDataWithIcon; + this.totalApplicationsCount.set(report.reportData.length); + } else { + this.dataSource.data = []; + } + }, + error: () => { + this.dataSource.data = []; + }, + }); + + this.dataService.criticalReportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (criticalReport) => { + if (criticalReport != null) { + this.criticalApplicationsCount.set(criticalReport.reportData.length); + } else { + this.criticalApplicationsCount.set(0); + } + }, + }); + + combineLatest([ + this.searchControl.valueChanges.pipe(startWith("")), + this.selectedFilterObservable, + ]) + .pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)) + .subscribe(([searchText, selectedFilter]) => { + let filterFunction = (app: ApplicationTableDataSource) => true; + + if (selectedFilter === ApplicationFilterOption.Critical) { + filterFunction = (app) => app.isMarkedAsCritical; + } else if (selectedFilter === ApplicationFilterOption.NonCritical) { + filterFunction = (app) => !app.isMarkedAsCritical; + } + + this.dataSource.filter = (app) => + filterFunction(app) && + app.applicationName.toLowerCase().includes(searchText.toLowerCase()); + + // filter selectedUrls down to only applications showing with active filters + const filteredUrls = new Set(); + this.dataSource.filteredData?.forEach((row) => { + if (this.selectedUrls().has(row.applicationName)) { + filteredUrls.add(row.applicationName); + } + }); + this.selectedUrls.set(filteredUrls); + + if (this.dataSource?.filteredData?.length === 0) { + this.emptyTableExplanation.set(this.i18nService.t("noApplicationsMatchTheseFilters")); + } else { + this.emptyTableExplanation.set(""); + } + }); + } + + setFilterApplicationsByStatus(value: ApplicationFilterOption) { + this.selectedFilter.set(value); + } + + isMarkedAsCriticalItem(applicationName: string) { + return this.selectedUrls().has(applicationName); + } + + markAppsAsCritical = async () => { + this.markingAsCritical.set(true); + const count = this.selectedUrls().size; + + this.dataService + .saveCriticalApplications(Array.from(this.selectedUrls())) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("criticalApplicationsMarkedSuccess", count.toString()), + }); + this.selectedUrls.set(new Set()); + this.markingAsCritical.set(false); + }, + error: () => { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("applicationsMarkedAsCriticalFail"), + }); + }, + }); + }; + + showAppAtRiskMembers = async (applicationName: string) => { + await this.dataService.setDrawerForAppAtRiskMembers(applicationName); + }; + + onCheckboxChange = (applicationName: string, event: Event) => { + const isChecked = (event.target as HTMLInputElement).checked; + this.selectedUrls.update((selectedUrls) => { + const nextSelected = new Set(selectedUrls); + if (isChecked) { + nextSelected.add(applicationName); + } else { + nextSelected.delete(applicationName); + } + return nextSelected; + }); + }; + + downloadApplicationsCSV = () => { + try { + const data = this.dataSource.filteredData; + if (!data || data.length === 0) { + return; + } + + const exportData = data.map((app) => ({ + applicationName: app.applicationName, + atRiskPasswordCount: app.atRiskPasswordCount, + passwordCount: app.passwordCount, + atRiskMemberCount: app.atRiskMemberCount, + memberCount: app.memberCount, + isMarkedAsCritical: app.isMarkedAsCritical + ? this.i18nService.t("yes") + : this.i18nService.t("no"), + })); + + this.fileDownloadService.download({ + fileName: ExportHelper.getFileName("applications"), + blobData: exportToCSV(exportData, { + applicationName: this.i18nService.t("application"), + atRiskPasswordCount: this.i18nService.t("atRiskPasswords"), + passwordCount: this.i18nService.t("totalPasswords"), + atRiskMemberCount: this.i18nService.t("atRiskMembers"), + memberCount: this.i18nService.t("totalMembers"), + isMarkedAsCritical: this.i18nService.t("criticalBadge"), + }), + blobOptions: { type: "text/plain" }, + }); + } catch (error) { + this.logService.error("Failed to download applications CSV", error); + } + }; +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts index b61190df660..3033bf139c3 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts @@ -1,10 +1,8 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, DestroyRef, inject, OnInit, ChangeDetectionStrategy } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { debounceTime, EMPTY, from, map, switchMap, take } from "rxjs"; +import { catchError, debounceTime, EMPTY, from, map, switchMap, take } from "rxjs"; import { Security } from "@bitwarden/assets/svg"; import { @@ -14,6 +12,7 @@ import { } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers"; import { OrganizationReportSummary } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { @@ -53,7 +52,7 @@ import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks export class CriticalApplicationsComponent implements OnInit { private destroyRef = inject(DestroyRef); protected enableRequestPasswordChange = false; - protected organizationId: OrganizationId; + protected organizationId: OrganizationId = "" as OrganizationId; noItemsIcon = Security; protected dataSource = new TableDataSource(); @@ -151,35 +150,43 @@ export class CriticalApplicationsComponent implements OnInit { }); }; - async requestPasswordChange() { + requestPasswordChange(): void { this.dataService.criticalApplicationAtRiskCipherIds$ .pipe( takeUntilDestroyed(this.destroyRef), // Satisfy eslint rule take(1), // Handle unsubscribe for one off operation - switchMap((cipherIds) => { - return from( + switchMap((cipherIds) => + from( this.securityTasksService.requestPasswordChangeForCriticalApplications( this.organizationId, cipherIds, ), - ); - }), - ) - .subscribe({ - next: () => { - this.toastService.showToast({ - message: this.i18nService.t("notifiedMembers"), - variant: "success", - title: this.i18nService.t("success"), - }); - }, - error: () => { + ), + ), + catchError((error: unknown) => { + if (error instanceof ErrorResponse && error.statusCode === 404) { + this.toastService.showToast({ + message: this.i18nService.t("mustBeOrganizationOwnerAdmin"), + variant: "error", + title: this.i18nService.t("error"), + }); + return EMPTY; + } + this.toastService.showToast({ message: this.i18nService.t("unexpectedError"), variant: "error", title: this.i18nService.t("error"), }); - }, + return EMPTY; + }), + ) + .subscribe(() => { + this.toastService.showToast({ + message: this.i18nService.t("notifiedMembers"), + variant: "success", + title: this.i18nService.t("success"), + }); }); } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html index 42600671e8c..59aa680fa4e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html @@ -6,12 +6,11 @@ {{ title() }}
-
- {{ description() }} -
+ @if (description()) { +
+ {{ description() }} +
+ } @if (benefits().length > 0) {
@for (benefit of benefits(); track $index) { @@ -38,69 +37,74 @@
} -
- -
+ @if (buttonText() && buttonAction()) { +
+ +
+ }
-
-
- @if (videoSrc()) { - - } @else if (icon()) { -
- +
+ @if (videoSrc()) { +
- } + > + } @else if (icon()) { +
+ +
+ } +
-
- -
-
- @if (videoSrc()) { - - } @else if (icon()) { -
- +
+ @if (videoSrc()) { +
- } + > + } @else if (icon()) { +
+ +
+ } +
-
+ }
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts index 54d97e984ec..a9ad86dc67c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts @@ -1,17 +1,16 @@ -import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, input, isDevMode, OnInit } from "@angular/core"; -import { Icon } from "@bitwarden/assets/svg"; -import { ButtonModule, IconModule } from "@bitwarden/components"; +import { BitSvg } from "@bitwarden/assets/svg"; +import { ButtonModule, SvgModule } from "@bitwarden/components"; @Component({ selector: "empty-state-card", templateUrl: "./empty-state-card.component.html", - imports: [CommonModule, IconModule, ButtonModule], + imports: [SvgModule, ButtonModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class EmptyStateCardComponent implements OnInit { - readonly icon = input(null); + readonly icon = input(null); readonly videoSrc = input(null); readonly title = input(""); readonly description = input(""); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index dfbd49d95f7..1e58d334288 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -44,10 +44,11 @@
-
- {{ "reviewAtRiskPasswords" | i18n }} -
- @let isRunningReport = dataService.isGeneratingReport$ | async; + @if (appsCount > 0) { +
+ {{ "reviewAtRiskPasswords" | i18n }} +
+ }
@@ -62,7 +63,6 @@ } - - -
@@ -88,6 +81,11 @@ + @if (milestone11Enabled) { + + + + } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index b307c91d29f..657bdb87d4a 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -21,6 +21,8 @@ import { ReportStatus, RiskInsightsDataService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -38,6 +40,7 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod import { AllActivityComponent } from "./activity/all-activity.component"; import { AllApplicationsComponent } from "./all-applications/all-applications.component"; +import { ApplicationsComponent } from "./all-applications/applications.component"; import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component"; import { EmptyStateCardComponent } from "./empty-state-card.component"; import { RiskInsightsTabType } from "./models/risk-insights.models"; @@ -53,6 +56,7 @@ type ProgressStep = ReportProgress | null; templateUrl: "./risk-insights.component.html", imports: [ AllApplicationsComponent, + ApplicationsComponent, AsyncActionsModule, ButtonModule, CommonModule, @@ -77,6 +81,7 @@ type ProgressStep = ReportProgress | null; export class RiskInsightsComponent implements OnInit, OnDestroy { private destroyRef = inject(DestroyRef); protected ReportStatusEnum = ReportStatus; + protected milestone11Enabled: boolean = false; tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllActivity; @@ -114,6 +119,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { protected dialogService: DialogService, private fileDownloadService: FileDownloadService, private logService: LogService, + private configService: ConfigService, ) { this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ tabIndex }) => { this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllActivity; @@ -121,6 +127,10 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { } async ngOnInit() { + this.milestone11Enabled = await this.configService.getFeatureFlag( + FeatureFlag.Milestone11AppPageImprovements, + ); + this.route.paramMap .pipe( takeUntilDestroyed(this.destroyRef), diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html new file mode 100644 index 00000000000..67cee2a4639 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html @@ -0,0 +1,132 @@ + + + + + + + + {{ "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/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 c1491240bbc..c9d1f4a7bc5 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 @@ -62,6 +62,8 @@ describe("DefaultOrganizationUserService", () => { organizationUserApiService = { postOrganizationUserConfirm: jest.fn(), postOrganizationUserBulkConfirm: jest.fn(), + restoreOrganizationUser_vNext: jest.fn(), + restoreManyOrganizationUsers_vNext: jest.fn(), } as any; accountService = { @@ -175,4 +177,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 99597179296..adec0e12de9 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 7a2875249a1..afbd6906f80 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,18 +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 { UnsignedSharedKey } from "@bitwarden/sdk-internal"; +import { + DEFAULT_KDF_CONFIG, + fromSdkKdfConfig, + KdfConfigService, + KeyService, +} from "@bitwarden/key-management"; +import { UnsignedSharedKey , + AuthClient, + BitwardenClient, + WrappedAccountCryptographicState, +} from "@bitwarden/sdk-internal"; import { DefaultSetInitialPasswordService } from "./default-set-initial-password.service.implementation"; import { + InitializeJitPasswordCredentials, SetInitialPasswordCredentials, SetInitialPasswordService, SetInitialPasswordTdeOffboardingCredentials, @@ -59,6 +79,7 @@ describe("DefaultSetInitialPasswordService", () => { let organizationUserApiService: MockProxy; let userDecryptionOptionsService: MockProxy; let accountCryptographicStateService: MockProxy; + const registerSdkService = mock(); let userId: UserId; let userKey: UserKey; @@ -95,6 +116,7 @@ describe("DefaultSetInitialPasswordService", () => { organizationUserApiService, userDecryptionOptionsService, accountCryptographicStateService, + registerSdkService, ); }); @@ -102,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; @@ -398,7 +424,6 @@ describe("DefaultSetInitialPasswordService", () => { // Assert expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); - expect(keyService.setPrivateKey).toHaveBeenCalledWith(keyPair[1].encryptedString, userId); expect( accountCryptographicStateService.setAccountCryptographicState, ).toHaveBeenCalledWith( @@ -641,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 () => { @@ -835,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/types/subscription-pricing-card-details.ts b/libs/angular/src/billing/types/subscription-pricing-card-details.ts index 9000b10a729..5f37f91c4f0 100644 --- a/libs/angular/src/billing/types/subscription-pricing-card-details.ts +++ b/libs/angular/src/billing/types/subscription-pricing-card-details.ts @@ -1,10 +1,14 @@ import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier"; -import { ButtonType } from "@bitwarden/components"; +import { BitwardenIcon, ButtonType } from "@bitwarden/components"; export type SubscriptionPricingCardDetails = { title: string; tagline: string; price?: { amount: number; cadence: SubscriptionCadence }; - button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } }; + button: { + text: string; + type: ButtonType; + icon?: { type: BitwardenIcon; position: "before" | "after" }; + }; features: string[]; }; diff --git a/libs/angular/src/jslib.module.ts b/libs/angular/src/jslib.module.ts index 8d222a4aaf9..c3670148d67 100644 --- a/libs/angular/src/jslib.module.ts +++ b/libs/angular/src/jslib.module.ts @@ -11,7 +11,7 @@ import { DialogModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, MenuModule, RadioButtonModule, @@ -73,9 +73,9 @@ import { IconComponent } from "./vault/components/icon.component"; MenuModule, NoItemsModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, - IconModule, + SvgModule, TextDragDirective, CopyClickDirective, A11yTitleDirective, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 5eaac4033eb..429e54ced28 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -108,7 +108,7 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@ import { WebAuthnLoginApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-api.service.abstraction"; import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction"; import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; -import { SendTokenService, DefaultSendTokenService } from "@bitwarden/common/auth/send-access"; +import { DefaultSendTokenService, SendTokenService } from "@bitwarden/common/auth/send-access"; import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service"; @@ -131,10 +131,10 @@ import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauth import { WebAuthnLoginPrfKeyService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-key.service"; import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service"; import { - TwoFactorApiService, DefaultTwoFactorApiService, - TwoFactorService, DefaultTwoFactorService, + TwoFactorApiService, + TwoFactorService, } from "@bitwarden/common/auth/two-factor"; import { AutofillSettingsService, @@ -208,8 +208,8 @@ import { PinService } from "@bitwarden/common/key-management/pin/pin.service.imp 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, DefaultSendPasswordService, + SendPasswordService, } from "@bitwarden/common/key-management/sends"; import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { @@ -303,6 +303,7 @@ import { import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service"; +import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; @@ -321,6 +322,7 @@ import { CipherAuthorizationService, DefaultCipherAuthorizationService, } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service"; import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service"; @@ -387,12 +389,12 @@ import { SafeInjectionToken } from "@bitwarden/ui-common"; // eslint-disable-next-line no-restricted-imports import { PasswordRepromptService } from "@bitwarden/vault"; import { + DefaultVaultExportApiService, IndividualVaultExportService, IndividualVaultExportServiceAbstraction, - DefaultVaultExportApiService, - VaultExportApiService, OrganizationVaultExportService, OrganizationVaultExportServiceAbstraction, + VaultExportApiService, VaultExportService, VaultExportServiceAbstraction, } from "@bitwarden/vault-export-core"; @@ -590,6 +592,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultDomainSettingsService, deps: [StateProvider, PolicyServiceAbstraction, AccountService], }), + safeProvider({ + provide: CipherSdkService, + useClass: DefaultCipherSdkService, + deps: [SdkService, LogService], + }), safeProvider({ provide: CipherServiceAbstraction, useFactory: ( @@ -607,6 +614,7 @@ const safeProviders: SafeProvider[] = [ logService: LogService, cipherEncryptionService: CipherEncryptionService, messagingService: MessagingServiceAbstraction, + cipherSdkService: CipherSdkService, ) => new CipherService( keyService, @@ -623,6 +631,7 @@ const safeProviders: SafeProvider[] = [ logService, cipherEncryptionService, messagingService, + cipherSdkService, ), deps: [ KeyService, @@ -639,6 +648,7 @@ const safeProviders: SafeProvider[] = [ LogService, CipherEncryptionService, MessagingServiceAbstraction, + CipherSdkService, ], }), safeProvider({ @@ -757,12 +767,13 @@ const safeProviders: SafeProvider[] = [ AccountServiceAbstraction, StateProvider, KdfConfigService, + AccountCryptographicStateService, ], }), safeProvider({ provide: SecurityStateService, useClass: DefaultSecurityStateService, - deps: [StateProvider], + deps: [AccountCryptographicStateService], }), safeProvider({ provide: RestrictedItemTypesService, @@ -848,6 +859,8 @@ const safeProviders: SafeProvider[] = [ KeyGenerationService, SendStateProviderAbstraction, EncryptService, + CryptoFunctionServiceAbstraction, + ConfigService, ], }), safeProvider({ @@ -886,7 +899,7 @@ const safeProviders: SafeProvider[] = [ FolderApiServiceAbstraction, InternalOrganizationServiceAbstraction, SendApiServiceAbstraction, - UserDecryptionOptionsServiceAbstraction, + InternalUserDecryptionOptionsServiceAbstraction, AvatarServiceAbstraction, LOGOUT_CALLBACK, BillingAccountProfileStateService, @@ -1583,6 +1596,7 @@ const safeProviders: SafeProvider[] = [ OrganizationUserApiService, InternalUserDecryptionOptionsServiceAbstraction, AccountCryptographicStateService, + RegisterSdkService, ], }), safeProvider({ @@ -1691,6 +1705,7 @@ const safeProviders: SafeProvider[] = [ SdkService, ApiServiceAbstraction, ConfigService, + AccountCryptographicStateService, ], }), safeProvider({ diff --git a/libs/angular/src/vault/abstractions/deprecated-vault-filter.service.ts b/libs/angular/src/vault/abstractions/deprecated-vault-filter.service.ts index 30a4c6d4739..a499003b42b 100644 --- a/libs/angular/src/vault/abstractions/deprecated-vault-filter.service.ts +++ b/libs/angular/src/vault/abstractions/deprecated-vault-filter.service.ts @@ -1,8 +1,6 @@ import { Observable } 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 { UserId } from "@bitwarden/common/types/guid"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index 563fd48028d..6058955788e 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -94,7 +94,7 @@ export class VaultItemsComponent implements OnDestroy protected cipherService: CipherService, protected accountService: AccountService, protected restrictedItemTypesService: RestrictedItemTypesService, - private configService: ConfigService, + protected configService: ConfigService, ) { this.subscribeToCiphers(); @@ -194,12 +194,7 @@ export class VaultItemsComponent implements OnDestroy return this.searchService.searchCiphers( userId, searchText, - [ - filter, - this.deletedFilter, - ...(this.deleted ? [] : [this.archivedFilter]), - restrictedTypeFilter, - ], + [filter, restrictedTypeFilter], allCiphers, ); }), diff --git a/libs/angular/src/vault/index.ts b/libs/angular/src/vault/index.ts index b9131338a45..a89be7bc2c4 100644 --- a/libs/angular/src/vault/index.ts +++ b/libs/angular/src/vault/index.ts @@ -1,4 +1,8 @@ // Note: Nudge related code is exported from `libs/angular` because it is consumed by multiple // `libs/*` packages. Exporting from the `libs/vault` package creates circular dependencies. export { NudgesService, NudgeStatus, NudgeType } from "./services/nudges.service"; -export { AUTOFILL_NUDGE_SERVICE } from "./services/nudge-injection-tokens"; +export { + AUTOFILL_NUDGE_SERVICE, + AUTO_CONFIRM_NUDGE_SERVICE, +} from "./services/nudge-injection-tokens"; +export { AutoConfirmNudgeService } from "./services/custom-nudges-services"; diff --git a/libs/angular/src/vault/services/custom-nudges-services/README.md b/libs/angular/src/vault/services/custom-nudges-services/README.md new file mode 100644 index 00000000000..f0759979de9 --- /dev/null +++ b/libs/angular/src/vault/services/custom-nudges-services/README.md @@ -0,0 +1,204 @@ +# Custom Nudge Services + +This folder contains custom implementations of `SingleNudgeService` that provide specialized logic for determining when nudges should be shown or dismissed. + +## Architecture Overview + +### Core Components + +- **`NudgesService`** (`../nudges.service.ts`) - The main service that components use to check nudge status and dismiss nudges +- **`SingleNudgeService`** - Interface that all nudge services implement +- **`DefaultSingleNudgeService`** - Base implementation that stores dismissed state in user state +- **Custom nudge services** - Specialized implementations with additional logic + +### How It Works + +1. Components call `NudgesService.showNudgeSpotlight$()` or `showNudgeBadge$()` with a `NudgeType` +2. `NudgesService` routes to the appropriate custom nudge service (or falls back to `DefaultSingleNudgeService`) +3. The custom service returns a `NudgeStatus` indicating if the badge/spotlight should be shown +4. Custom services can combine the persisted dismissed state with dynamic conditions (e.g., account age, vault contents) + +### NudgeStatus + +```typescript +type NudgeStatus = { + hasBadgeDismissed: boolean; // True if the badge indicator should be hidden + hasSpotlightDismissed: boolean; // True if the spotlight/callout should be hidden +}; +``` + +## Service Categories + +### Universal Services + +These services work on **all clients** (browser, web, desktop) and use `@Injectable({ providedIn: "root" })`. + +| Service | Purpose | +| --------------------------------- | ---------------------------------------------------------------------- | +| `NewAccountNudgeService` | Auto-dismisses after account is 30 days old | +| `NewItemNudgeService` | Checks cipher counts for "add first item" nudges | +| `HasItemsNudgeService` | Checks if vault has items | +| `EmptyVaultNudgeService` | Checks empty vault state | +| `AccountSecurityNudgeService` | Checks security settings (PIN, biometrics) | +| `VaultSettingsImportNudgeService` | Checks import status | +| `NoOpNudgeService` | Always returns dismissed (used as fallback for client specific nudges) | + +### Client-Specific Services + +These services require **platform-specific features** and must be explicitly registered in each client that supports them. + +| Service | Clients | Requires | +| ----------------------------- | ------------ | -------------------------------------- | +| `AutoConfirmNudgeService` | Browser only | `AutomaticUserConfirmationService` | +| `BrowserAutofillNudgeService` | Browser only | `BrowserApi` (lives in `apps/browser`) | + +## Adding a New Nudge Service + +### Step 1: Determine if Universal or Client-Specific + +**Universal** - If your service only depends on: + +- `StateProvider` +- Services available in all clients (e.g., `CipherService`, `OrganizationService`) + +**Client-Specific** - If your service depends on: + +- Browser APIs (`BrowserApi`, autofill services) +- Services only available in certain clients +- Platform-specific features + +### Step 2: Create the Service + +#### For Universal Services + +```typescript +// my-nudge.service.ts +import { Injectable } from "@angular/core"; +import { combineLatest, map, Observable } from "rxjs"; + +import { StateProvider } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { DefaultSingleNudgeService } from "../default-single-nudge.service"; +import { NudgeStatus, NudgeType } from "../nudges.service"; + +@Injectable({ providedIn: "root" }) +export class MyNudgeService extends DefaultSingleNudgeService { + constructor( + stateProvider: StateProvider, + private myDependency: MyDependency, // Must be available in all clients + ) { + super(stateProvider); + } + + nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable { + return combineLatest([ + this.getNudgeStatus$(nudgeType, userId), // Gets persisted dismissed state + this.myDependency.someData$, + ]).pipe( + map(([persistedStatus, data]) => { + // Return dismissed if user already dismissed OR your condition is met + const autoDismiss = /* your logic */; + return { + hasBadgeDismissed: persistedStatus.hasBadgeDismissed || autoDismiss, + hasSpotlightDismissed: persistedStatus.hasSpotlightDismissed || autoDismiss, + }; + }), + ); + } +} +``` + +#### For Client-Specific Services + +```typescript +// my-client-specific-nudge.service.ts +import { Injectable } from "@angular/core"; +import { combineLatest, map, Observable } from "rxjs"; + +import { StateProvider } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { DefaultSingleNudgeService } from "../default-single-nudge.service"; +import { NudgeStatus, NudgeType } from "../nudges.service"; + +@Injectable() // NO providedIn: "root" +export class MyClientSpecificNudgeService extends DefaultSingleNudgeService { + constructor( + stateProvider: StateProvider, + private clientSpecificService: ClientSpecificService, + ) { + super(stateProvider); + } + + nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable { + return combineLatest([ + this.getNudgeStatus$(nudgeType, userId), + this.clientSpecificService.someData$, + ]).pipe( + map(([persistedStatus, data]) => { + const autoDismiss = /* your logic */; + return { + hasBadgeDismissed: persistedStatus.hasBadgeDismissed || autoDismiss, + hasSpotlightDismissed: persistedStatus.hasSpotlightDismissed || autoDismiss, + }; + }), + ); + } +} +``` + +### Step 3: Add NudgeType + +Add your nudge type to `NudgeType` in `../nudges.service.ts`: + +```typescript +export const NudgeType = { + // ... existing types + MyNewNudge: "my-new-nudge", +} as const; +``` + +### Step 4: Register in NudgesService + +#### For Universal Services + +Add to `customNudgeServices` map in `../nudges.service.ts`: + +```typescript +private customNudgeServices: Partial> = { + // ... existing + [NudgeType.MyNewNudge]: inject(MyNudgeService), +}; +``` + +#### For Client-Specific Services + +1. **Add injection token** in `../nudge-injection-tokens.ts`: + +```typescript +export const MY_NUDGE_SERVICE = new InjectionToken("MyNudgeService"); +``` + +2. **Inject with optional** in `../nudges.service.ts`: + +```typescript +private myNudgeService = inject(MY_NUDGE_SERVICE, { optional: true }); + +private customNudgeServices = { + // ... existing + [NudgeType.MyNewNudge]: this.myNudgeService ?? this.noOpNudgeService, +}; +``` + +3. **Register in each supporting client** (e.g., `apps/browser/src/popup/services/services.module.ts`): + +```typescript +import { MY_NUDGE_SERVICE } from "@bitwarden/angular/vault"; + +safeProvider({ + provide: MY_NUDGE_SERVICE as SafeInjectionToken, + useClass: MyClientSpecificNudgeService, + deps: [StateProvider, ClientSpecificService], +}), +``` diff --git a/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts index 835c9e35ac7..ab8a1869266 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts @@ -39,7 +39,7 @@ export class AccountSecurityNudgeService extends DefaultSingleNudgeService { this.getNudgeStatus$(nudgeType, userId), of(Date.now() - THIRTY_DAYS_MS), from(this.pinService.isPinSet(userId)), - this.biometricStateService.biometricUnlockEnabled$, + this.biometricStateService.biometricUnlockEnabled$(userId), this.organizationService.organizations$(userId), this.policyService.policiesByType$(PolicyType.RemoveUnlockWithPin, userId), ]).pipe( diff --git a/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.ts index 52fc87d7604..9fe843e50e0 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.ts @@ -1,15 +1,24 @@ -import { inject, Injectable } from "@angular/core"; +import { Injectable } from "@angular/core"; import { combineLatest, map, Observable } from "rxjs"; import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; +import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/user-core"; import { DefaultSingleNudgeService } from "../default-single-nudge.service"; import { NudgeType, NudgeStatus } from "../nudges.service"; -@Injectable({ providedIn: "root" }) +/** + * Browser specific nudge service for auto-confirm nudge. + */ +@Injectable() export class AutoConfirmNudgeService extends DefaultSingleNudgeService { - autoConfirmService = inject(AutomaticUserConfirmationService); + constructor( + stateProvider: StateProvider, + private autoConfirmService: AutomaticUserConfirmationService, + ) { + super(stateProvider); + } nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable { return combineLatest([ diff --git a/libs/angular/src/vault/services/custom-nudges-services/has-items-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/has-items-nudge.service.ts index d030b37dbd1..336aead0e8c 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/has-items-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/has-items-nudge.service.ts @@ -1,8 +1,8 @@ import { inject, Injectable } from "@angular/core"; -import { combineLatest, from, Observable, of, switchMap } from "rxjs"; -import { catchError } from "rxjs/operators"; +import { combineLatest, Observable, of, switchMap } from "rxjs"; +import { catchError, map } from "rxjs/operators"; -import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -20,11 +20,14 @@ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; }) export class HasItemsNudgeService extends DefaultSingleNudgeService { cipherService = inject(CipherService); - vaultProfileService = inject(VaultProfileService); + accountService = inject(AccountService); logService = inject(LogService); nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable { - const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe( + const profileDate$ = this.accountService.activeAccount$.pipe( + map((account) => { + return account?.creationDate ?? new Date(); + }), catchError(() => { this.logService.error("Error getting profile creation date"); // Default to today to ensure we show the nudge diff --git a/libs/angular/src/vault/services/custom-nudges-services/new-account-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/new-account-nudge.service.ts index 39af9a2e4aa..8c18da8a103 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/new-account-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/new-account-nudge.service.ts @@ -1,10 +1,11 @@ -import { Injectable, inject } from "@angular/core"; +import { Injectable } from "@angular/core"; import { Observable, combineLatest, from, map, of } from "rxjs"; import { catchError } from "rxjs/operators"; import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; +import { StateProvider } from "@bitwarden/state"; import { DefaultSingleNudgeService } from "../default-single-nudge.service"; import { NudgeStatus, NudgeType } from "../nudges.service"; @@ -18,8 +19,13 @@ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; providedIn: "root", }) export class NewAccountNudgeService extends DefaultSingleNudgeService { - vaultProfileService = inject(VaultProfileService); - logService = inject(LogService); + constructor( + stateProvider: StateProvider, + private vaultProfileService: VaultProfileService, + private logService: LogService, + ) { + super(stateProvider); + } nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable { const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe( diff --git a/libs/angular/src/vault/services/default-single-nudge.service.ts b/libs/angular/src/vault/services/default-single-nudge.service.ts index 8abc344c4a0..06c08371f41 100644 --- a/libs/angular/src/vault/services/default-single-nudge.service.ts +++ b/libs/angular/src/vault/services/default-single-nudge.service.ts @@ -1,4 +1,4 @@ -import { inject, Injectable } from "@angular/core"; +import { Injectable } from "@angular/core"; import { map, Observable } from "rxjs"; import { StateProvider } from "@bitwarden/common/platform/state"; @@ -22,7 +22,11 @@ export interface SingleNudgeService { providedIn: "root", }) export class DefaultSingleNudgeService implements SingleNudgeService { - stateProvider = inject(StateProvider); + protected stateProvider: StateProvider; + + constructor(stateProvider: StateProvider) { + this.stateProvider = stateProvider; + } protected getNudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable { return this.stateProvider diff --git a/libs/angular/src/vault/services/nudge-injection-tokens.ts b/libs/angular/src/vault/services/nudge-injection-tokens.ts index 52a0838d356..43db5ec1dc6 100644 --- a/libs/angular/src/vault/services/nudge-injection-tokens.ts +++ b/libs/angular/src/vault/services/nudge-injection-tokens.ts @@ -2,6 +2,25 @@ import { InjectionToken } from "@angular/core"; import { SingleNudgeService } from "./default-single-nudge.service"; +/** + * Injection tokens for client specific nudge services. + * + * These services require platform-specific features and must be explicitly + * provided by each client that supports them. If not provided, NudgesService + * falls back to NoOpNudgeService. + * + * Client specific services should use constructor injection (not inject()) + * to maintain safeProvider type safety. + * + * Universal services use @Injectable({ providedIn: "root" }) and can use inject(). + */ + +/** Browser: Requires BrowserApi */ export const AUTOFILL_NUDGE_SERVICE = new InjectionToken( "AutofillNudgeService", ); + +/** Browser: Requires AutomaticUserConfirmationService */ +export const AUTO_CONFIRM_NUDGE_SERVICE = new InjectionToken( + "AutoConfirmNudgeService", +); diff --git a/libs/angular/src/vault/services/nudges.service.ts b/libs/angular/src/vault/services/nudges.service.ts index afd0d184d6e..a8a8feb073f 100644 --- a/libs/angular/src/vault/services/nudges.service.ts +++ b/libs/angular/src/vault/services/nudges.service.ts @@ -12,11 +12,10 @@ import { NewItemNudgeService, AccountSecurityNudgeService, VaultSettingsImportNudgeService, - AutoConfirmNudgeService, NoOpNudgeService, } from "./custom-nudges-services"; import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service"; -import { AUTOFILL_NUDGE_SERVICE } from "./nudge-injection-tokens"; +import { AUTOFILL_NUDGE_SERVICE, AUTO_CONFIRM_NUDGE_SERVICE } from "./nudge-injection-tokens"; export type NudgeStatus = { hasBadgeDismissed: boolean; @@ -63,12 +62,21 @@ export class NudgesService { // NoOp service that always returns dismissed private noOpNudgeService = inject(NoOpNudgeService); - // Optional Browser-specific service provided via injection token (not all clients have autofill) + // Client specific services (optional, via injection tokens) + // These services require platform-specific features and fallback to NoOpNudgeService if not provided + private autofillNudgeService = inject(AUTOFILL_NUDGE_SERVICE, { optional: true }); + private autoConfirmNudgeService = inject(AUTO_CONFIRM_NUDGE_SERVICE, { optional: true }); /** * Custom nudge services to use for specific nudge types * Each nudge type can have its own service to determine when to show the nudge + * + * NOTE: If a custom nudge service requires client specific services/features: + * 1. The custom nudge service must be provided via injection token and marked as optional. + * 2. The custom nudge service must be manually registered with that token in the client(s). + * + * See the README.md in the custom-nudge-services folder for more details on adding custom nudges. * @private */ private customNudgeServices: Partial> = { @@ -84,7 +92,7 @@ export class NudgesService { [NudgeType.NewIdentityItemStatus]: this.newItemNudgeService, [NudgeType.NewNoteItemStatus]: this.newItemNudgeService, [NudgeType.NewSshItemStatus]: this.newItemNudgeService, - [NudgeType.AutoConfirmNudge]: inject(AutoConfirmNudgeService), + [NudgeType.AutoConfirmNudge]: this.autoConfirmNudgeService ?? this.noOpNudgeService, }; /** diff --git a/libs/angular/src/vault/services/vault-profile.service.ts b/libs/angular/src/vault/services/vault-profile.service.ts index 3a8c9d4ee95..3977b275d02 100644 --- a/libs/angular/src/vault/services/vault-profile.service.ts +++ b/libs/angular/src/vault/services/vault-profile.service.ts @@ -21,6 +21,9 @@ export class VaultProfileService { * Returns the creation date of the profile. * Note: `Date`s are mutable in JS, creating a new * instance is important to avoid unwanted changes. + * + * @deprecated use `creationDate` directly from the `AccountService.activeAccount$` instead, + * PM-31409 will replace all usages of this service. */ async getProfileCreationDate(userId: string): Promise { if (this.profileCreatedDate && userId === this.userId) { diff --git a/libs/angular/src/vault/vault-filter/components/collection-filter.component.ts b/libs/angular/src/vault/vault-filter/components/collection-filter.component.ts index 4d4037a3517..dfb6069f130 100644 --- a/libs/angular/src/vault/vault-filter/components/collection-filter.component.ts +++ b/libs/angular/src/vault/vault-filter/components/collection-filter.component.ts @@ -2,9 +2,10 @@ // @ts-strict-ignore import { Directive, EventEmitter, Input, Output } from "@angular/core"; -// 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 { + CollectionView, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node"; import { DynamicTreeNode } from "../models/dynamic-tree-node.model"; diff --git a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts index f664cff2e8d..8886f93233f 100644 --- a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts +++ b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts @@ -3,9 +3,7 @@ import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; import { firstValueFrom, Observable } 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; diff --git a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts index 83693c85239..d3ad29142e2 100644 --- a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts +++ b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts @@ -54,6 +54,12 @@ export class VaultFilter { cipherPassesFilter = CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher); } + + if (this.status !== "archive" && this.status !== "trash" && cipherPassesFilter) { + cipherPassesFilter = + !CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher); + } + if (this.cipherType != null && cipherPassesFilter) { cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType; } diff --git a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts index 6777da7a9e5..9b34890cbce 100644 --- a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts +++ b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts @@ -3,14 +3,14 @@ import { firstValueFrom, from, map, mergeMap, Observable, switchMap, take } from // 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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { + CollectionView, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; diff --git a/libs/assets/README.md b/libs/assets/README.md index 60df3f8992a..944c942640a 100644 --- a/libs/assets/README.md +++ b/libs/assets/README.md @@ -8,9 +8,9 @@ This lib contains assets used by the Bitwarden clients. Unused assets are tree-s ### SVGs -SVGs intended to be used with the `bit-icon` component live in `src/svgs`. These SVGs are built with the `icon-service` for security reasons. These SVGs can be viewed in our Component Library [Icon Story](https://components.bitwarden.com/?path=/story/component-library-icon--default). +SVGs intended to be used with the `bit-svg` component live in `src/svgs`. These SVGs are built with the `svg` function for security reasons. These SVGs can be viewed in our Component Library [SVG Story](https://components.bitwarden.com/?path=/story/component-library-svg--default). -When adding a new SVG, follow the instructions in our Component Library: [SVG Icon Docs](https://components.bitwarden.com/?path=/docs/component-library-icon--docs) +When adding a new SVG, follow the instructions in our Component Library: [SVG Docs](https://components.bitwarden.com/?path=/docs/component-library-svg--docs) When importing an SVG in one of the clients: `import { ExampleSvg } from "@bitwarden/assets/svg";` diff --git a/libs/assets/src/svg/icon-service.ts b/libs/assets/src/svg/icon-service.ts deleted file mode 100644 index b397431da28..00000000000 --- a/libs/assets/src/svg/icon-service.ts +++ /dev/null @@ -1,25 +0,0 @@ -class Icon { - constructor(readonly svg: string) {} -} - -// We only export the type to prohibit the creation of Icons without using -// the `svgIcon` template literal tag. -export type { Icon }; - -export function isIcon(icon: unknown): icon is Icon { - return icon instanceof Icon; -} - -export class DynamicContentNotAllowedError extends Error { - constructor() { - super("Dynamic content in icons is not allowed due to risk of user-injected XSS."); - } -} - -export function svgIcon(strings: TemplateStringsArray, ...values: unknown[]): Icon { - if (values.length > 0) { - throw new DynamicContentNotAllowedError(); - } - - return new Icon(strings[0]); -} diff --git a/libs/assets/src/svg/index.ts b/libs/assets/src/svg/index.ts index 9f86a14f772..6a0fff490ff 100644 --- a/libs/assets/src/svg/index.ts +++ b/libs/assets/src/svg/index.ts @@ -1,2 +1,2 @@ export * from "./svgs"; -export * from "./icon-service"; +export * from "./svg"; diff --git a/libs/assets/src/svg/icon-service.spec.ts b/libs/assets/src/svg/svg.spec.ts similarity index 69% rename from libs/assets/src/svg/icon-service.spec.ts rename to libs/assets/src/svg/svg.spec.ts index 2561c85aefa..2d8401f0b5d 100644 --- a/libs/assets/src/svg/icon-service.spec.ts +++ b/libs/assets/src/svg/svg.spec.ts @@ -1,5 +1,5 @@ -import * as IconExports from "./icon-service"; -import { DynamicContentNotAllowedError, isIcon, svgIcon } from "./icon-service"; +import * as IconExports from "./svg"; +import { DynamicContentNotAllowedError, isBitSvg, svg } from "./svg"; describe("Icon", () => { it("exports should not expose Icon class", () => { @@ -8,13 +8,13 @@ describe("Icon", () => { describe("isIcon", () => { it("should return true when input is icon", () => { - const result = isIcon(svgIcon`icon`); + const result = isBitSvg(svg`icon`); expect(result).toBe(true); }); it("should return false when input is not an icon", () => { - const result = isIcon({ svg: "not an icon" }); + const result = isBitSvg({ svg: "not an icon" }); expect(result).toBe(false); }); @@ -24,13 +24,13 @@ describe("Icon", () => { it("should throw when attempting to create dynamic icons", () => { const dynamic = "some user input"; - const f = () => svgIcon`static and ${dynamic}`; + const f = () => svg`static and ${dynamic}`; expect(f).toThrow(DynamicContentNotAllowedError); }); it("should return svg content when supplying icon with svg string", () => { - const icon = svgIcon`safe static content`; + const icon = svg`safe static content`; expect(icon.svg).toBe("safe static content"); }); diff --git a/libs/assets/src/svg/svg.ts b/libs/assets/src/svg/svg.ts new file mode 100644 index 00000000000..71324ea4bac --- /dev/null +++ b/libs/assets/src/svg/svg.ts @@ -0,0 +1,25 @@ +class BitSvg { + constructor(readonly svg: string) {} +} + +// We only export the type to prohibit the creation of Svgs without using +// the `svg` template literal tag. +export type { BitSvg }; + +export function isBitSvg(svgContent: unknown): svgContent is BitSvg { + return svgContent instanceof BitSvg; +} + +export class DynamicContentNotAllowedError extends Error { + constructor() { + super("Dynamic content in icons is not allowed due to risk of user-injected XSS."); + } +} + +export function svg(strings: TemplateStringsArray, ...values: unknown[]): BitSvg { + if (values.length > 0) { + throw new DynamicContentNotAllowedError(); + } + + return new BitSvg(strings[0]); +} diff --git a/libs/assets/src/svg/svgs/account-warning.icon.ts b/libs/assets/src/svg/svgs/account-warning.icon.ts index 80e29dad870..81bf62d6e64 100644 --- a/libs/assets/src/svg/svgs/account-warning.icon.ts +++ b/libs/assets/src/svg/svgs/account-warning.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const AccountWarning = svgIcon` +export const AccountWarning = svg` diff --git a/libs/assets/src/svg/svgs/active-send.icon.ts b/libs/assets/src/svg/svgs/active-send.icon.ts index 3b12ee865d1..3016466e062 100644 --- a/libs/assets/src/svg/svgs/active-send.icon.ts +++ b/libs/assets/src/svg/svgs/active-send.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ActiveSendIcon = svgIcon` +export const ActiveSendIcon = svg` diff --git a/libs/assets/src/svg/svgs/admin-console.ts b/libs/assets/src/svg/svgs/admin-console.ts index 83c8cf9f0e1..146c834b442 100644 --- a/libs/assets/src/svg/svgs/admin-console.ts +++ b/libs/assets/src/svg/svgs/admin-console.ts @@ -1,14 +1,14 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const AdminConsoleLogo = svgIcon` +const AdminConsoleLogo = svg` - + - + diff --git a/libs/assets/src/svg/svgs/auto-confirmation.ts b/libs/assets/src/svg/svgs/auto-confirmation.ts index 2a1416a5d25..5d0e0dd380c 100644 --- a/libs/assets/src/svg/svgs/auto-confirmation.ts +++ b/libs/assets/src/svg/svgs/auto-confirmation.ts @@ -1,5 +1,5 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const AutoConfirmSvg = svgIcon` +export const AutoConfirmSvg = svg` `; diff --git a/libs/assets/src/svg/svgs/background-left-illustration.ts b/libs/assets/src/svg/svgs/background-left-illustration.ts index a34f31f1621..f091f905c64 100644 --- a/libs/assets/src/svg/svgs/background-left-illustration.ts +++ b/libs/assets/src/svg/svgs/background-left-illustration.ts @@ -1,5 +1,5 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BackgroundLeftIllustration = svgIcon` +export const BackgroundLeftIllustration = svg` `; diff --git a/libs/assets/src/svg/svgs/background-right-illustration.ts b/libs/assets/src/svg/svgs/background-right-illustration.ts index 1c488f7242d..8f3bbba3462 100644 --- a/libs/assets/src/svg/svgs/background-right-illustration.ts +++ b/libs/assets/src/svg/svgs/background-right-illustration.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BackgroundRightIllustration = svgIcon` +export const BackgroundRightIllustration = svg` diff --git a/libs/assets/src/svg/svgs/bitwarden-icon.ts b/libs/assets/src/svg/svgs/bitwarden-icon.ts index 203460952b5..43aea78ced6 100644 --- a/libs/assets/src/svg/svgs/bitwarden-icon.ts +++ b/libs/assets/src/svg/svgs/bitwarden-icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BitwardenIcon = svgIcon` +export const BitwardenIcon = svg` diff --git a/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts b/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts index 9c1c7248ec6..85d0a471a6e 100644 --- a/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts +++ b/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BitwardenLogo = svgIcon` +export const BitwardenLogo = svg` Bitwarden diff --git a/libs/assets/src/svg/svgs/browser-extension.ts b/libs/assets/src/svg/svgs/browser-extension.ts index c15a536c007..2c40c584255 100644 --- a/libs/assets/src/svg/svgs/browser-extension.ts +++ b/libs/assets/src/svg/svgs/browser-extension.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BrowserExtensionIcon = svgIcon` +export const BrowserExtensionIcon = svg` diff --git a/libs/assets/src/svg/svgs/business-unit-portal.ts b/libs/assets/src/svg/svgs/business-unit-portal.ts index db3a6b8ef4f..cd06afcbf9a 100644 --- a/libs/assets/src/svg/svgs/business-unit-portal.ts +++ b/libs/assets/src/svg/svgs/business-unit-portal.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const BusinessUnitPortalLogo = svgIcon` +const BusinessUnitPortalLogo = svg` diff --git a/libs/assets/src/svg/svgs/business-welcome.icon.ts b/libs/assets/src/svg/svgs/business-welcome.icon.ts index 06c4950ec18..1d1caed8d47 100644 --- a/libs/assets/src/svg/svgs/business-welcome.icon.ts +++ b/libs/assets/src/svg/svgs/business-welcome.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BusinessWelcome = svgIcon` +export const BusinessWelcome = svg` diff --git a/libs/assets/src/svg/svgs/carousel-icon.ts b/libs/assets/src/svg/svgs/carousel-icon.ts index e29fd952098..4d645ad8029 100644 --- a/libs/assets/src/svg/svgs/carousel-icon.ts +++ b/libs/assets/src/svg/svgs/carousel-icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const CarouselIcon = svgIcon` +export const CarouselIcon = svg` diff --git a/libs/assets/src/svg/svgs/credit-card.icon.ts b/libs/assets/src/svg/svgs/credit-card.icon.ts index e334766fac7..dd0eb6a121a 100644 --- a/libs/assets/src/svg/svgs/credit-card.icon.ts +++ b/libs/assets/src/svg/svgs/credit-card.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const CreditCardIcon = svgIcon` +export const CreditCardIcon = svg` diff --git a/libs/assets/src/svg/svgs/deactivated-org.ts b/libs/assets/src/svg/svgs/deactivated-org.ts index 75b25e3fd27..d2566712a98 100644 --- a/libs/assets/src/svg/svgs/deactivated-org.ts +++ b/libs/assets/src/svg/svgs/deactivated-org.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const DeactivatedOrg = svgIcon` +export const DeactivatedOrg = svg` diff --git a/libs/assets/src/svg/svgs/devices.icon.ts b/libs/assets/src/svg/svgs/devices.icon.ts index 7c97df48657..a3a4aa06442 100644 --- a/libs/assets/src/svg/svgs/devices.icon.ts +++ b/libs/assets/src/svg/svgs/devices.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const DevicesIcon = svgIcon` +export const DevicesIcon = svg` diff --git a/libs/assets/src/svg/svgs/domain.icon.ts b/libs/assets/src/svg/svgs/domain.icon.ts index 04bd173be98..af47b1930d7 100644 --- a/libs/assets/src/svg/svgs/domain.icon.ts +++ b/libs/assets/src/svg/svgs/domain.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const DomainIcon = svgIcon` +export const DomainIcon = svg` diff --git a/libs/assets/src/svg/svgs/empty-trash.ts b/libs/assets/src/svg/svgs/empty-trash.ts index d6c0043d880..da48bd69c3e 100644 --- a/libs/assets/src/svg/svgs/empty-trash.ts +++ b/libs/assets/src/svg/svgs/empty-trash.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const EmptyTrash = svgIcon` +export const EmptyTrash = svg` diff --git a/libs/assets/src/svg/svgs/favorites.icon.ts b/libs/assets/src/svg/svgs/favorites.icon.ts index 4725d0b0a7c..8777eaeef88 100644 --- a/libs/assets/src/svg/svgs/favorites.icon.ts +++ b/libs/assets/src/svg/svgs/favorites.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const FavoritesIcon = svgIcon` +export const FavoritesIcon = svg` diff --git a/libs/assets/src/svg/svgs/gear.ts b/libs/assets/src/svg/svgs/gear.ts index 261c6d262e1..c04dc8e1a17 100644 --- a/libs/assets/src/svg/svgs/gear.ts +++ b/libs/assets/src/svg/svgs/gear.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const GearIcon = svgIcon` +export const GearIcon = svg` diff --git a/libs/assets/src/svg/svgs/generator.ts b/libs/assets/src/svg/svgs/generator.ts index 52368ddc204..26b09f19455 100644 --- a/libs/assets/src/svg/svgs/generator.ts +++ b/libs/assets/src/svg/svgs/generator.ts @@ -1,12 +1,12 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const GeneratorInactive = svgIcon` +export const GeneratorInactive = svg` `; -export const GeneratorActive = svgIcon` +export const GeneratorActive = svg` diff --git a/libs/assets/src/svg/svgs/item-types.ts b/libs/assets/src/svg/svgs/item-types.ts index 50ed51bd018..b066df72b0d 100644 --- a/libs/assets/src/svg/svgs/item-types.ts +++ b/libs/assets/src/svg/svgs/item-types.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ItemTypes = svgIcon` +export const ItemTypes = svg` diff --git a/libs/assets/src/svg/svgs/lock.icon.ts b/libs/assets/src/svg/svgs/lock.icon.ts index 9d73ad6294c..f42630739f1 100644 --- a/libs/assets/src/svg/svgs/lock.icon.ts +++ b/libs/assets/src/svg/svgs/lock.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const LockIcon = svgIcon` +export const LockIcon = svg` diff --git a/libs/assets/src/svg/svgs/login-cards.ts b/libs/assets/src/svg/svgs/login-cards.ts index 3a43b1a0121..13c456a1658 100644 --- a/libs/assets/src/svg/svgs/login-cards.ts +++ b/libs/assets/src/svg/svgs/login-cards.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const LoginCards = svgIcon` +export const LoginCards = svg` diff --git a/libs/assets/src/svg/svgs/no-credentials.icon.ts b/libs/assets/src/svg/svgs/no-credentials.icon.ts index bfecfd4834c..da7795db808 100644 --- a/libs/assets/src/svg/svgs/no-credentials.icon.ts +++ b/libs/assets/src/svg/svgs/no-credentials.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoCredentialsIcon = svgIcon` +export const NoCredentialsIcon = svg` diff --git a/libs/assets/src/svg/svgs/no-folders.ts b/libs/assets/src/svg/svgs/no-folders.ts index c8858ca83e5..7facc01e4d6 100644 --- a/libs/assets/src/svg/svgs/no-folders.ts +++ b/libs/assets/src/svg/svgs/no-folders.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoFolders = svgIcon` +export const NoFolders = svg` diff --git a/libs/assets/src/svg/svgs/no-results.ts b/libs/assets/src/svg/svgs/no-results.ts index 5f914ad213c..75ad485181f 100644 --- a/libs/assets/src/svg/svgs/no-results.ts +++ b/libs/assets/src/svg/svgs/no-results.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoResults = svgIcon` +export const NoResults = svg` diff --git a/libs/assets/src/svg/svgs/no-send.icon.ts b/libs/assets/src/svg/svgs/no-send.icon.ts index a246c0177f8..a7125caabf6 100644 --- a/libs/assets/src/svg/svgs/no-send.icon.ts +++ b/libs/assets/src/svg/svgs/no-send.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoSendsIcon = svgIcon` +export const NoSendsIcon = svg` diff --git a/libs/assets/src/svg/svgs/party.ts b/libs/assets/src/svg/svgs/party.ts index efa5331f4fc..991f4a3deda 100644 --- a/libs/assets/src/svg/svgs/party.ts +++ b/libs/assets/src/svg/svgs/party.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const Party = svgIcon` +export const Party = svg` diff --git a/libs/assets/src/svg/svgs/password-manager.ts b/libs/assets/src/svg/svgs/password-manager.ts index 17b6f148be3..aa7e8ecc52d 100644 --- a/libs/assets/src/svg/svgs/password-manager.ts +++ b/libs/assets/src/svg/svgs/password-manager.ts @@ -1,14 +1,14 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const PasswordManagerLogo = svgIcon` +const PasswordManagerLogo = svg` - + - + diff --git a/libs/assets/src/svg/svgs/provider-portal.ts b/libs/assets/src/svg/svgs/provider-portal.ts index 51c04e1553b..97d23633a9e 100644 --- a/libs/assets/src/svg/svgs/provider-portal.ts +++ b/libs/assets/src/svg/svgs/provider-portal.ts @@ -1,14 +1,14 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const ProviderPortalLogo = svgIcon` +const ProviderPortalLogo = svg` - + - + diff --git a/libs/assets/src/svg/svgs/registration-check-email.icon.ts b/libs/assets/src/svg/svgs/registration-check-email.icon.ts index ae4cf3098e6..006a60bc7c0 100644 --- a/libs/assets/src/svg/svgs/registration-check-email.icon.ts +++ b/libs/assets/src/svg/svgs/registration-check-email.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const RegistrationCheckEmailIcon = svgIcon` +export const RegistrationCheckEmailIcon = svg` diff --git a/libs/assets/src/svg/svgs/registration-user-add.icon.ts b/libs/assets/src/svg/svgs/registration-user-add.icon.ts index 7428daa5848..358412c38eb 100644 --- a/libs/assets/src/svg/svgs/registration-user-add.icon.ts +++ b/libs/assets/src/svg/svgs/registration-user-add.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const RegistrationUserAddIcon = svgIcon` +export const RegistrationUserAddIcon = svg` diff --git a/libs/assets/src/svg/svgs/report-breach.icon.ts b/libs/assets/src/svg/svgs/report-breach.icon.ts index 83dd6c72b82..e926388e333 100644 --- a/libs/assets/src/svg/svgs/report-breach.icon.ts +++ b/libs/assets/src/svg/svgs/report-breach.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ReportBreach = svgIcon` +export const ReportBreach = svg` diff --git a/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts b/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts index 0309eb643d9..590e7d7d1a1 100644 --- a/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts +++ b/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ReportExposedPasswords = svgIcon` +export const ReportExposedPasswords = svg` diff --git a/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts b/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts index 487381ccaa9..831a6570812 100644 --- a/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts +++ b/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ReportUnsecuredWebsites = svgIcon` +export const ReportUnsecuredWebsites = svg` diff --git a/libs/assets/src/svg/svgs/restricted-view.ts b/libs/assets/src/svg/svgs/restricted-view.ts index 5eec1a4a972..7bf40467ac6 100644 --- a/libs/assets/src/svg/svgs/restricted-view.ts +++ b/libs/assets/src/svg/svgs/restricted-view.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const RestrictedView = svgIcon` +export const RestrictedView = svg` diff --git a/libs/assets/src/svg/svgs/secrets-manager-alt.ts b/libs/assets/src/svg/svgs/secrets-manager-alt.ts index 98640803ca9..70fa7d6386c 100644 --- a/libs/assets/src/svg/svgs/secrets-manager-alt.ts +++ b/libs/assets/src/svg/svgs/secrets-manager-alt.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SecretsManagerAlt = svgIcon` +export const SecretsManagerAlt = svg` diff --git a/libs/assets/src/svg/svgs/secrets-manager.ts b/libs/assets/src/svg/svgs/secrets-manager.ts index 27589e7e2f9..3cd66df59e3 100644 --- a/libs/assets/src/svg/svgs/secrets-manager.ts +++ b/libs/assets/src/svg/svgs/secrets-manager.ts @@ -1,14 +1,14 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const SecretsManagerLogo = svgIcon` +const SecretsManagerLogo = svg` - + - + diff --git a/libs/assets/src/svg/svgs/security.ts b/libs/assets/src/svg/svgs/security.ts index 6e475b25ab7..119d0164599 100644 --- a/libs/assets/src/svg/svgs/security.ts +++ b/libs/assets/src/svg/svgs/security.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const Security = svgIcon` +export const Security = svg` diff --git a/libs/assets/src/svg/svgs/send.ts b/libs/assets/src/svg/svgs/send.ts index f09f59a5388..309844f9fd9 100644 --- a/libs/assets/src/svg/svgs/send.ts +++ b/libs/assets/src/svg/svgs/send.ts @@ -1,12 +1,12 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SendInactive = svgIcon` +export const SendInactive = svg` `; -export const SendActive = svgIcon` +export const SendActive = svg` diff --git a/libs/assets/src/svg/svgs/settings.ts b/libs/assets/src/svg/svgs/settings.ts index 3b54bbbd88c..b0e42821c6b 100644 --- a/libs/assets/src/svg/svgs/settings.ts +++ b/libs/assets/src/svg/svgs/settings.ts @@ -1,13 +1,13 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SettingsInactive = svgIcon` +export const SettingsInactive = svg` `; -export const SettingsActive = svgIcon` +export const SettingsActive = svg` diff --git a/libs/assets/src/svg/svgs/shield.ts b/libs/assets/src/svg/svgs/shield.ts index 38d429604aa..bd5f9e02d1d 100644 --- a/libs/assets/src/svg/svgs/shield.ts +++ b/libs/assets/src/svg/svgs/shield.ts @@ -1,13 +1,13 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const BitwardenShield = svgIcon` +const BitwardenShield = svg` - + - + diff --git a/libs/assets/src/svg/svgs/sso-key.icon.ts b/libs/assets/src/svg/svgs/sso-key.icon.ts index ad81c707449..d6e45b13b42 100644 --- a/libs/assets/src/svg/svgs/sso-key.icon.ts +++ b/libs/assets/src/svg/svgs/sso-key.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SsoKeyIcon = svgIcon` +export const SsoKeyIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts index 622875b59f2..11d2fafb745 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthAuthenticatorIcon = svgIcon` +export const TwoFactorAuthAuthenticatorIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts index 5bf43334d18..a40a6418885 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts @@ -1,8 +1,10 @@ -// this svg includes the Duo logo, which contains colors not part of our bitwarden theme colors /* eslint-disable @bitwarden/components/require-theme-colors-in-svg */ -import { svgIcon } from "../icon-service"; -export const TwoFactorAuthDuoIcon = svgIcon` +// this svg includes the Duo logo, which contains colors not part of our bitwarden theme colors + +import { svg } from "../svg"; + +export const TwoFactorAuthDuoIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts index 20709a8a1e1..8fdee85da82 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthEmailIcon = svgIcon` +export const TwoFactorAuthEmailIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts index 0e467bf1901..3eab3bb00c6 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthSecurityKeyFailedIcon = svgIcon` +export const TwoFactorAuthSecurityKeyFailedIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts index f10068b735b..830db83f3e8 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthSecurityKeyIcon = svgIcon` +export const TwoFactorAuthSecurityKeyIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts index b9114259584..9f0decb1f36 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthWebAuthnIcon = svgIcon` +export const TwoFactorAuthWebAuthnIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts index d4d38c363ae..6368442cde6 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts @@ -1,8 +1,9 @@ -// this svg includes the Yubico logo, which contains colors not part of our bitwarden theme colors /* eslint-disable @bitwarden/components/require-theme-colors-in-svg */ -import { svgIcon } from "../icon-service"; +// this svg includes the Yubico logo, which contains colors not part of our bitwarden theme colors -export const TwoFactorAuthYubicoIcon = svgIcon` +import { svg } from "../svg"; + +export const TwoFactorAuthYubicoIcon = svg` diff --git a/libs/assets/src/svg/svgs/unlocked.icon.ts b/libs/assets/src/svg/svgs/unlocked.icon.ts index 6ce40819e44..1a754733d26 100644 --- a/libs/assets/src/svg/svgs/unlocked.icon.ts +++ b/libs/assets/src/svg/svgs/unlocked.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const UnlockedIcon = svgIcon` +export const UnlockedIcon = svg` diff --git a/libs/assets/src/svg/svgs/user-lock.icon.ts b/libs/assets/src/svg/svgs/user-lock.icon.ts index cc848a05769..5deead382b3 100644 --- a/libs/assets/src/svg/svgs/user-lock.icon.ts +++ b/libs/assets/src/svg/svgs/user-lock.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const UserLockIcon = svgIcon` +export const UserLockIcon = svg` diff --git a/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts b/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts index 19e1aa3e6cd..c175bb78993 100644 --- a/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts +++ b/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const UserVerificationBiometricsIcon = svgIcon` +export const UserVerificationBiometricsIcon = svg` diff --git a/libs/assets/src/svg/svgs/vault-open.ts b/libs/assets/src/svg/svgs/vault-open.ts index 3ad82b9bbac..52e8a971d60 100644 --- a/libs/assets/src/svg/svgs/vault-open.ts +++ b/libs/assets/src/svg/svgs/vault-open.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const VaultOpen = svgIcon` +export const VaultOpen = svg` diff --git a/libs/assets/src/svg/svgs/vault.icon.ts b/libs/assets/src/svg/svgs/vault.icon.ts index 61ec2589b34..1f442ad0471 100644 --- a/libs/assets/src/svg/svgs/vault.icon.ts +++ b/libs/assets/src/svg/svgs/vault.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const VaultIcon = svgIcon` +export const VaultIcon = svg` diff --git a/libs/assets/src/svg/svgs/vault.ts b/libs/assets/src/svg/svgs/vault.ts index 1c699f2ba8e..8e1acab2670 100644 --- a/libs/assets/src/svg/svgs/vault.ts +++ b/libs/assets/src/svg/svgs/vault.ts @@ -1,13 +1,13 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const VaultInactive = svgIcon` +export const VaultInactive = svg` `; -export const VaultActive = svgIcon` +export const VaultActive = svg` diff --git a/libs/assets/src/svg/svgs/wave.icon.ts b/libs/assets/src/svg/svgs/wave.icon.ts index 6c97d0fbbb3..7b00ba0f3eb 100644 --- a/libs/assets/src/svg/svgs/wave.icon.ts +++ b/libs/assets/src/svg/svgs/wave.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const WaveIcon = svgIcon` +export const WaveIcon = svg` diff --git a/libs/auth/src/angular/input-password/input-password.component.ts b/libs/auth/src/angular/input-password/input-password.component.ts index 62294f037a0..b81e01156f1 100644 --- a/libs/auth/src/angular/input-password/input-password.component.ts +++ b/libs/auth/src/angular/input-password/input-password.component.ts @@ -10,7 +10,9 @@ import { import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; @@ -209,6 +211,7 @@ export class InputPasswordComponent implements OnInit { constructor( private auditService: AuditService, private cipherService: CipherService, + private configService: ConfigService, private dialogService: DialogService, private formBuilder: FormBuilder, private i18nService: I18nService, @@ -312,7 +315,7 @@ export class InputPasswordComponent implements OnInit { } if (!this.email) { - throw new Error("Email is required to create master key."); + throw new Error("Email not found."); } // 1. Determine kdfConfig @@ -320,13 +323,13 @@ export class InputPasswordComponent implements OnInit { this.kdfConfig = DEFAULT_KDF_CONFIG; } else { if (!this.userId) { - throw new Error("userId not passed down"); + throw new Error("userId not found."); } this.kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId)); } if (this.kdfConfig == null) { - throw new Error("KdfConfig is required to create master key."); + throw new Error("KdfConfig not found."); } const salt = @@ -334,7 +337,7 @@ export class InputPasswordComponent implements OnInit { ? await firstValueFrom(this.masterPasswordService.saltForUser$(this.userId)) : this.masterPasswordService.emailToSalt(this.email); if (salt == null) { - throw new Error("Salt is required to create master key."); + throw new Error("Salt not found."); } // 2. Verify current password is correct (if necessary) @@ -361,6 +364,41 @@ export class InputPasswordComponent implements OnInit { return; } + // When you unwind the flag in PM-28143, also remove the ConfigService if it is un-used. + const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword, + ); + + if (newApisWithInputPasswordFlagEnabled) { + // 4. Build a PasswordInputResult object + const passwordInputResult: PasswordInputResult = { + newPassword, + kdfConfig: this.kdfConfig, + salt, + newPasswordHint, + newApisWithInputPasswordFlagEnabled, // To be removed in PM-28143 + }; + + if ( + this.flow === InputPasswordFlow.ChangePassword || + this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation + ) { + passwordInputResult.currentPassword = currentPassword; + } + + if (this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) { + passwordInputResult.rotateUserKey = this.formGroup.controls.rotateUserKey?.value; + } + + // 5. Emit and return PasswordInputResult object + this.onPasswordFormSubmit.emit(passwordInputResult); + return passwordInputResult; + } + + /******************************************************************* + * The following code (within this `try`) to be removed in PM-28143 + *******************************************************************/ + // 4. Create cryptographic keys and build a PasswordInputResult object const newMasterKey = await this.keyService.makeMasterKey( newPassword, diff --git a/libs/auth/src/angular/input-password/input-password.mdx b/libs/auth/src/angular/input-password/input-password.mdx index e3cdcbf08b9..4b174658d16 100644 --- a/libs/auth/src/angular/input-password/input-password.mdx +++ b/libs/auth/src/angular/input-password/input-password.mdx @@ -6,14 +6,12 @@ import * as stories from "./input-password.stories.ts"; # InputPassword Component -The `InputPasswordComponent` allows a user to enter master password related credentials. -Specifically, it does the following: +The `InputPasswordComponent` allows a user to enter a new master password for the purpose of setting +an initial password or changing an existing password. Specifically, it does the following: 1. Displays form fields in the UI 2. Validates form fields -3. Generates cryptographic properties based on the form inputs (e.g. `newMasterKey`, - `newServerMasterKeyHash`, etc.) -4. Emits the generated properties to the parent component +3. Emits values to the parent component The `InputPasswordComponent` is central to our set/change password flows, allowing us to keep our form UI and validation logic consistent. As such, it is intended for re-use in different set/change @@ -30,7 +28,6 @@ those values as needed. - [The InputPasswordFlow](#the-inputpasswordflow) - [Use Cases](#use-cases) - [HTML - Form Fields](#html---form-fields) - - [TypeScript - Credential Generation](#typescript---credential-generation) - [Difference between SetInitialPasswordAccountRegistration and SetInitialPasswordAuthedUser](#difference-between-setinitialpasswordaccountregistration-and-setinitialpasswordautheduser) - [Validation](#validation) - [Submit Logic](#submit-logic) @@ -44,20 +41,20 @@ those values as needed. **Required** - `flow` - the parent component must provide an `InputPasswordFlow`, which is used to determine - which form input elements will be displayed in the UI and which cryptographic keys will be created - and emitted. [Click here](#the-inputpasswordflow) to learn more about the different - `InputPasswordFlow` options. + which form input elements will be displayed in the UI and which values will be emitted. + [Click here](#the-inputpasswordflow) to learn more about the different `InputPasswordFlow` + options. **Optional (sometimes)** -These two `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs` -are not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that -the `email` and/or `userId` is present in certain flows, while not present in other flows. +These `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs` are +not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that the +`email` and/or `userId` is present in certain flows, while not present in other flows. -- `email` - allows the `InputPasswordComponent` to generate a master key +- `email` - allows the `InputPasswordComponent` to use the email as a salt (if needed) - `userId` - allows the `InputPasswordComponent` to do things like get the user's `kdfConfig`, - verify that a current password is correct, and perform validation prior to user key rotation on - the parent + verify that a current password is correct, and perform validation prior to user key rotation (if + selected) on the parent **Optional** @@ -87,8 +84,7 @@ These `@Inputs` are truly optional. ## The `InputPasswordFlow` The `InputPasswordFlow` is a crucial and required `@Input` that influences both the HTML and the -credential generation logic of the component. It is important for the dev to understand when to use -each flow. +logic of the component. It is important for the dev to understand when to use each flow. ### Use Cases @@ -106,8 +102,9 @@ Used in scenarios where we do have an existing and authed user, and thus an acti - A "just-in-time" (JIT) provisioned user joins a master password (MP) encryption org and must set their initial password -- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with a - starting role that requires them to have/set their initial password +- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with the reset + password permission ("manage account recovery") from the start, which requires them to have/set + their initial password - A note on JIT provisioned user flows: - Even though a JIT provisioned user is a brand-new user who was “just” created, we consider them to be an “existing authed user” _from the perspective of the set-password flow_. This is @@ -117,8 +114,9 @@ Used in scenarios where we do have an existing and authed user, and thus an acti registration when a user reaches the `/finish-signup` or `/trial-initiation` page to set their initial password, their account does not yet exist in the database, and will only be created once they set an initial password. -- An existing user in a TDE org logs in after the org admin upgraded the user to a role that now - requires them to have/set their initial password +- An existing user in a TDE org logs in after an org admin upgraded the user to have the reset + password persmission ("manage account recovery"), which now requires the user to have/set their + initial password - An existing user logs in after their org admin offboarded the org from TDE, and the user must now have/set their initial password

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

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

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

{{ "verifyWithBiometrics" | i18n }}

diff --git a/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts b/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts index 296359c92ff..af73cc3de99 100644 --- a/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts +++ b/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts @@ -28,7 +28,7 @@ import { CalloutModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, } from "@bitwarden/components"; @@ -64,7 +64,7 @@ import { ActiveClientVerificationOption } from "./active-client-verification-opt FormFieldModule, AsyncActionsModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, ButtonModule, CalloutModule, diff --git a/libs/auth/src/common/login-strategies/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 3ec324404cb..7f38f5f09f4 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts @@ -72,14 +72,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, @@ -98,20 +99,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/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..954b4d913d2 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,9 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore +import { UnsignedSharedKey } from "@bitwarden/sdk-internal"; + export class ProviderUserConfirmRequest { - key: string; + protected key: string; + + constructor(key: UnsignedSharedKey) { + 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 4294a519502..3cf64633c30 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 @@ -8,19 +8,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: UnsignedSharedKey; + 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 = this.getResponseProperty("EncryptedUserKey") as UnsignedSharedKey; + + const encUserKey = this.getResponseProperty("EncryptedUserKey"); + if (encUserKey) { + this.encryptedUserKey = encUserKey as UnsignedSharedKey; } + + 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/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 ab8fe5decd8..9941e7671f4 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -13,15 +13,20 @@ export enum FeatureFlag { /* Admin Console Team */ AutoConfirm = "pm-19934-auto-confirm-organization-users", BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration", - IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud", + DefaultUserCollectionRestore = "pm-30883-my-items-restored-users", + MembersComponentRefactor = "pm-29503-refactor-members-inheritance", /* Auth */ PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin", + PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password", + SafariAccountSwitching = "pm-5594-safari-account-switching", /* Autofill */ + UseUndeterminedCipherScenarioTriggeringLogic = "undetermined-cipher-scenario-logic", MacOsNativeCredentialSync = "macos-native-credential-sync", WindowsDesktopAutotype = "windows-desktop-autotype", WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga", + SSHAgentV2 = "ssh-agent-v2", /* Billing */ TrialPaymentOptional = "PM-8163-trial-payment", @@ -38,16 +43,16 @@ export enum FeatureFlag { PrivateKeyRegeneration = "pm-12241-private-key-regeneration", EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation", ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings", - 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", @@ -55,26 +60,28 @@ export enum FeatureFlag { /* DIRT */ EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike", + EventManagementForHuntress = "event-management-for-huntress", PhishingDetection = "phishing-detection", + Milestone11AppPageImprovements = "pm-30538-dirt-milestone-11-app-page-improvements", /* Vault */ PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view", PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption", CipherKeyEncryption = "cipher-key-encryption", - RiskInsightsForPremium = "pm-23904-risk-insights-for-premium", - VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders", BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight", MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems", + PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk", /* Platform */ - IpcChannelFramework = "ipc-channel-framework", + ContentScriptIpcChannelFramework = "content-script-ipc-channel-framework", /* Innovation */ PM19148_InnovationArchive = "pm-19148-innovation-archive", /* Desktop */ DesktopUiMigrationMilestone1 = "desktop-ui-migration-milestone-1", + DesktopUiMigrationMilestone2 = "desktop-ui-migration-milestone-2", /* UIF */ RouterFocusManagement = "router-focus-management", @@ -100,15 +107,17 @@ export const DefaultFeatureFlagValue = { /* Admin Console Team */ [FeatureFlag.AutoConfirm]: FALSE, [FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE, - [FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE, + [FeatureFlag.DefaultUserCollectionRestore]: FALSE, + [FeatureFlag.MembersComponentRefactor]: FALSE, /* Autofill */ + [FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic]: FALSE, [FeatureFlag.MacOsNativeCredentialSync]: FALSE, [FeatureFlag.WindowsDesktopAutotype]: FALSE, [FeatureFlag.WindowsDesktopAutotypeGA]: FALSE, + [FeatureFlag.SSHAgentV2]: FALSE, /* Tools */ - [FeatureFlag.DesktopSendUIRefresh]: FALSE, [FeatureFlag.UseSdkPasswordGenerators]: FALSE, [FeatureFlag.ChromiumImporterWithABE]: FALSE, [FeatureFlag.SendUIRefresh]: FALSE, @@ -116,20 +125,23 @@ export const DefaultFeatureFlagValue = { /* DIRT */ [FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE, + [FeatureFlag.EventManagementForHuntress]: FALSE, [FeatureFlag.PhishingDetection]: FALSE, + [FeatureFlag.Milestone11AppPageImprovements]: FALSE, /* Vault */ [FeatureFlag.CipherKeyEncryption]: FALSE, [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, [FeatureFlag.PM22134SdkCipherListView]: FALSE, [FeatureFlag.PM22136_SdkCipherEncryption]: FALSE, - [FeatureFlag.RiskInsightsForPremium]: FALSE, - [FeatureFlag.VaultLoadingSkeletons]: FALSE, [FeatureFlag.BrowserPremiumSpotlight]: FALSE, + [FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE, [FeatureFlag.MigrateMyVaultToMyItems]: FALSE, /* Auth */ [FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE, + [FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword]: FALSE, + [FeatureFlag.SafariAccountSwitching]: FALSE, /* Billing */ [FeatureFlag.TrialPaymentOptional]: FALSE, @@ -146,22 +158,24 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PrivateKeyRegeneration]: FALSE, [FeatureFlag.EnrollAeadOnKeyRotation]: FALSE, [FeatureFlag.ForceUpdateKDFSettings]: 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, /* 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..645666c582d 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 3e9705e08bd..7302e78a303 100644 --- a/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts +++ b/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts @@ -1,4 +1,3 @@ -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { UnsignedSharedKey } from "@bitwarden/sdk-internal"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; @@ -6,12 +5,6 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr 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 * @param plainValue - The value to encrypt 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 ef8e9862d88..29ff311036e 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, UnsignedSharedKey } 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 4c0453a4266..5d677504ce7 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/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..284555052dd 100644 --- a/libs/common/src/platform/services/fido2/domain-utils.spec.ts +++ b/libs/common/src/platform/services/fido2/domain-utils.spec.ts @@ -2,6 +2,18 @@ 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 null", () => { + const origin = "example.com"; + + expect(isValidRpId(null, origin)).toBe(false); + }); + + it("should not be valid when origin is null", () => { + const rpId = "example.com"; + + expect(isValidRpId(rpId, null)).toBe(false); + }); + 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"; @@ -25,7 +37,7 @@ describe("validateRpId", () => { it("should not be valid when rpId and origin are both different TLD", () => { const rpId = "bitwarden"; - const origin = "localhost"; + const origin = "https://localhost"; expect(isValidRpId(rpId, origin)).toBe(false); }); @@ -34,14 +46,14 @@ describe("validateRpId", () => { // 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"; + const origin = "https://bitwarden"; expect(isValidRpId(rpId, origin)).toBe(false); }); it("should not be valid when rpId and origin are ip-addresses", () => { const rpId = "127.0.0.1"; - const origin = "127.0.0.1"; + const origin = "https://127.0.0.1"; expect(isValidRpId(rpId, origin)).toBe(false); }); @@ -80,4 +92,11 @@ describe("validateRpId", () => { expect(isValidRpId(rpId, origin)).toBe(true); }); + + it("should not be valid for a partial match of a subdomain", () => { + const rpId = "accounts.example.com"; + const origin = "https://evilaccounts.example.com"; + + expect(isValidRpId(rpId, origin)).toBe(false); + }); }); diff --git a/libs/common/src/platform/services/fido2/domain-utils.ts b/libs/common/src/platform/services/fido2/domain-utils.ts index 67874355908..542beae3435 100644 --- a/libs/common/src/platform/services/fido2/domain-utils.ts +++ b/libs/common/src/platform/services/fido2/domain-utils.ts @@ -1,17 +1,78 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { parse } from "tldts"; +/** + * Validates whether a Relying Party ID (rpId) is valid for a given origin according to WebAuthn specifications. + * + * 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://) + * @returns `true` if the rpId is valid for the given origin, `false` otherwise + * + */ export function isValidRpId(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; + } + + return false; } 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.ts b/libs/common/src/platform/services/fido2/fido2-client.service.ts index 503ffef8241..2aa618e974d 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.ts @@ -30,7 +30,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"; @@ -195,7 +194,7 @@ export class Fido2ClientService< } const timeoutSubscription = this.setAbortTimeout( abortController, - params.authenticatorSelection?.userVerification, + makeCredentialParams.requireUserVerification, params.timeout, ); @@ -318,7 +317,7 @@ export class Fido2ClientService< const timeoutSubscription = this.setAbortTimeout( abortController, - params.userVerification, + getAssertionParams.requireUserVerification, params.timeout, ); @@ -441,13 +440,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.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts index 5084f5f5f18..e2c9c77e204 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -80,7 +80,7 @@ export class DefaultSdkService implements SdkService { client$ = this.environmentService.environment$.pipe( concatMap(async (env) => { await SdkLoadService.Ready; - const settings = this.toSettings(env); + const settings = await this.toSettings(env); const client = await this.sdkClientFactory.createSdkClient( new JsTokenProvider(this.apiService), settings, @@ -210,7 +210,7 @@ export class DefaultSdkService implements SdkService { return undefined; } - const settings = this.toSettings(env); + const settings = await this.toSettings(env); const client = await this.sdkClientFactory.createSdkClient( new JsTokenProvider(this.apiService, userId), settings, @@ -322,11 +322,12 @@ export class DefaultSdkService implements SdkService { client.platform().load_flags(featureFlagMap); } - private toSettings(env: Environment): ClientSettings { + private async toSettings(env: Environment): Promise { return { apiUrl: env.getApiUrl(), identityUrl: env.getIdentityUrl(), deviceType: toSdkDevice(this.platformUtilsService.getDevice()), + bitwardenClientVersion: await this.platformUtilsService.getApplicationVersionNumber(), userAgent: this.userAgent ?? navigator.userAgent, }; } diff --git a/libs/common/src/platform/services/sdk/register-sdk.service.ts b/libs/common/src/platform/services/sdk/register-sdk.service.ts index a222807640f..073c5c0560c 100644 --- a/libs/common/src/platform/services/sdk/register-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/register-sdk.service.ts @@ -62,7 +62,7 @@ export class DefaultRegisterSdkService implements RegisterSdkService { client$ = this.environmentService.environment$.pipe( concatMap(async (env) => { await SdkLoadService.Ready; - const settings = this.toSettings(env); + const settings = await this.toSettings(env); const client = await this.sdkClientFactory.createSdkClient( new JsTokenProvider(this.apiService), settings, @@ -137,7 +137,7 @@ export class DefaultRegisterSdkService implements RegisterSdkService { return undefined; } - const settings = this.toSettings(env); + const settings = await this.toSettings(env); const client = await this.sdkClientFactory.createSdkClient( new JsTokenProvider(this.apiService, userId), settings, @@ -185,12 +185,13 @@ export class DefaultRegisterSdkService implements RegisterSdkService { client.platform().load_flags(featureFlagMap); } - private toSettings(env: Environment): ClientSettings { + private async toSettings(env: Environment): Promise { return { apiUrl: env.getApiUrl(), identityUrl: env.getIdentityUrl(), deviceType: toSdkDevice(this.platformUtilsService.getDevice()), userAgent: this.userAgent ?? navigator.userAgent, + bitwardenClientVersion: await this.platformUtilsService.getApplicationVersionNumber(), }; } } diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts index fc83954ee7d..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..a25b1b3c210 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, @@ -178,6 +183,8 @@ export class DefaultSyncService extends CoreSyncService { const response = await this.inFlightApiCalls.sync; + await this.cipherService.clear(response.profile.id); + await this.syncUserDecryption(response.profile.id, response.userDecryption); await this.syncProfile(response.profile); await this.syncFolders(response.folders, response.profile.id); @@ -245,29 +252,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 +443,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 8839ea8ca50..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"; 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 bfa72b04087..4081eba2878 100644 --- a/libs/common/src/tools/send/models/data/send.data.ts +++ b/libs/common/src/tools/send/models/data/send.data.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { AuthType } from "../../types/auth-type"; import { SendType } from "../../types/send-type"; import { SendResponse } from "../response/send.response"; @@ -22,8 +23,10 @@ export class SendData { deletionDate: string; password: string; emails: string; + emailHashes: string; disabled: boolean; hideEmail: boolean; + authType: AuthType; constructor(response?: SendResponse) { if (response == null) { @@ -33,6 +36,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; @@ -43,8 +47,10 @@ export class SendData { this.deletionDate = response.deletionDate; this.password = response.password; this.emails = response.emails; + this.emailHashes = ""; this.disabled = response.disable; this.hideEmail = response.hideEmail; + this.authType = response.authType; switch (this.type) { case SendType.Text: diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index b0cfd200483..f660333c917 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -1,6 +1,7 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { Send } from "@bitwarden/common/tools/send/models/domain/send"; import { emptyGuid, UserId } from "@bitwarden/common/types/guid"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports @@ -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 { 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,11 @@ describe("Send", () => { expirationDate: "2022-01-31T12:00:00.000Z", deletionDate: "2022-01-31T12:00:00.000Z", password: "password", - emails: null!, + emails: "", + emailHashes: "", disabled: false, hideEmail: true, + authType: AuthType.None, }; mockContainerService(); @@ -55,6 +58,7 @@ describe("Send", () => { id: null, accessId: null, type: undefined, + authType: undefined, name: null, notes: null, text: undefined, @@ -66,6 +70,8 @@ describe("Send", () => { expirationDate: null, deletionDate: null, password: undefined, + emails: null, + emailHashes: undefined, disabled: undefined, hideEmail: undefined, }); @@ -91,9 +97,11 @@ describe("Send", () => { expirationDate: new Date("2022-01-31T12:00:00.000Z"), deletionDate: new Date("2022-01-31T12:00:00.000Z"), password: "password", - emails: null!, + emails: null, + emailHashes: "", disabled: false, hideEmail: true, + authType: AuthType.None, }); }); @@ -107,6 +115,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 +125,22 @@ describe("Send", () => { send.expirationDate = new Date("2022-01-31T12:00:00.000Z"); send.deletionDate = new Date("2022-01-31T12:00:00.000Z"); send.password = "password"; + send.emails = null; send.disabled = false; send.hideEmail = true; + send.authType = AuthType.None; const encryptService = mock(); const keyService = mock(); encryptService.decryptBytes .calledWith(send.key, userKey) .mockResolvedValue(makeStaticByteArray(32)); + encryptService.decryptString + .calledWith(send.name, "cryptoKey" as any) + .mockResolvedValue("name"); + encryptService.decryptString + .calledWith(send.notes, "cryptoKey" as any) + .mockResolvedValue("notes"); keyService.makeSendKey.mockResolvedValue("cryptoKey" as any); keyService.userKey$.calledWith(userId).mockReturnValue(of(userKey)); @@ -132,12 +149,6 @@ describe("Send", () => { const view = await send.decrypt(userId); expect(text.decrypt).toHaveBeenNthCalledWith(1, "cryptoKey"); - expect(send.name.decrypt).toHaveBeenNthCalledWith( - 1, - null, - "cryptoKey", - "Property: name; ObjectContext: No Domain Context", - ); expect(view).toMatchObject({ id: "id", @@ -155,8 +166,265 @@ describe("Send", () => { expirationDate: new Date("2022-01-31T12:00:00.000Z"), deletionDate: new Date("2022-01-31T12:00:00.000Z"), password: "password", + emails: [], disabled: false, hideEmail: true, + authType: AuthType.None, + }); + }); + + describe("Email decryption", () => { + let encryptService: jest.Mocked; + let keyService: jest.Mocked; + const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const userId = emptyGuid as UserId; + + beforeEach(() => { + encryptService = mock(); + keyService = mock(); + encryptService.decryptBytes.mockResolvedValue(makeStaticByteArray(32)); + keyService.makeSendKey.mockResolvedValue("cryptoKey" as any); + keyService.userKey$.mockReturnValue(of(userKey)); + (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); + }); + + it("should decrypt and parse single email", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc("test@example.com"); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve("test@example.com"); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(encryptService.decryptString).toHaveBeenCalledWith(send.emails, "cryptoKey"); + expect(view.emails).toEqual(["test@example.com"]); + }); + + it("should decrypt and parse multiple emails", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc("test@example.com,user@test.com,admin@domain.com"); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve("test@example.com,user@test.com,admin@domain.com"); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual(["test@example.com", "user@test.com", "admin@domain.com"]); + }); + + it("should trim whitespace from decrypted emails", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc(" test@example.com , user@test.com "); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve(" test@example.com , user@test.com "); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual(["test@example.com", "user@test.com"]); + }); + + it("should return empty array when emails is null", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = null; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual([]); + expect(encryptService.decryptString).not.toHaveBeenCalledWith(expect.anything(), "cryptoKey"); + }); + + it("should return empty array when decrypted emails is empty string", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc(""); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve(""); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual([]); + }); + + it("should return empty array when decrypted emails is null", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc("something"); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve(null); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual([]); + }); + }); + + describe("Null handling for name and notes decryption", () => { + let encryptService: jest.Mocked; + let keyService: jest.Mocked; + const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const userId = emptyGuid as UserId; + + beforeEach(() => { + encryptService = mock(); + keyService = mock(); + encryptService.decryptBytes.mockResolvedValue(makeStaticByteArray(32)); + keyService.makeSendKey.mockResolvedValue("cryptoKey" as any); + keyService.userKey$.mockReturnValue(of(userKey)); + (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); + }); + + it("should return null for name when name is null", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = null; + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = null; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + const view = await send.decrypt(userId); + + expect(view.name).toBeNull(); + expect(encryptService.decryptString).not.toHaveBeenCalledWith(null, expect.anything()); + }); + + it("should return null for notes when notes is null", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = null; + send.key = mockEnc("key"); + send.emails = null; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + const view = await send.decrypt(userId); + + expect(view.notes).toBeNull(); + }); + + it("should decrypt non-null name and notes", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("Test Name"); + send.notes = mockEnc("Test Notes"); + send.key = mockEnc("key"); + send.emails = null; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.name) { + return Promise.resolve("Test Name"); + } + if (encString === send.notes) { + return Promise.resolve("Test Notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.name).toBe("Test Name"); + expect(view.notes).toBe("Test Notes"); }); }); }); diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index b85509183b0..5247d35c655 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -8,6 +8,7 @@ import { UserId } from "@bitwarden/common/types/guid"; import { EncString } from "../../../../key-management/crypto/models/enc-string"; import { Utils } from "../../../../platform/misc/utils"; import Domain from "../../../../platform/models/domain/domain-base"; +import { AuthType } from "../../types/auth-type"; import { SendType } from "../../types/send-type"; import { SendData } from "../data/send.data"; import { SendView } from "../view/send.view"; @@ -30,9 +31,11 @@ export class Send extends Domain { expirationDate: Date; deletionDate: Date; password: string; - emails: string; + emails: EncString; + emailHashes: string; disabled: boolean; hideEmail: boolean; + authType: AuthType; constructor(obj?: SendData) { super(); @@ -49,20 +52,23 @@ export class Send extends Domain { name: null, notes: null, key: null, + emails: null, }, ["id", "accessId"], ); 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.emailHashes = obj.emailHashes; this.disabled = obj.disabled; this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null; this.deletionDate = obj.deletionDate != null ? new Date(obj.deletionDate) : null; this.expirationDate = obj.expirationDate != null ? new Date(obj.expirationDate) : null; this.hideEmail = obj.hideEmail; + this.authType = obj.authType; switch (this.type) { case SendType.Text: @@ -88,8 +94,17 @@ export class Send extends Domain { // model.key is a seed used to derive a key, not a SymmetricCryptoKey model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey); model.cryptoKey = await keyService.makeSendKey(model.key); + model.name = + this.name != null ? await encryptService.decryptString(this.name, model.cryptoKey) : null; + model.notes = + this.notes != null ? await encryptService.decryptString(this.notes, model.cryptoKey) : null; - await this.decryptObj(this, model, ["name", "notes"], model.cryptoKey); + if (this.emails != null) { + const decryptedEmails = await encryptService.decryptString(this.emails, model.cryptoKey); + model.emails = decryptedEmails ? decryptedEmails.split(",").map((e) => e.trim()) : []; + } else { + model.emails = []; + } switch (this.type) { case SendType.File: @@ -118,6 +133,7 @@ export class Send extends Domain { key: EncString.fromJSON(obj.key), name: EncString.fromJSON(obj.name), notes: EncString.fromJSON(obj.notes), + emails: EncString.fromJSON(obj.emails), text: SendText.fromJSON(obj.text), file: SendFile.fromJSON(obj.file), revisionDate, diff --git a/libs/common/src/tools/send/models/request/send.request.spec.ts b/libs/common/src/tools/send/models/request/send.request.spec.ts new file mode 100644 index 00000000000..1daee1d01ff --- /dev/null +++ b/libs/common/src/tools/send/models/request/send.request.spec.ts @@ -0,0 +1,192 @@ +import { Send } from "@bitwarden/common/tools/send/models/domain/send"; + +import { EncString } from "../../../../key-management/crypto/models/enc-string"; +import { SendType } from "../../types/send-type"; +import { SendText } from "../domain/send-text"; + +import { SendRequest } from "./send.request"; + +describe("SendRequest", () => { + describe("constructor", () => { + it("should populate emails with encrypted string from Send.emails", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.notes = new EncString("encryptedNotes"); + send.key = new EncString("encryptedKey"); + send.emails = new EncString("encryptedEmailList"); + send.emailHashes = "HASH1,HASH2,HASH3"; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.emails).toBe("encryptedEmailList"); + }); + + it("should populate emailHashes from Send.emailHashes", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.notes = new EncString("encryptedNotes"); + send.key = new EncString("encryptedKey"); + send.emails = new EncString("encryptedEmailList"); + send.emailHashes = "HASH1,HASH2,HASH3"; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.emailHashes).toBe("HASH1,HASH2,HASH3"); + }); + + it("should set emails to null when Send.emails is null", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.notes = new EncString("encryptedNotes"); + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.emails).toBeNull(); + expect(request.emailHashes).toBe(""); + }); + + it("should handle empty emailHashes", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.emailHashes).toBe(""); + }); + + it("should not expose plaintext emails", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.key = new EncString("encryptedKey"); + send.emails = new EncString("2.encrypted|emaildata|here"); + send.emailHashes = "ABC123,DEF456"; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + // Ensure the request contains the encrypted string format, not plaintext + expect(request.emails).toBe("2.encrypted|emaildata|here"); + expect(request.emails).not.toContain("@"); + }); + + it("should handle name being null", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = null; + send.notes = new EncString("encryptedNotes"); + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.name).toBeNull(); + }); + + it("should handle notes being null", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.notes = null; + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.notes).toBeNull(); + }); + + it("should include fileLength when provided for text send", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send, 1024); + + expect(request.fileLength).toBe(1024); + }); + }); + + describe("Email auth requirements", () => { + it("should create request with encrypted emails and plaintext emailHashes", () => { + // Setup: A Send with encrypted emails and computed hashes + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.key = new EncString("encryptedKey"); + send.emails = new EncString("2.encryptedEmailString|data"); + send.emailHashes = "A1B2C3D4,E5F6G7H8"; // Plaintext hashes + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + // Act: Create the request + const request = new SendRequest(send); + + // emails field contains encrypted value + expect(request.emails).toBe("2.encryptedEmailString|data"); + expect(request.emails).toContain("encrypted"); + + //emailHashes field contains plaintext comma-separated hashes + expect(request.emailHashes).toBe("A1B2C3D4,E5F6G7H8"); + expect(request.emailHashes).not.toContain("encrypted"); + expect(request.emailHashes.split(",")).toHaveLength(2); + }); + }); +}); diff --git a/libs/common/src/tools/send/models/request/send.request.ts b/libs/common/src/tools/send/models/request/send.request.ts index 902ca0a2c54..37590e40108 100644 --- a/libs/common/src/tools/send/models/request/send.request.ts +++ b/libs/common/src/tools/send/models/request/send.request.ts @@ -18,6 +18,7 @@ export class SendRequest { file: SendFileApi; password: string; emails: string; + emailHashes: string; disabled: boolean; hideEmail: boolean; @@ -31,7 +32,8 @@ export class SendRequest { this.deletionDate = send.deletionDate != null ? send.deletionDate.toISOString() : null; this.key = send.key != null ? send.key.encryptedString : null; this.password = send.password; - this.emails = send.emails; + this.emails = send.emails ? send.emails.encryptedString : null; + this.emailHashes = send.emailHashes; this.disabled = send.disabled; this.hideEmail = send.hideEmail; diff --git a/libs/common/src/tools/send/models/response/send.response.ts b/libs/common/src/tools/send/models/response/send.response.ts index 6bbaf91ebe8..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 "../../types/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.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index 1bb3b527a73..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,6 +4,7 @@ import { View } from "../../../../models/view/view"; import { Utils } from "../../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { DeepJsonify } from "../../../../types/deep-jsonify"; +import { AuthType } from "../../types/auth-type"; import { SendType } from "../../types/send-type"; import { Send } from "../domain/send"; @@ -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 1c931b7ad98..57004b6ff0e 100644 --- a/libs/common/src/tools/send/services/send-api.service.ts +++ b/libs/common/src/tools/send/services/send-api.service.ts @@ -1,3 +1,5 @@ +import { SendAccessToken } from "@bitwarden/common/auth/send-access"; + import { ApiService } from "../../../abstractions/api.service"; import { ErrorResponse } from "../../../models/response/error.response"; import { ListResponse } from "../../../models/response/list.response"; @@ -52,6 +54,25 @@ export class SendApiService implements SendApiServiceAbstraction { return new SendAccessResponse(r); } + async postSendAccessV2( + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise { + const setAuthTokenHeader = (headers: Headers) => { + headers.set("Authorization", "Bearer " + accessToken.token); + }; + const r = await this.apiService.send( + "POST", + "/sends/access", + null, + false, + true, + apiUrl, + setAuthTokenHeader, + ); + return new SendAccessResponse(r); + } + async getSendFileDownloadData( send: SendAccessView, request: SendAccessRequest, @@ -72,6 +93,26 @@ export class SendApiService implements SendApiServiceAbstraction { return new SendFileDownloadDataResponse(r); } + async getSendFileDownloadDataV2( + send: SendAccessView, + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise { + const setAuthTokenHeader = (headers: Headers) => { + headers.set("Authorization", "Bearer " + accessToken.token); + }; + const r = await this.apiService.send( + "POST", + "/sends/access/file/" + send.file.id, + null, + true, + true, + apiUrl, + setAuthTokenHeader, + ); + return new SendFileDownloadDataResponse(r); + } + async getSends(): Promise> { const r = await this.apiService.send("GET", "/sends", null, true, true); return new ListResponse(r, SendResponse); @@ -148,6 +189,7 @@ export class SendApiService implements SendApiServiceAbstraction { private async upload(sendData: [Send, EncArrayBuffer]): Promise { const request = new SendRequest(sendData[0], sendData[1]?.buffer.byteLength); + let response: SendResponse; if (sendData[0].id == null) { if (sendData[0].type === SendType.Text) { diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index fb99ddbe3bc..1c587327098 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -1,6 +1,7 @@ import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; @@ -16,6 +17,7 @@ import { import { KeyGenerationService } from "../../../key-management/crypto"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; import { EncString } from "../../../key-management/crypto/models/enc-string"; +import { ConfigService } from "../../../platform/abstractions/config/config.service"; import { EnvironmentService } from "../../../platform/abstractions/environment.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { Utils } from "../../../platform/misc/utils"; @@ -29,6 +31,7 @@ import { SendTextApi } from "../models/api/send-text.api"; import { SendFileData } from "../models/data/send-file.data"; import { SendTextData } from "../models/data/send-text.data"; import { SendData } from "../models/data/send.data"; +import { SendTextView } from "../models/view/send-text.view"; import { SendView } from "../models/view/send.view"; import { SendType } from "../types/send-type"; @@ -48,7 +51,8 @@ describe("SendService", () => { const keyGenerationService = mock(); const encryptService = mock(); const environmentService = mock(); - + const cryptoFunctionService = mock(); + const configService = mock(); let sendStateProvider: SendStateProvider; let sendService: SendService; @@ -94,6 +98,8 @@ describe("SendService", () => { keyGenerationService, sendStateProvider, encryptService, + cryptoFunctionService, + configService, ); }); @@ -573,4 +579,256 @@ describe("SendService", () => { expect(sendsAfterDelete.length).toBe(0); }); }); + + describe("encrypt", () => { + let sendView: SendView; + const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const mockCryptoKey = new SymmetricCryptoKey(new Uint8Array(32)); + + beforeEach(() => { + sendView = new SendView(); + sendView.id = "sendId"; + sendView.type = SendType.Text; + sendView.name = "Test Send"; + sendView.notes = "Test Notes"; + const sendTextView = new SendTextView(); + sendTextView.text = "test text"; + sendTextView.hidden = false; + sendView.text = sendTextView; + sendView.key = new Uint8Array(16); + sendView.cryptoKey = mockCryptoKey; + sendView.maxAccessCount = 5; + sendView.disabled = false; + sendView.hideEmail = false; + sendView.deletionDate = new Date("2024-12-31"); + sendView.expirationDate = null; + + keyService.userKey$.mockReturnValue(of(userKey)); + keyService.makeSendKey.mockResolvedValue(mockCryptoKey); + encryptService.encryptBytes.mockResolvedValue({ encryptedString: "encryptedKey" } as any); + encryptService.encryptString.mockResolvedValue({ encryptedString: "encrypted" } as any); + }); + + describe("when SendEmailOTP feature flag is ON", () => { + beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(true); + cryptoFunctionService.hash.mockClear(); + }); + + describe("email encryption", () => { + it("should encrypt emails when email list is provided", async () => { + sendView.emails = ["test@example.com", "user@test.com"]; + cryptoFunctionService.hash.mockResolvedValue(new Uint8Array([0xab, 0xcd])); + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(encryptService.encryptString).toHaveBeenCalledWith( + "test@example.com,user@test.com", + mockCryptoKey, + ); + expect(send.emails).toEqual({ encryptedString: "encrypted" }); + expect(send.password).toBeNull(); + }); + + it("should set emails to null when email list is empty", async () => { + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + }); + + it("should set emails to null when email list is null", async () => { + sendView.emails = null; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + }); + + it("should set emails to null when email list is undefined", async () => { + sendView.emails = undefined; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + }); + }); + + describe("email hashing", () => { + it("should hash emails using SHA-256 and return uppercase hex", async () => { + sendView.emails = ["test@example.com"]; + const mockHash = new Uint8Array([0xab, 0xcd, 0xef]); + + cryptoFunctionService.hash.mockResolvedValue(mockHash); + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256"); + expect(send.emailHashes).toBe("ABCDEF"); + }); + + it("should hash multiple emails and return comma-separated hashes", async () => { + sendView.emails = ["test@example.com", "user@test.com"]; + const mockHash1 = new Uint8Array([0xab, 0xcd]); + const mockHash2 = new Uint8Array([0x12, 0x34]); + + cryptoFunctionService.hash + .mockResolvedValueOnce(mockHash1) + .mockResolvedValueOnce(mockHash2); + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256"); + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("user@test.com", "sha256"); + expect(send.emailHashes).toBe("ABCD,1234"); + }); + + it("should trim and lowercase emails before hashing", async () => { + sendView.emails = [" Test@Example.COM ", "USER@test.com"]; + const mockHash = new Uint8Array([0xff]); + + cryptoFunctionService.hash.mockResolvedValue(mockHash); + + await sendService.encrypt(sendView, null, null); + + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256"); + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("user@test.com", "sha256"); + }); + + it("should set emailHashes to empty string when no emails", async () => { + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emailHashes).toBe(""); + expect(cryptoFunctionService.hash).not.toHaveBeenCalled(); + }); + + it("should handle single email correctly", async () => { + sendView.emails = ["single@test.com"]; + const mockHash = new Uint8Array([0xa1, 0xb2, 0xc3]); + + cryptoFunctionService.hash.mockResolvedValue(mockHash); + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emailHashes).toBe("A1B2C3"); + }); + }); + + describe("emails and password mutual exclusivity", () => { + it("should set password to null when emails are provided", async () => { + sendView.emails = ["test@example.com"]; + + const [send] = await sendService.encrypt(sendView, null, "password123"); + + expect(send.emails).toBeDefined(); + expect(send.password).toBeNull(); + }); + + it("should set password when no emails are provided", async () => { + sendView.emails = []; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue({ + keyB64: "hashedPassword", + } as any); + + const [send] = await sendService.encrypt(sendView, null, "password123"); + + expect(send.emails).toBeNull(); + expect(send.password).toBe("hashedPassword"); + }); + }); + }); + + describe("when SendEmailOTP feature flag is OFF", () => { + beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(false); + cryptoFunctionService.hash.mockClear(); + }); + + it("should NOT encrypt emails even when provided", async () => { + sendView.emails = ["test@example.com"]; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + expect(cryptoFunctionService.hash).not.toHaveBeenCalled(); + }); + + it("should use password when provided and flag is OFF", async () => { + sendView.emails = []; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue({ + keyB64: "hashedPassword", + } as any); + + const [send] = await sendService.encrypt(sendView, null, "password123"); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + expect(send.password).toBe("hashedPassword"); + }); + + it("should ignore emails and use password when both provided", async () => { + sendView.emails = ["test@example.com"]; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue({ + keyB64: "hashedPassword", + } as any); + + const [send] = await sendService.encrypt(sendView, null, "password123"); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + expect(send.password).toBe("hashedPassword"); + expect(cryptoFunctionService.hash).not.toHaveBeenCalled(); + }); + + it("should set emails and password to null when neither provided", async () => { + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + expect(send.password).toBeUndefined(); + }); + }); + + describe("null handling for name and notes", () => { + it("should handle null name correctly", async () => { + sendView.name = null; + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.name).toBeNull(); + }); + + it("should handle null notes correctly", async () => { + sendView.notes = null; + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.notes).toBeNull(); + }); + + it("should encrypt non-null name and notes", async () => { + sendView.name = "Test Name"; + sendView.notes = "Test Notes"; + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(encryptService.encryptString).toHaveBeenCalledWith("Test Name", mockCryptoKey); + expect(encryptService.encryptString).toHaveBeenCalledWith("Test Notes", mockCryptoKey); + expect(send.name).toEqual({ encryptedString: "encrypted" }); + expect(send.notes).toEqual({ encryptedString: "encrypted" }); + }); + }); + }); }); diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index c274d90146e..078e94b2563 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -7,9 +7,12 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv // eslint-disable-next-line no-restricted-imports import { PBKDF2KdfConfig, KeyService } from "@bitwarden/key-management"; +import { FeatureFlag } from "../../../enums/feature-flag.enum"; import { KeyGenerationService } from "../../../key-management/crypto"; +import { CryptoFunctionService } from "../../../key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; import { EncString } from "../../../key-management/crypto/models/enc-string"; +import { ConfigService } from "../../../platform/abstractions/config/config.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { Utils } from "../../../platform/misc/utils"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; @@ -51,6 +54,8 @@ export class SendService implements InternalSendServiceAbstraction { private keyGenerationService: KeyGenerationService, private stateProvider: SendStateProvider, private encryptService: EncryptService, + private cryptoFunctionService: CryptoFunctionService, + private configService: ConfigService, ) {} async encrypt( @@ -80,19 +85,30 @@ export class SendService implements InternalSendServiceAbstraction { model.cryptoKey = key.derivedKey; } + // Check feature flag for email OTP authentication + const sendEmailOTPEnabled = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); + const hasEmails = (model.emails?.length ?? 0) > 0; - if (hasEmails) { - send.emails = model.emails.join(","); + + if (sendEmailOTPEnabled && hasEmails) { + const plaintextEmails = model.emails.join(","); + send.emails = await this.encryptService.encryptString(plaintextEmails, model.cryptoKey); + send.emailHashes = await this.hashEmails(plaintextEmails); send.password = null; - } else if (password != null) { - // Note: Despite being called key, the passwordKey is not used for encryption. - // It is used as a static proof that the client knows the password, and has the encryption key. - const passwordKey = await this.keyGenerationService.deriveKeyFromPassword( - password, - model.key, - new PBKDF2KdfConfig(SEND_KDF_ITERATIONS), - ); - send.password = passwordKey.keyB64; + } else { + send.emails = null; + send.emailHashes = ""; + + if (password != null) { + // Note: Despite being called key, the passwordKey is not used for encryption. + // It is used as a static proof that the client knows the password, and has the encryption key. + const passwordKey = await this.keyGenerationService.deriveKeyFromPassword( + password, + model.key, + new PBKDF2KdfConfig(SEND_KDF_ITERATIONS), + ); + send.password = passwordKey.keyB64; + } } const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; if (userKey == null) { @@ -100,10 +116,14 @@ export class SendService implements InternalSendServiceAbstraction { } // Key is not a SymmetricCryptoKey, but key material used to derive the cryptoKey send.key = await this.encryptService.encryptBytes(model.key, userKey); - // FIXME: model.name can be null. encryptString should not be called with null values. - send.name = await this.encryptService.encryptString(model.name, model.cryptoKey); - // FIXME: model.notes can be null. encryptString should not be called with null values. - send.notes = await this.encryptService.encryptString(model.notes, model.cryptoKey); + send.name = + model.name != null + ? await this.encryptService.encryptString(model.name, model.cryptoKey) + : null; + send.notes = + model.notes != null + ? await this.encryptService.encryptString(model.notes, model.cryptoKey) + : null; if (send.type === SendType.Text) { send.text = new SendText(); // FIXME: model.text.text can be null. encryptString should not be called with null values. @@ -127,6 +147,8 @@ export class SendService implements InternalSendServiceAbstraction { } } + send.authType = model.authType; + return [send, fileData]; } @@ -371,4 +393,19 @@ export class SendService implements InternalSendServiceAbstraction { decryptedSends.sort(Utils.getSortFunction(this.i18nService, "name")); return decryptedSends; } + + private async hashEmails(emails: string): Promise { + if (!emails) { + return ""; + } + + const emailArray = emails.split(",").map((e) => e.trim().toLowerCase()); + const hashPromises = emailArray.map(async (email) => { + const hash: Uint8Array = await this.cryptoFunctionService.hash(email, "sha256"); + return Utils.fromBufferToHex(hash).toUpperCase(); + }); + + const hashes = await Promise.all(hashPromises); + return hashes.join(","); + } } diff --git a/libs/common/src/tools/send/services/test-data/send-tests.data.ts b/libs/common/src/tools/send/services/test-data/send-tests.data.ts index c1d04ab2926..9c4e121edc0 100644 --- a/libs/common/src/tools/send/services/test-data/send-tests.data.ts +++ b/libs/common/src/tools/send/services/test-data/send-tests.data.ts @@ -20,6 +20,7 @@ export function testSendViewData(id: string, name: string) { data.deletionDate = null; data.notes = "Notes!!"; data.key = null; + data.emails = []; return data; } @@ -39,6 +40,8 @@ export function createSendData(value: Partial = {}) { expirationDate: "2024-09-04", deletionDate: "2024-09-04", password: "password", + emails: "", + emailHashes: "", disabled: false, hideEmail: false, }; @@ -62,6 +65,8 @@ export function testSendData(id: string, name: string) { data.deletionDate = null; data.notes = "Notes!!"; data.key = null; + data.emails = ""; + data.emailHashes = ""; return data; } @@ -77,5 +82,7 @@ export function testSend(id: string, name: string) { data.deletionDate = null; data.notes = new EncString("Notes!!"); data.key = null; + data.emails = null; + data.emailHashes = ""; return data; } diff --git a/libs/common/src/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/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/abstractions/search.service.ts b/libs/common/src/vault/abstractions/search.service.ts index 29575ec3af9..b4dfc015efe 100644 --- a/libs/common/src/vault/abstractions/search.service.ts +++ b/libs/common/src/vault/abstractions/search.service.ts @@ -2,7 +2,6 @@ import { Observable } from "rxjs"; import { SendView } from "../../tools/send/models/view/send.view"; import { IndexedEntityId, UserId } from "../../types/guid"; -import { CipherView } from "../models/view/cipher.view"; import { CipherViewLike } from "../utils/cipher-view-like-utils"; export abstract class SearchService { @@ -20,7 +19,7 @@ export abstract class SearchService { abstract isSearchable(userId: UserId, query: string | null): Promise; abstract indexCiphers( userId: UserId, - ciphersToIndex: CipherView[], + ciphersToIndex: CipherViewLike[], indexedEntityGuid?: string, ): Promise; abstract searchCiphers( diff --git a/libs/common/src/vault/models/view/cipher.view.spec.ts b/libs/common/src/vault/models/view/cipher.view.spec.ts index 475fe9e23f3..1c7017d5d89 100644 --- a/libs/common/src/vault/models/view/cipher.view.spec.ts +++ b/libs/common/src/vault/models/view/cipher.view.spec.ts @@ -353,4 +353,366 @@ describe("CipherView", () => { }); }); }); + + // Note: These tests use jest.requireActual() because the file has jest.mock() calls + // at the top that mock LoginView, FieldView, etc. Those mocks are needed for other tests + // but interfere with these tests which need the real implementations. + describe("toSdkCreateCipherRequest", () => { + it("maps all properties correctly for a login cipher", () => { + const { FieldView: RealFieldView } = jest.requireActual("./field.view"); + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.organizationId = "000f2a6e-da5e-4726-87ed-1c5c77322c3c"; + cipherView.folderId = "41b22db4-8e2a-4ed2-b568-f1186c72922f"; + cipherView.collectionIds = ["b0473506-3c3c-4260-a734-dfaaf833ab6f"]; + cipherView.name = "Test Login"; + cipherView.notes = "Test notes"; + cipherView.type = CipherType.Login; + cipherView.favorite = true; + cipherView.reprompt = CipherRepromptType.Password; + + const field = new RealFieldView(); + field.name = "testField"; + field.value = "testValue"; + field.type = SdkFieldType.Text; + cipherView.fields = [field]; + + cipherView.login = new RealLoginView(); + cipherView.login.username = "testuser"; + cipherView.login.password = "testpass"; + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.organizationId).toEqual(asUuid("000f2a6e-da5e-4726-87ed-1c5c77322c3c")); + expect(result.folderId).toEqual(asUuid("41b22db4-8e2a-4ed2-b568-f1186c72922f")); + expect(result.collectionIds).toEqual([asUuid("b0473506-3c3c-4260-a734-dfaaf833ab6f")]); + expect(result.name).toBe("Test Login"); + expect(result.notes).toBe("Test notes"); + expect(result.favorite).toBe(true); + expect(result.reprompt).toBe(CipherRepromptType.Password); + expect(result.fields).toHaveLength(1); + expect(result.fields![0]).toMatchObject({ + name: "testField", + value: "testValue", + type: SdkFieldType.Text, + }); + expect(result.type).toHaveProperty("login"); + expect((result.type as any).login).toMatchObject({ + username: "testuser", + password: "testpass", + }); + }); + + it("handles undefined organizationId and folderId", () => { + const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view"); + + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + cipherView.type = CipherType.SecureNote; + cipherView.secureNote = new RealSecureNoteView(); + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.organizationId).toBeUndefined(); + expect(result.folderId).toBeUndefined(); + expect(result.name).toBe("Test Cipher"); + }); + + it("handles empty collectionIds array", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + cipherView.collectionIds = []; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.collectionIds).toEqual([]); + }); + + it("defaults favorite to false when undefined", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + cipherView.favorite = undefined as any; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.favorite).toBe(false); + }); + + it("defaults reprompt to None when undefined", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + cipherView.reprompt = undefined as any; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.reprompt).toBe(CipherRepromptType.None); + }); + + test.each([ + ["Login", CipherType.Login, "login.view", "LoginView"], + ["Card", CipherType.Card, "card.view", "CardView"], + ["Identity", CipherType.Identity, "identity.view", "IdentityView"], + ["SecureNote", CipherType.SecureNote, "secure-note.view", "SecureNoteView"], + ["SshKey", CipherType.SshKey, "ssh-key.view", "SshKeyView"], + ])( + "creates correct type property for %s cipher", + (typeName: string, cipherType: CipherType, moduleName: string, className: string) => { + const module = jest.requireActual(`./${moduleName}`); + const ViewClass = module[className]; + + const cipherView = new CipherView(); + cipherView.name = `Test ${typeName}`; + cipherView.type = cipherType; + + // Set the appropriate view property + const viewPropertyName = typeName.charAt(0).toLowerCase() + typeName.slice(1); + (cipherView as any)[viewPropertyName] = new ViewClass(); + + const result = cipherView.toSdkCreateCipherRequest(); + + const typeKey = typeName.charAt(0).toLowerCase() + typeName.slice(1); + expect(result.type).toHaveProperty(typeKey); + }, + ); + }); + + describe("toSdkUpdateCipherRequest", () => { + it("maps all properties correctly for an update request", () => { + const { FieldView: RealFieldView } = jest.requireActual("./field.view"); + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.organizationId = "000f2a6e-da5e-4726-87ed-1c5c77322c3c"; + cipherView.folderId = "41b22db4-8e2a-4ed2-b568-f1186c72922f"; + cipherView.name = "Updated Login"; + cipherView.notes = "Updated notes"; + cipherView.type = CipherType.Login; + cipherView.favorite = true; + cipherView.reprompt = CipherRepromptType.Password; + cipherView.revisionDate = new Date("2022-01-02T12:00:00.000Z"); + cipherView.archivedDate = new Date("2022-01-03T12:00:00.000Z"); + cipherView.key = new EncString("cipher-key"); + + const mockField = new RealFieldView(); + mockField.name = "testField"; + mockField.value = "testValue"; + cipherView.fields = [mockField]; + + cipherView.login = new RealLoginView(); + cipherView.login.username = "testuser"; + + const result = cipherView.toSdkUpdateCipherRequest(); + + expect(result.id).toEqual(asUuid("0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602")); + expect(result.organizationId).toEqual(asUuid("000f2a6e-da5e-4726-87ed-1c5c77322c3c")); + expect(result.folderId).toEqual(asUuid("41b22db4-8e2a-4ed2-b568-f1186c72922f")); + expect(result.name).toBe("Updated Login"); + expect(result.notes).toBe("Updated notes"); + expect(result.favorite).toBe(true); + expect(result.reprompt).toBe(CipherRepromptType.Password); + expect(result.revisionDate).toBe("2022-01-02T12:00:00.000Z"); + expect(result.archivedDate).toBe("2022-01-03T12:00:00.000Z"); + expect(result.fields).toHaveLength(1); + expect(result.fields![0]).toMatchObject({ + name: "testField", + value: "testValue", + }); + expect(result.type).toHaveProperty("login"); + expect((result.type as any).login).toMatchObject({ + username: "testuser", + }); + expect(result.key).toBeDefined(); + }); + + it("handles undefined optional properties", () => { + const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view"); + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.name = "Test Cipher"; + cipherView.type = CipherType.SecureNote; + cipherView.secureNote = new RealSecureNoteView(); + cipherView.revisionDate = new Date("2022-01-02T12:00:00.000Z"); + + const result = cipherView.toSdkUpdateCipherRequest(); + + expect(result.organizationId).toBeUndefined(); + expect(result.folderId).toBeUndefined(); + expect(result.archivedDate).toBeUndefined(); + expect(result.key).toBeUndefined(); + }); + + it("converts dates to ISO strings", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.name = "Test Cipher"; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + cipherView.revisionDate = new Date("2022-05-15T10:30:00.000Z"); + cipherView.archivedDate = new Date("2022-06-20T14:45:00.000Z"); + + const result = cipherView.toSdkUpdateCipherRequest(); + + expect(result.revisionDate).toBe("2022-05-15T10:30:00.000Z"); + expect(result.archivedDate).toBe("2022-06-20T14:45:00.000Z"); + }); + + it("includes attachments when present", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + const { AttachmentView: RealAttachmentView } = jest.requireActual("./attachment.view"); + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.name = "Test Cipher"; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + + const attachment1 = new RealAttachmentView(); + attachment1.id = "attachment-id-1"; + attachment1.fileName = "file1.txt"; + + const attachment2 = new RealAttachmentView(); + attachment2.id = "attachment-id-2"; + attachment2.fileName = "file2.pdf"; + + cipherView.attachments = [attachment1, attachment2]; + + const result = cipherView.toSdkUpdateCipherRequest(); + + expect(result.attachments).toHaveLength(2); + }); + + test.each([ + ["Login", CipherType.Login, "login.view", "LoginView"], + ["Card", CipherType.Card, "card.view", "CardView"], + ["Identity", CipherType.Identity, "identity.view", "IdentityView"], + ["SecureNote", CipherType.SecureNote, "secure-note.view", "SecureNoteView"], + ["SshKey", CipherType.SshKey, "ssh-key.view", "SshKeyView"], + ])( + "creates correct type property for %s cipher", + (typeName: string, cipherType: CipherType, moduleName: string, className: string) => { + const module = jest.requireActual(`./${moduleName}`); + const ViewClass = module[className]; + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.name = `Test ${typeName}`; + cipherView.type = cipherType; + + // Set the appropriate view property + const viewPropertyName = typeName.charAt(0).toLowerCase() + typeName.slice(1); + (cipherView as any)[viewPropertyName] = new ViewClass(); + + const result = cipherView.toSdkUpdateCipherRequest(); + + const typeKey = typeName.charAt(0).toLowerCase() + typeName.slice(1); + expect(result.type).toHaveProperty(typeKey); + }, + ); + }); + + describe("getSdkCipherViewType", () => { + it("returns login type for Login cipher", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + cipherView.login.username = "testuser"; + cipherView.login.password = "testpass"; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("login"); + expect((result as any).login).toMatchObject({ + username: "testuser", + password: "testpass", + }); + }); + + it("returns card type for Card cipher", () => { + const { CardView: RealCardView } = jest.requireActual("./card.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.Card; + cipherView.card = new RealCardView(); + cipherView.card.cardholderName = "John Doe"; + cipherView.card.number = "4111111111111111"; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("card"); + expect((result as any).card.cardholderName).toBe("John Doe"); + expect((result as any).card.number).toBe("4111111111111111"); + }); + + it("returns identity type for Identity cipher", () => { + const { IdentityView: RealIdentityView } = jest.requireActual("./identity.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.Identity; + cipherView.identity = new RealIdentityView(); + cipherView.identity.firstName = "John"; + cipherView.identity.lastName = "Doe"; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("identity"); + expect((result as any).identity.firstName).toBe("John"); + expect((result as any).identity.lastName).toBe("Doe"); + }); + + it("returns secureNote type for SecureNote cipher", () => { + const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.SecureNote; + cipherView.secureNote = new RealSecureNoteView(); + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("secureNote"); + }); + + it("returns sshKey type for SshKey cipher", () => { + const { SshKeyView: RealSshKeyView } = jest.requireActual("./ssh-key.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.SshKey; + cipherView.sshKey = new RealSshKeyView(); + cipherView.sshKey.privateKey = "privateKeyData"; + cipherView.sshKey.publicKey = "publicKeyData"; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("sshKey"); + expect((result as any).sshKey.privateKey).toBe("privateKeyData"); + expect((result as any).sshKey.publicKey).toBe("publicKeyData"); + }); + + it("defaults to empty login for unknown cipher type", () => { + const cipherView = new CipherView(); + cipherView.type = 999 as CipherType; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("login"); + }); + }); }); diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 89f59665681..0909d0bda80 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -1,7 +1,12 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { asUuid, uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { ItemView } from "@bitwarden/common/vault/models/view/item.view"; -import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal"; +import { + CipherCreateRequest, + CipherEditRequest, + CipherViewType, + CipherView as SdkCipherView, +} from "@bitwarden/sdk-internal"; import { View } from "../../../models/view/view"; import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface"; @@ -332,6 +337,85 @@ export class CipherView implements View, InitializerMetadata { return cipherView; } + /** + * Maps CipherView to an SDK CipherCreateRequest + * + * @returns {CipherCreateRequest} The SDK cipher create request object + */ + toSdkCreateCipherRequest(): CipherCreateRequest { + const sdkCipherCreateRequest: CipherCreateRequest = { + organizationId: this.organizationId ? asUuid(this.organizationId) : undefined, + collectionIds: this.collectionIds ? this.collectionIds.map((i) => asUuid(i)) : [], + folderId: this.folderId ? asUuid(this.folderId) : undefined, + name: this.name ?? "", + notes: this.notes, + favorite: this.favorite ?? false, + reprompt: this.reprompt ?? CipherRepromptType.None, + fields: this.fields?.map((f) => f.toSdkFieldView()), + type: this.getSdkCipherViewType(), + }; + + return sdkCipherCreateRequest; + } + + /** + * Maps CipherView to an SDK CipherEditRequest + * + * @returns {CipherEditRequest} The SDK cipher edit request object + */ + toSdkUpdateCipherRequest(): CipherEditRequest { + const sdkCipherEditRequest: CipherEditRequest = { + id: asUuid(this.id), + organizationId: this.organizationId ? asUuid(this.organizationId) : undefined, + folderId: this.folderId ? asUuid(this.folderId) : undefined, + name: this.name ?? "", + notes: this.notes, + favorite: this.favorite ?? false, + reprompt: this.reprompt ?? CipherRepromptType.None, + fields: this.fields?.map((f) => f.toSdkFieldView()), + type: this.getSdkCipherViewType(), + revisionDate: this.revisionDate?.toISOString(), + archivedDate: this.archivedDate?.toISOString(), + attachments: this.attachments?.map((a) => a.toSdkAttachmentView()), + key: this.key?.toSdk(), + }; + + return sdkCipherEditRequest; + } + + /** + * Returns the SDK CipherViewType object for the cipher. + * + * @returns {CipherViewType} The SDK CipherViewType for the cipher.t + */ + getSdkCipherViewType(): CipherViewType { + let viewType: CipherViewType; + switch (this.type) { + case CipherType.Card: + viewType = { card: this.card?.toSdkCardView() }; + break; + case CipherType.Identity: + viewType = { identity: this.identity?.toSdkIdentityView() }; + break; + case CipherType.Login: + viewType = { login: this.login?.toSdkLoginView() }; + break; + case CipherType.SecureNote: + viewType = { secureNote: this.secureNote?.toSdkSecureNoteView() }; + break; + case CipherType.SshKey: + viewType = { sshKey: this.sshKey?.toSdkSshKeyView() }; + break; + default: + viewType = { + // Default to empty login - should not be valid code path. + login: new LoginView().toSdkLoginView(), + }; + break; + } + return viewType; + } + /** * Maps CipherView to SdkCipherView * diff --git a/libs/common/src/vault/services/cipher-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..696ef49065c 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> { @@ -164,13 +173,14 @@ export class CipherService implements CipherServiceAbstraction { decryptStartTime = performance.now(); }), switchMap(async (ciphers) => { - const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId, false); - void this.setFailedDecryptedCiphers(failures, userId); - // Trigger full decryption and indexing in background - void this.getAllDecrypted(userId); - return decrypted; + return await this.decryptCiphersWithSdk(ciphers, userId, false); }), - tap((decrypted) => { + tap(([decrypted, failures]) => { + void Promise.all([ + this.setFailedDecryptedCiphers(failures, userId), + this.searchService.indexCiphers(userId, decrypted), + ]); + this.logService.measure( decryptStartTime, "Vault", @@ -179,10 +189,11 @@ export class CipherService implements CipherServiceAbstraction { [["Items", decrypted.length]], ); }), + map(([decrypted]) => decrypted), ); }), ); - }); + }, this.clearCipherViewsForUser$); /** * Observable that emits an array of decrypted ciphers for the active user. @@ -903,6 +914,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 +977,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 { @@ -1119,8 +1206,7 @@ export class CipherService implements CipherServiceAbstraction { //in order to keep item and it's attachments with the same encryption level if (cipher.key != null && !cipherKeyEncryptionEnabled) { const model = await this.decrypt(cipher, userId); - const reEncrypted = await this.encrypt(model, userId); - await this.updateWithServer(reEncrypted); + await this.updateWithServer(model, userId); } const encFileName = await this.encryptService.encryptString(filename, cipherEncKey); @@ -1318,7 +1404,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 +1421,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 +1573,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 +1601,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 +1618,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 +1674,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 +1697,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-encryption.service.spec.ts b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts index a0ca4833b92..98b554b5762 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts @@ -95,6 +95,7 @@ describe("DefaultCipherEncryptionService", () => { vault: jest.fn().mockReturnValue({ ciphers: jest.fn().mockReturnValue({ encrypt: jest.fn(), + encrypt_list: jest.fn(), encrypt_cipher_for_rotation: jest.fn(), set_fido2_credentials: jest.fn(), decrypt: jest.fn(), @@ -280,10 +281,23 @@ describe("DefaultCipherEncryptionService", () => { name: "encrypted-name-3", } as unknown as Cipher; - mockSdkClient.vault().ciphers().encrypt.mockReturnValue({ - cipher: sdkCipher, - encryptedFor: userId, - }); + mockSdkClient + .vault() + .ciphers() + .encrypt_list.mockReturnValue([ + { + cipher: sdkCipher, + encryptedFor: userId, + }, + { + cipher: sdkCipher, + encryptedFor: userId, + }, + { + cipher: sdkCipher, + encryptedFor: userId, + }, + ]); jest .spyOn(Cipher, "fromSdkCipher") @@ -299,7 +313,8 @@ describe("DefaultCipherEncryptionService", () => { expect(results[1].cipher).toEqual(expectedCipher2); expect(results[2].cipher).toEqual(expectedCipher3); - expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledTimes(3); + expect(mockSdkClient.vault().ciphers().encrypt_list).toHaveBeenCalledTimes(1); + expect(mockSdkClient.vault().ciphers().encrypt).not.toHaveBeenCalled(); expect(results[0].encryptedFor).toBe(userId); expect(results[1].encryptedFor).toBe(userId); @@ -311,7 +326,7 @@ describe("DefaultCipherEncryptionService", () => { expect(results).toBeDefined(); expect(results.length).toBe(0); - expect(mockSdkClient.vault().ciphers().encrypt).not.toHaveBeenCalled(); + expect(mockSdkClient.vault().ciphers().encrypt_list).not.toHaveBeenCalled(); }); }); diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.ts b/libs/common/src/vault/services/default-cipher-encryption.service.ts index 588265846e0..45542091618 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.ts @@ -65,21 +65,14 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { using ref = sdk.take(); - const results: EncryptionContext[] = []; - - // TODO: https://bitwarden.atlassian.net/browse/PM-30580 - // Replace this loop with a native SDK encryptMany method for better performance. - for (const model of models) { - const sdkCipherView = this.toSdkCipherView(model, ref.value); - const encryptionContext = ref.value.vault().ciphers().encrypt(sdkCipherView); - - results.push({ + return ref.value + .vault() + .ciphers() + .encrypt_list(models.map((model) => this.toSdkCipherView(model, ref.value))) + .map((encryptionContext) => ({ cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!, encryptedFor: uuidAsString(encryptionContext.encryptedFor) as UserId, - }); - } - - return results; + })); }), catchError((error: unknown) => { this.logService.error(`Failed to encrypt ciphers in batch: ${error}`); diff --git a/libs/common/src/vault/services/search.service.ts b/libs/common/src/vault/services/search.service.ts index feb6a7494b5..e14a66aad6f 100644 --- a/libs/common/src/vault/services/search.service.ts +++ b/libs/common/src/vault/services/search.service.ts @@ -21,7 +21,6 @@ import { IndexedEntityId, UserId } from "../../types/guid"; import { SearchService as SearchServiceAbstraction } from "../abstractions/search.service"; import { FieldType } from "../enums"; import { CipherType } from "../enums/cipher-type"; -import { CipherView } from "../models/view/cipher.view"; import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils"; // Time to wait before performing a search after the user stops typing. @@ -169,7 +168,7 @@ export class SearchService implements SearchServiceAbstraction { async indexCiphers( userId: UserId, - ciphers: CipherView[], + ciphers: CipherViewLike[], indexedEntityId?: string, ): Promise { if (await this.getIsIndexing(userId)) { @@ -182,34 +181,47 @@ export class SearchService implements SearchServiceAbstraction { const builder = new lunr.Builder(); builder.pipeline.add(this.normalizeAccentsPipelineFunction); builder.ref("id"); - builder.field("shortid", { boost: 100, extractor: (c: CipherView) => c.id.substr(0, 8) }); + builder.field("shortid", { + boost: 100, + extractor: (c: CipherViewLike) => uuidAsString(c.id).substr(0, 8), + }); builder.field("name", { boost: 10, }); builder.field("subtitle", { boost: 5, - extractor: (c: CipherView) => { - if (c.subTitle != null && c.type === CipherType.Card) { - return c.subTitle.replace(/\*/g, ""); + extractor: (c: CipherViewLike) => { + const subtitle = CipherViewLikeUtils.subtitle(c); + if (subtitle != null && CipherViewLikeUtils.getType(c) === CipherType.Card) { + return subtitle.replace(/\*/g, ""); } - return c.subTitle; + return subtitle; }, }); - builder.field("notes"); + builder.field("notes", { extractor: (c: CipherViewLike) => CipherViewLikeUtils.getNotes(c) }); builder.field("login.username", { - extractor: (c: CipherView) => - c.type === CipherType.Login && c.login != null ? c.login.username : null, + extractor: (c: CipherViewLike) => { + const login = CipherViewLikeUtils.getLogin(c); + return login?.username ?? null; + }, + }); + builder.field("login.uris", { + boost: 2, + extractor: (c: CipherViewLike) => this.uriExtractor(c), + }); + builder.field("fields", { + extractor: (c: CipherViewLike) => this.fieldExtractor(c, false), + }); + builder.field("fields_joined", { + extractor: (c: CipherViewLike) => this.fieldExtractor(c, true), }); - builder.field("login.uris", { boost: 2, extractor: (c: CipherView) => this.uriExtractor(c) }); - builder.field("fields", { extractor: (c: CipherView) => this.fieldExtractor(c, false) }); - builder.field("fields_joined", { extractor: (c: CipherView) => this.fieldExtractor(c, true) }); builder.field("attachments", { - extractor: (c: CipherView) => this.attachmentExtractor(c, false), + extractor: (c: CipherViewLike) => this.attachmentExtractor(c, false), }); builder.field("attachments_joined", { - extractor: (c: CipherView) => this.attachmentExtractor(c, true), + extractor: (c: CipherViewLike) => this.attachmentExtractor(c, true), }); - builder.field("organizationid", { extractor: (c: CipherView) => c.organizationId }); + builder.field("organizationid", { extractor: (c: CipherViewLike) => c.organizationId }); ciphers = ciphers || []; ciphers.forEach((c) => builder.add(c)); const index = builder.build(); @@ -400,37 +412,44 @@ export class SearchService implements SearchServiceAbstraction { return await firstValueFrom(this.searchIsIndexing$(userId)); } - private fieldExtractor(c: CipherView, joined: boolean) { - if (!c.hasFields) { + private fieldExtractor(c: CipherViewLike, joined: boolean) { + const fields = CipherViewLikeUtils.getFields(c); + if (!fields || fields.length === 0) { return null; } - let fields: string[] = []; - c.fields.forEach((f) => { + let fieldStrings: string[] = []; + fields.forEach((f) => { if (f.name != null) { - fields.push(f.name); + fieldStrings.push(f.name); } - if (f.type === FieldType.Text && f.value != null) { - fields.push(f.value); + // For CipherListView, value is only populated for Text fields + // For CipherView, we check the type explicitly + if (f.value != null) { + const fieldType = (f as { type?: FieldType }).type; + if (fieldType === undefined || fieldType === FieldType.Text) { + fieldStrings.push(f.value); + } } }); - fields = fields.filter((f) => f.trim() !== ""); - if (fields.length === 0) { + fieldStrings = fieldStrings.filter((f) => f.trim() !== ""); + if (fieldStrings.length === 0) { return null; } - return joined ? fields.join(" ") : fields; + return joined ? fieldStrings.join(" ") : fieldStrings; } - private attachmentExtractor(c: CipherView, joined: boolean) { - if (!c.hasAttachments) { + private attachmentExtractor(c: CipherViewLike, joined: boolean) { + const attachmentNames = CipherViewLikeUtils.getAttachmentNames(c); + if (!attachmentNames || attachmentNames.length === 0) { return null; } let attachments: string[] = []; - c.attachments.forEach((a) => { - if (a != null && a.fileName != null) { - if (joined && a.fileName.indexOf(".") > -1) { - attachments.push(a.fileName.substr(0, a.fileName.lastIndexOf("."))); + attachmentNames.forEach((fileName) => { + if (fileName != null) { + if (joined && fileName.indexOf(".") > -1) { + attachments.push(fileName.substring(0, fileName.lastIndexOf("."))); } else { - attachments.push(a.fileName); + attachments.push(fileName); } } }); @@ -441,43 +460,39 @@ export class SearchService implements SearchServiceAbstraction { return joined ? attachments.join(" ") : attachments; } - private uriExtractor(c: CipherView) { - if (c.type !== CipherType.Login || c.login == null || !c.login.hasUris) { + private uriExtractor(c: CipherViewLike) { + if (CipherViewLikeUtils.getType(c) !== CipherType.Login) { + return null; + } + const login = CipherViewLikeUtils.getLogin(c); + if (!login?.uris?.length) { return null; } const uris: string[] = []; - c.login.uris.forEach((u) => { + login.uris.forEach((u) => { if (u.uri == null || u.uri === "") { return; } - // Match ports + // Extract port from URI const portMatch = u.uri.match(/:(\d+)(?:[/?#]|$)/); const port = portMatch?.[1]; - let uri = u.uri; - - if (u.hostname !== null) { - uris.push(u.hostname); + const hostname = CipherViewLikeUtils.getUriHostname(u); + if (hostname !== undefined) { + uris.push(hostname); if (port) { - uris.push(`${u.hostname}:${port}`); - uris.push(port); - } - return; - } else { - const slash = uri.indexOf("/"); - const hostPart = slash > -1 ? uri.substring(0, slash) : uri; - uris.push(hostPart); - if (port) { - uris.push(`${hostPart}`); + uris.push(`${hostname}:${port}`); uris.push(port); } } + // Add processed URI (strip protocol and query params for non-regex matches) + let uri = u.uri; if (u.match !== UriMatchStrategy.RegularExpression) { const protocolIndex = uri.indexOf("://"); if (protocolIndex > -1) { - uri = uri.substr(protocolIndex + 3); + uri = uri.substring(protocolIndex + 3); } const queryIndex = uri.search(/\?|&|#/); if (queryIndex > -1) { @@ -486,6 +501,7 @@ export class SearchService implements SearchServiceAbstraction { } uris.push(uri); }); + return uris.length > 0 ? uris : null; } diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts b/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts index 56b94fcf3ce..2a7bfac2970 100644 --- a/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts +++ b/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts @@ -651,4 +651,198 @@ describe("CipherViewLikeUtils", () => { expect(CipherViewLikeUtils.decryptionFailure(cipherListView)).toBe(false); }); }); + + describe("getNotes", () => { + describe("CipherView", () => { + it("returns notes when present", () => { + const cipherView = createCipherView(); + cipherView.notes = "This is a test note"; + + expect(CipherViewLikeUtils.getNotes(cipherView)).toBe("This is a test note"); + }); + + it("returns undefined when notes are not present", () => { + const cipherView = createCipherView(); + cipherView.notes = undefined; + + expect(CipherViewLikeUtils.getNotes(cipherView)).toBeUndefined(); + }); + }); + + describe("CipherListView", () => { + it("returns notes when present", () => { + const cipherListView = { + type: "secureNote", + notes: "List view notes", + } as CipherListView; + + expect(CipherViewLikeUtils.getNotes(cipherListView)).toBe("List view notes"); + }); + + it("returns undefined when notes are not present", () => { + const cipherListView = { + type: "secureNote", + } as CipherListView; + + expect(CipherViewLikeUtils.getNotes(cipherListView)).toBeUndefined(); + }); + }); + }); + + describe("getFields", () => { + describe("CipherView", () => { + it("returns fields when present", () => { + const cipherView = createCipherView(); + cipherView.fields = [ + { name: "Field1", value: "Value1" } as any, + { name: "Field2", value: "Value2" } as any, + ]; + + const fields = CipherViewLikeUtils.getFields(cipherView); + + expect(fields).toHaveLength(2); + expect(fields?.[0].name).toBe("Field1"); + expect(fields?.[0].value).toBe("Value1"); + expect(fields?.[1].name).toBe("Field2"); + expect(fields?.[1].value).toBe("Value2"); + }); + + it("returns empty array when fields array is empty", () => { + const cipherView = createCipherView(); + cipherView.fields = []; + + expect(CipherViewLikeUtils.getFields(cipherView)).toEqual([]); + }); + }); + + describe("CipherListView", () => { + it("returns fields when present", () => { + const cipherListView = { + type: { login: {} }, + fields: [ + { name: "Username", value: "user@example.com" }, + { name: "API Key", value: "abc123" }, + ], + } as CipherListView; + + const fields = CipherViewLikeUtils.getFields(cipherListView); + + expect(fields).toHaveLength(2); + expect(fields?.[0].name).toBe("Username"); + expect(fields?.[0].value).toBe("user@example.com"); + expect(fields?.[1].name).toBe("API Key"); + expect(fields?.[1].value).toBe("abc123"); + }); + + it("returns empty array when fields array is empty", () => { + const cipherListView = { + type: "secureNote", + fields: [], + } as unknown as CipherListView; + + expect(CipherViewLikeUtils.getFields(cipherListView)).toEqual([]); + }); + + it("returns undefined when fields are not present", () => { + const cipherListView = { + type: "secureNote", + } as CipherListView; + + expect(CipherViewLikeUtils.getFields(cipherListView)).toBeUndefined(); + }); + }); + }); + + describe("getAttachmentNames", () => { + describe("CipherView", () => { + it("returns attachment filenames when present", () => { + const cipherView = createCipherView(); + const attachment1 = new AttachmentView(); + attachment1.id = "1"; + attachment1.fileName = "document.pdf"; + const attachment2 = new AttachmentView(); + attachment2.id = "2"; + attachment2.fileName = "image.png"; + const attachment3 = new AttachmentView(); + attachment3.id = "3"; + attachment3.fileName = "spreadsheet.xlsx"; + cipherView.attachments = [attachment1, attachment2, attachment3]; + + const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView); + + expect(attachmentNames).toEqual(["document.pdf", "image.png", "spreadsheet.xlsx"]); + }); + + it("filters out null and undefined filenames", () => { + const cipherView = createCipherView(); + const attachment1 = new AttachmentView(); + attachment1.id = "1"; + attachment1.fileName = "valid.pdf"; + const attachment2 = new AttachmentView(); + attachment2.id = "2"; + attachment2.fileName = null as any; + const attachment3 = new AttachmentView(); + attachment3.id = "3"; + attachment3.fileName = undefined; + const attachment4 = new AttachmentView(); + attachment4.id = "4"; + attachment4.fileName = "another.txt"; + cipherView.attachments = [attachment1, attachment2, attachment3, attachment4]; + + const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView); + + expect(attachmentNames).toEqual(["valid.pdf", "another.txt"]); + }); + + it("returns empty array when attachments have no filenames", () => { + const cipherView = createCipherView(); + const attachment1 = new AttachmentView(); + attachment1.id = "1"; + const attachment2 = new AttachmentView(); + attachment2.id = "2"; + cipherView.attachments = [attachment1, attachment2]; + + const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView); + + expect(attachmentNames).toEqual([]); + }); + + it("returns empty array for empty attachments array", () => { + const cipherView = createCipherView(); + cipherView.attachments = []; + + expect(CipherViewLikeUtils.getAttachmentNames(cipherView)).toEqual([]); + }); + }); + + describe("CipherListView", () => { + it("returns attachment names when present", () => { + const cipherListView = { + type: "secureNote", + attachmentNames: ["report.pdf", "photo.jpg", "data.csv"], + } as CipherListView; + + const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherListView); + + expect(attachmentNames).toEqual(["report.pdf", "photo.jpg", "data.csv"]); + }); + + it("returns empty array when attachmentNames is empty", () => { + const cipherListView = { + type: "secureNote", + attachmentNames: [], + } as unknown as CipherListView; + + expect(CipherViewLikeUtils.getAttachmentNames(cipherListView)).toEqual([]); + }); + + it("returns undefined when attachmentNames is not present", () => { + const cipherListView = { + type: "secureNote", + } as CipherListView; + + expect(CipherViewLikeUtils.getAttachmentNames(cipherListView)).toBeUndefined(); + }); + }); + }); }); diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.ts b/libs/common/src/vault/utils/cipher-view-like-utils.ts index 04adb8d4832..5359bfb958f 100644 --- a/libs/common/src/vault/utils/cipher-view-like-utils.ts +++ b/libs/common/src/vault/utils/cipher-view-like-utils.ts @@ -10,6 +10,7 @@ import { LoginUriView as LoginListUriView, } from "@bitwarden/sdk-internal"; +import { Utils } from "../../platform/misc/utils"; import { CipherType } from "../enums"; import { Cipher } from "../models/domain/cipher"; import { CardView } from "../models/view/card.view"; @@ -290,6 +291,71 @@ export class CipherViewLikeUtils { static decryptionFailure = (cipher: CipherViewLike): boolean => { return "decryptionFailure" in cipher ? cipher.decryptionFailure : false; }; + + /** + * Returns the notes from the cipher. + * + * @param cipher - The cipher to extract notes from (either `CipherView` or `CipherListView`) + * @returns The notes string if present, or `undefined` if not set + */ + static getNotes = (cipher: CipherViewLike): string | undefined => { + return cipher.notes; + }; + + /** + * Returns the fields from the cipher. + * + * @param cipher - The cipher to extract fields from (either `CipherView` or `CipherListView`) + * @returns Array of field objects with `name` and `value` properties, `undefined` if not set + */ + static getFields = ( + cipher: CipherViewLike, + ): { name?: string | null; value?: string | undefined }[] | undefined => { + if (this.isCipherListView(cipher)) { + return cipher.fields; + } + return cipher.fields; + }; + + /** + * Returns attachment filenames from the cipher. + * + * @param cipher - The cipher to extract attachment names from (either `CipherView` or `CipherListView`) + * @returns Array of attachment filenames, `undefined` if attachments are not present + */ + static getAttachmentNames = (cipher: CipherViewLike): string[] | undefined => { + if (this.isCipherListView(cipher)) { + return cipher.attachmentNames; + } + + return cipher.attachments + ?.map((a) => a.fileName) + .filter((name): name is string => name != null); + }; + + /** + * Extracts hostname from a login URI. + * + * @param uri - The URI object (either `LoginUriView` class or `LoginListUriView`) + * @returns The hostname if available, `undefined` otherwise + * + * @remarks + * - For `LoginUriView` (CipherView): Uses the built-in `hostname` getter + * - For `LoginListUriView` (CipherListView): Computes hostname using `Utils.getHostname()` + * - Returns `undefined` for RegularExpression match types or when hostname cannot be extracted + */ + static getUriHostname = (uri: LoginListUriView | LoginUriView): string | undefined => { + if ("hostname" in uri && typeof uri.hostname !== "undefined") { + return uri.hostname ?? undefined; + } + + if (uri.match !== UriMatchStrategy.RegularExpression && uri.uri) { + const hostname = Utils.getHostname(uri.uri); + return hostname === "" ? undefined : hostname; + } + + return undefined; + }; } /** 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/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) { @@ -42,7 +40,6 @@ @if (breadcrumb.route(); as route) { } @else { - } @@ -61,7 +58,6 @@ @if (breadcrumb.route(); as route) { 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 @@ - - - + + + @if (startIcon()) { + + } +
+ +
+ @if (endIcon()) { + + }
@if (showLoadingStyle()) { 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 = { 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("secondary"); + readonly startIcon = input(undefined); + + readonly endIcon = input(undefined); + readonly size = input("default"); readonly block = input(false, { transform: booleanAttribute }); readonly loading = model(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*/ `
-
-
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: ` (args)}>

The content of the callout

-
Visit the help center + Visit the help center `, }), 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) { - + {{ character }} @if (showCount()) { {{ i + 1 }} @@ -31,6 +40,9 @@ export class ColorPasswordComponent { return Array.from(this.password() ?? ""); }); + private platformUtilsService = inject(PlatformUtilsService); + private elementRef = inject(ElementRef); + characterStyles: Record = { emoji: [], letter: ["tw-text-main"], @@ -78,4 +90,28 @@ export class ColorPasswordComponent { return "letter"; } + + @HostListener("copy", ["$event"]) + onCopy(event: ClipboardEvent) { + event.preventDefault(); + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + + const spanElements = this.elementRef.nativeElement.querySelectorAll( + "span[data-password-character]", + ); + let copiedText = ""; + + spanElements.forEach((span: HTMLElement, index: number) => { + if (selection.containsNode(span, true)) { + copiedText += this.passwordCharArray()[index]; + } + }); + + if (copiedText) { + this.platformUtilsService.copyToClipboard(copiedText); + } + } } diff --git a/libs/components/src/color-password/color-password.stories.ts b/libs/components/src/color-password/color-password.stories.ts index 2ed5cdc4b8d..f00b3a4acf5 100644 --- a/libs/components/src/color-password/color-password.stories.ts +++ b/libs/components/src/color-password/color-password.stories.ts @@ -1,4 +1,6 @@ -import { Meta, StoryObj } from "@storybook/angular"; +import { applicationConfig, Meta, StoryObj } from "@storybook/angular"; + +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet"; @@ -9,6 +11,19 @@ const examplePassword = "Wq$Jk😀7jlI DX#rS5Sdi!z0O "; export default { title: "Component Library/Color Password", component: ColorPasswordComponent, + decorators: [ + applicationConfig({ + providers: [ + { + provide: PlatformUtilsService, + useValue: { + // eslint-disable-next-line + copyToClipboard: (text: string) => console.log(`${text} copied to clipboard`), + }, + }, + ], + }), + ], args: { password: examplePassword, showCount: false, diff --git a/libs/components/src/dialog/dialog.service.ts b/libs/components/src/dialog/dialog.service.ts index 88dee499e07..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 implements Pick< export type DialogConfig = Pick< CdkDialogConfig, - "data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width" + "data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width" | "restoreFocus" >; /** @@ -242,6 +242,11 @@ export class DialogService { }; ref.cdkDialogRefBase = this.dialog.open(componentOrTemplateRef, _config); + + if (config?.restoreFocus === undefined) { + this.setRestoreFocusEl(ref); + } + return ref; } @@ -305,6 +310,48 @@ export class DialogService { return this.activeDrawer?.close(); } + /** + * Configure the dialog to return focus to the previous active element upon closing. + * @param ref CdkDialogRef + * + * The cdk dialog already has the optional directive `cdkTrapFocusAutoCapture` to capture the + * current active element and return focus to it upon close. However, it does not have a way to + * delay the capture of the element. We need this delay in some situations, where the active + * element may be changing as the dialog is opening, and we want to wait for that to settle. + * + * For example -- the menu component often contains menu items that open dialogs. When the dialog + * opens, the menu is closing and is setting focus back to the menu trigger since the menu item no + * longer exists. We want to capture the menu trigger as the active element, not the about-to-be- + * nonexistent menu item. If we wait a tick, we can let the menu finish that focus move. + */ + private setRestoreFocusEl(ref: CdkDialogRef) { + /** + * First, capture the current active el with no delay so that we can support normal use cases + * where we are not doing manual focus management + */ + const activeEl = document.activeElement; + + const restoreFocusTimeout = setTimeout(() => { + let restoreFocusEl = activeEl; + + /** + * If the original active element is no longer connected, it's because we purposely removed it + * from the DOM and have moved focus. Select the new active element instead. + */ + if (!restoreFocusEl?.isConnected) { + restoreFocusEl = document.activeElement; + } + + if (restoreFocusEl instanceof HTMLElement) { + ref.cdkDialogRefBase.config.restoreFocus = restoreFocusEl; + } + }, 0); + + ref.closed.pipe(take(1)).subscribe(() => { + clearTimeout(restoreFocusTimeout); + }); + } + /** The injector that is passed to the opened dialog */ private createInjector(opts: { data: unknown; dialogRef: DialogRef }): Injector { return Injector.create({ diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index 58364dfd045..f81f0594218 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -6,7 +6,6 @@ isDrawer ? 'tw-h-full tw-border-t-0' : 'tw-rounded-t-xl md:tw-rounded-xl tw-shadow-lg', ]" cdkTrapFocus - cdkTrapFocusAutoCapture > @let showHeaderBorder = bodyHasScrolledFrom().top;
{{ title() }} @if (subtitle(); as subtitleText) { diff --git a/libs/components/src/dialog/dialog/dialog.component.ts b/libs/components/src/dialog/dialog/dialog.component.ts index 0f9f341763a..63fbb69399d 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"; @@ -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>("dialogHeader"); private readonly scrollableBody = viewChild.required(CdkScrollable); private readonly scrollBottom = viewChild.required>("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/drawer/drawer.service.ts b/libs/components/src/dialog/drawer.service.ts similarity index 100% rename from libs/components/src/drawer/drawer.service.ts rename to libs/components/src/dialog/drawer.service.ts diff --git a/libs/components/src/dialog/index.ts b/libs/components/src/dialog/index.ts index fb4c2721b81..ce41f7957f6 100644 --- a/libs/components/src/dialog/index.ts +++ b/libs/components/src/dialog/index.ts @@ -2,3 +2,4 @@ export * from "./dialog.module"; export * from "./simple-dialog/types"; export * from "./dialog.service"; export { DIALOG_DATA } from "@angular/cdk/dialog"; +export { DialogComponent } from "./dialog/dialog.component"; diff --git a/libs/components/src/drawer/drawer-body.component.ts b/libs/components/src/drawer/drawer-body.component.ts deleted file mode 100644 index c6499067642..00000000000 --- a/libs/components/src/drawer/drawer-body.component.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { CdkScrollable } from "@angular/cdk/scrolling"; -import { ChangeDetectionStrategy, Component } from "@angular/core"; - -import { hasScrolledFrom } from "../utils/has-scrolled-from"; - -/** - * Body container for `bit-drawer` - */ -@Component({ - selector: "bit-drawer-body", - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [], - host: { - class: - "tw-p-4 tw-pt-0 tw-flex-1 tw-overflow-auto tw-border-solid tw-border tw-border-transparent tw-transition-colors tw-duration-200", - "[class.tw-border-t-secondary-300]": "this.hasScrolledFrom().top", - }, - hostDirectives: [ - { - directive: CdkScrollable, - }, - ], - template: ` `, -}) -export class DrawerBodyComponent { - protected hasScrolledFrom = hasScrolledFrom(); -} diff --git a/libs/components/src/drawer/drawer-close.directive.ts b/libs/components/src/drawer/drawer-close.directive.ts deleted file mode 100644 index f105e21ea62..00000000000 --- a/libs/components/src/drawer/drawer-close.directive.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Directive, inject } from "@angular/core"; - -import { DrawerComponent } from "./drawer.component"; - -/** - * Closes the ancestor drawer - * - * @example - * - * ```html - * - * - * - * ``` - **/ -@Directive({ - selector: "button[bitDrawerClose]", - host: { - "(click)": "onClick()", - }, -}) -export class DrawerCloseDirective { - private drawer = inject(DrawerComponent, { optional: true }); - - protected onClick() { - this.drawer?.open.set(false); - } -} diff --git a/libs/components/src/drawer/drawer-header.component.html b/libs/components/src/drawer/drawer-header.component.html deleted file mode 100644 index 2723744eda3..00000000000 --- a/libs/components/src/drawer/drawer-header.component.html +++ /dev/null @@ -1,9 +0,0 @@ -
-
- -

- {{ title() }} -

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

Lorem ipsum dolor...

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

Hello world!

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

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

- - - - - - - - -

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

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

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

- - -

- Hello world! -
- - `, - }), - args: { - open: true, - }, -}; - -export const MultipleDrawers: Story = { - render: (args) => ({ - props: args, - template: /*html*/ ` - - - - - - Foo - - - - Bar - - - `, - }), -}; diff --git a/libs/components/src/drawer/index.ts b/libs/components/src/drawer/index.ts deleted file mode 100644 index abf5b8d34f1..00000000000 --- a/libs/components/src/drawer/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./drawer.module"; -export * from "./drawer.component"; -export * from "./drawer-body.component"; -export * from "./drawer-close.directive"; -export * from "./drawer-header.component"; diff --git a/libs/components/src/header/header.stories.ts b/libs/components/src/header/header.stories.ts index 620f39a5dc3..23c2bb2edb5 100644 --- a/libs/components/src/header/header.stories.ts +++ b/libs/components/src/header/header.stories.ts @@ -14,7 +14,7 @@ import { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, NavigationModule, @@ -40,7 +40,7 @@ export default { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, NavigationModule, diff --git a/libs/components/src/icon-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 = { 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(); +export class IconComponent { + /** + * The Bitwarden icon name (e.g., "bwi-lock", "bwi-user") + */ + readonly name = input.required(); + /** + * Accessible label for the icon + */ readonly ariaLabel = input(); - constructor(private domSanitizer: DomSanitizer) { - effect(() => { - const icon = this.icon(); - if (!isIcon(icon)) { - return; - } - const svg = icon.svg; - this.innerHtml = this.domSanitizer.bypassSecurityTrustHtml(svg); - }); - } + protected readonly classList = computed(() => { + return ["bwi", this.name()].join(" "); + }); } diff --git a/libs/components/src/icon/icon.mdx b/libs/components/src/icon/icon.mdx index 4f6f13c895e..0914d681e59 100644 --- a/libs/components/src/icon/icon.mdx +++ b/libs/components/src/icon/icon.mdx @@ -8,113 +8,40 @@ import * as stories from "./icon.stories"; import { IconModule } from "@bitwarden/components"; ``` -# Icon Use Instructions +# Icon -- Icons will generally be attached to the associated Jira task. - - Designers should minify any SVGs before attaching them to Jira using a tool like - [SVGOMG](https://jakearchibald.github.io/svgomg/). - - **Note:** Ensure the "Remove viewbox" option is toggled off if responsive resizing of the icon - is desired. +The `bit-icon` component renders Bitwarden Web Icons (bwi) using icon font classes. -## Developer Instructions +## Basic Usage -1. **Download the SVG** and import it as an `.svg` initially into the IDE of your choice. - - The SVG should be formatted using either a built-in formatter or an external tool like - [SVG Formatter Beautifier](https://codebeautify.org/svg-formatter-beautifier) to make applying - classes easier. +```html + +``` -2. **Rename the file** as a `.icon.ts` TypeScript file and place it in the `libs/assets/svg` - lib. +## Icon Names -3. **Import** `svgIcon` from `./icon-service`. +All available icon names are defined in the `BitwardenIcon` type. Icons use the `bwi-*` naming +convention (e.g., `bwi-lock`, `bwi-user`, `bwi-key`). -4. **Define and export** a `const` to represent your `svgIcon`. +## Accessibility - ```typescript - export const ExampleIcon = svgIcon``; - ``` +By default, icons are decorative and marked with `aria-hidden="true"`. To make an icon accessible, +provide an `ariaLabel`: -5. **Replace any hardcoded strokes or fills** with the appropriate Tailwind class. - - **Note:** Stroke is used when styling the outline of an SVG path, while fill is used when - styling the inside of an SVG path. +```html + +``` - - A non-comprehensive list of common colors and their associated classes is below: +## Styling - | Hardcoded Value | Tailwind Stroke Class | Tailwind Fill Class | Tailwind Variable | - | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------- | ----------------------------------- | - | `#020F66` | `tw-stroke-illustration-outline` | `tw-fill-illustration-outline` | `--color-illustration-outline` | - | `#DBE5F6` | `tw-stroke-illustration-bg-primary` | `tw-fill-illustration-bg-primary` | `--color-illustration-bg-primary` | - | `#AAC3EF` | `tw-stroke-illustration-bg-secondary` | `tw-fill-illustration-bg-secondary` | `--color-illustration-bg-secondary` | - | `#FFFFFF` | `tw-stroke-illustration-bg-tertiary` | `tw-fill-illustration-bg-tertiary` | `--color-illustration-bg-tertiary` | - | `#FFBF00` | `tw-stroke-illustration-tertiary` | `tw-fill-illustration-tertiary` | `--color-illustration-tertiary` | - | `#175DDC` | `tw-stroke-illustration-logo` | `tw-fill-illustration-logo` | `--color-illustration-logo` | +The component renders as an inline element. Apply standard CSS classes or styles to customize +appearance: - - If the hex that you have on an SVG path is not listed above, there are a few ways to figure out - the appropriate Tailwind class: - - **Option 1: Figma** - - Open the SVG in Figma. - - Click on an individual path on the SVG until you see the path's properties in the - right-hand panel. - - Scroll down to the Colors section. - - Example: `Color/Illustration/Outline` - - This also includes Hex or RGB values that can be used to find the appropriate Tailwind - variable as well if you follow the manual search option below. - - Create the appropriate stroke or fill class from the color used. - - Example: `Color/Illustration/Outline` corresponds to `--color-illustration-outline` which - corresponds to `tw-stroke-illustration-outline` or `tw-fill-illustration-outline`. - - **Option 2: Manual Search** - - Take the path's stroke or fill hex value and convert it to RGB using a tool like - [Hex to RGB](https://www.rgbtohex.net/hex-to-rgb/). - - Search for the RGB value without commas in our `tw-theme.css` to find the Tailwind variable - that corresponds to the color. - - Create the appropriate stroke or fill class using the Tailwind variable. - - Example: `--color-illustration-outline` corresponds to `tw-stroke-illustration-outline` - or `tw-fill-illustration-outline`. +```html + +``` -6. **Remove any hardcoded width or height attributes** if your SVG has a configured - [viewBox](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox) attribute in order - to allow the SVG to scale to fit its container. - - **Note:** Scaling is required for any SVG used as an - [AnonLayout](?path=/docs/component-library-anon-layout--docs) `pageIcon`. +## Note on SVG Icons -7. **Replace any generic `clipPath` ids** (such as `id="a"`) with a unique id, and update the - referencing element to use the new id (such as `clip-path="url(#unique-id-here)"`). - -8. **Import your SVG const** anywhere you want to use the SVG. - - **Angular Component Example:** - - **TypeScript:** - - ```typescript - import { Component } from "@angular/core"; - import { IconModule } from '@bitwarden/components'; - import { ExampleIcon, Example2Icon } from "@bitwarden/assets/svg"; - - @Component({ - selector: "app-example", - standalone: true, - imports: [IconModule], - templateUrl: "./example.component.html", - }) - export class ExampleComponent { - readonly Icons = { ExampleIcon, Example2Icon }; - ... - } - ``` - - - **HTML:** - - > NOTE: SVG icons are treated as decorative by default and will be `aria-hidden` unless an - > `ariaLabel` is explicitly provided to the `` component - - ```html - - ``` - - With `ariaLabel` - - ```html - - ``` - -9. **Ensure your SVG renders properly** according to Figma in both light and dark modes on a client - which supports multiple style modes. +For SVG illustrations (not font icons), use the `bit-svg` component instead. See the Svg component +documentation for details. diff --git a/libs/components/src/icon/icon.module.ts b/libs/components/src/icon/icon.module.ts index 3d15b5bb3c3..b3e65619bd3 100644 --- a/libs/components/src/icon/icon.module.ts +++ b/libs/components/src/icon/icon.module.ts @@ -1,9 +1,9 @@ import { NgModule } from "@angular/core"; -import { BitIconComponent } from "./icon.component"; +import { IconComponent } from "./icon.component"; @NgModule({ - imports: [BitIconComponent], - exports: [BitIconComponent], + imports: [IconComponent], + exports: [IconComponent], }) export class IconModule {} diff --git a/libs/components/src/icon/icon.stories.ts b/libs/components/src/icon/icon.stories.ts index e94a7aaf51c..5626407ea51 100644 --- a/libs/components/src/icon/icon.stories.ts +++ b/libs/components/src/icon/icon.stories.ts @@ -1,50 +1,61 @@ -import { Meta } from "@storybook/angular"; +import { Meta, StoryObj } from "@storybook/angular"; -import * as SvgIcons from "@bitwarden/assets/svg"; +import { BITWARDEN_ICONS } from "../shared/icon"; -import { BitIconComponent } from "./icon.component"; +import { IconComponent } from "./icon.component"; export default { title: "Component Library/Icon", - component: BitIconComponent, + component: IconComponent, parameters: { design: { type: "figma", url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=21662-50335&t=k6OTDDPZOTtypRqo-11", }, }, -} as Meta; + argTypes: { + name: { + control: { type: "select" }, + options: BITWARDEN_ICONS, + }, + }, +} as Meta; -const { - // Filtering out the few non-icons in the libs/assets/svg import - // eslint-disable-next-line @typescript-eslint/no-unused-vars - DynamicContentNotAllowedError: _DynamicContentNotAllowedError, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - isIcon, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - svgIcon, - ...Icons -}: { - [key: string]: any; -} = SvgIcons; +type Story = StoryObj; -export const Default = { - render: (args: { icons: [string, any][] }) => ({ - props: args, - template: /*html*/ ` -
- @for (icon of icons; track icon[0]) { -
-
{{icon[0]}}
-
- -
-
- } -
- `, - }), +export const Default: Story = { args: { - icons: Object.entries(Icons), + name: "bwi-lock", }, }; + +export const AllIcons: Story = { + render: () => ({ + template: ` +
+ @for (icon of icons; track icon) { +
+ + {{ icon }} +
+ } +
+ `, + props: { + icons: BITWARDEN_ICONS, + }, + }), +}; + +export const WithAriaLabel: Story = { + args: { + name: "bwi-lock", + ariaLabel: "Secure lock icon", + }, +}; + +export const CompareWithLegacy: Story = { + render: () => ({ + template: ` `, + }), +}; diff --git a/libs/components/src/icon/index.ts b/libs/components/src/icon/index.ts index 1ee66e59837..670966a7630 100644 --- a/libs/components/src/icon/index.ts +++ b/libs/components/src/icon/index.ts @@ -1 +1,2 @@ export * from "./icon.module"; +export * from "./icon.component"; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 23fb5beb456..d92e0770e49 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -1,4 +1,5 @@ export { ButtonType, ButtonLikeAbstraction } from "./shared/button-like.abstraction"; +export { BitwardenIcon } from "./shared/icon"; export * from "./a11y"; export * from "./anon-layout"; export * from "./async-actions"; @@ -17,14 +18,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 @@ + + + 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 + * + *
+ * + *
+ *
+ * ``` + */ +@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 @@ +
+
+ + +
+
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 + * + * + * + * + * + * + * ``` + */ +@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("md"); + + private readonly maxWidthClassMap: Record = { + 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 @@ +
+ +
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 + * + *
+ * Privacy + * © 2024 Bitwarden + *
+ *
+ * ``` + */ +@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 @@ +
+ @if (!hideLogo()) { + + + + } +
+ +
+
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 + * + * + * + * + * ``` + */ +@Component({ + selector: "bit-landing-header", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-header.component.html", + imports: [RouterModule, SvgModule, SharedModule], +}) +export class LandingHeaderComponent { + readonly hideLogo = input(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()) { +
+ @if (icon()) { + + +
+ +
+ } + + @if (title()) { + +

+ {{ title() }} +

+ +

+ {{ title() }} +

+ } + + @if (subtitle()) { +
{{ subtitle() }}
+ } +
+} 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 + * + * ``` + */ +@Component({ + selector: "bit-landing-hero", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-hero.component.html", + imports: [SvgModule, TypographyModule], +}) +export class LandingHeroComponent { + readonly icon = input(null); + readonly title = input(); + readonly subtitle = input(); +} diff --git a/libs/components/src/landing-layout/landing-layout.component.html b/libs/components/src/landing-layout/landing-layout.component.html new file mode 100644 index 00000000000..a33054e8e64 --- /dev/null +++ b/libs/components/src/landing-layout/landing-layout.component.html @@ -0,0 +1,25 @@ +
+ +
+ +
+ @if (!hideBackgroundIllustration()) { +
+ +
+
+ +
+ } + +
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 + * + * ... + * ... + * ... + * + * ``` + */ +@Component({ + selector: "bit-landing-layout", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-layout.component.html", + imports: [SvgModule], +}) +export class LandingLayoutComponent { + readonly hideBackgroundIllustration = input(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 { + 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*/ ` + + @if (includeHeader) { + +
+
+
Header Content
+
+
+
+ } + +
+ @switch (contentLength) { + @case ('thin') { +
+
Thin Content
+
+ } + @case ('long') { +
+
Long Content
+
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?
+
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?
+
+ } + @default { +
+
Normal Content
+
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.
+
+ } + } +
+ + @if (includeFooter) { + +
+
Footer Content
+
+
+ } +
+ `, + }; + }, + + 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; + +type Story = StoryObj; + +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"; -
+
- @if ( - { - open: sideNavService.open$ | async, - }; - as data - ) { -
- @if (data.open) { -
- } -
- } +
+ @if (sideNavService.open()) { +
+ } +
diff --git a/libs/components/src/layout/layout.component.ts b/libs/components/src/layout/layout.component.ts index 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>("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>("skipLink"); + private readonly skipLink = viewChild.required("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 */ ` - + (args)}> @@ -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 @@ +
+ @if (startIcon()) { + + } + + + + @if (endIcon()) { + + } +
diff --git a/libs/components/src/link/link.component.ts b/libs/components/src/link/link.component.ts new file mode 100644 index 00000000000..d826a4633a9 --- /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 = { + 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]", + "disabled:tw-no-underline", + "disabled:tw-cursor-not-allowed", + "disabled:!tw-text-fg-disabled", + "disabled:hover:!tw-text-fg-disabled", + "disabled:hover:tw-no-underline", + "focus-visible:tw-outline-none", + "focus-visible:before:tw-ring-border-focus", + + // Workaround for html button tag not being able to be set to `display: inline` + // and at the same time not being able to use `tw-ring-offset` because of box-shadow issue. + // 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", +]; + +@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); + /** + * The variant of link you want to render + * @default "primary" + */ + readonly linkType = input("primary"); + /** + * The leading icon to display within the link + * @default undefined + */ + readonly startIcon = input(undefined); + /** + * The trailing icon to display within the link + * @default undefined + */ + readonly endIcon = input(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 = { - 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("primary"); -} - -/** - * Text Links and Buttons can use either the `` or `
-
-
@@ -98,23 +201,29 @@ export const Buttons: Story = { }, }; -export const Anchors: StoryObj = { +export const Anchors: StoryObj = { render: (args) => ({ - props: args, + props: { + linkType: args.linkType, + backgroundClass: + args.linkType === "contrast" + ? "tw-bg-bg-contrast" + : args.linkType === "light" + ? "tw-bg-bg-brand" + : "tw-bg-transparent", + }, template: /*html*/ ` -
+
@@ -134,23 +243,57 @@ export const Inline: Story = { props: args, template: /*html*/ ` - On the internet paragraphs often contain inline links, but few know that can be used for similar purposes. + On the internet paragraphs often contain inline links with very long text that might break, but few know that can be used for similar purposes. `, }), +}; + +export const WithIcons: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` +
+ + + +
+ +
+
+ +
+
+ +
+
+ `, + }), 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*/ ` - - + + Links can not be inactive +
- +
`, }), 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(); /** * Passed to internal `routerLink` * - * See {@link RouterLink.relativeTo} + * @see {@link RouterLink.relativeTo} */ readonly relativeTo = input(); /** * Passed to internal `routerLink` * - * See {@link RouterLinkActive.routerLinkActiveOptions} + * @default { paths: "subset", queryParams: "ignored", fragment: "ignored", matrixParams: "ignored" } + * @see {@link RouterLinkActive.routerLinkActiveOptions} */ readonly routerLinkActiveOptions = input({ paths: "subset", @@ -71,7 +77,5 @@ export abstract class NavBaseComponent { /** * Fires when main content is clicked */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref - @Output() mainContentClicked: EventEmitter = new EventEmitter(); + readonly mainContentClicked = output(); } diff --git a/libs/components/src/navigation/nav-divider.component.html b/libs/components/src/navigation/nav-divider.component.html index 2d8e1dfa24b..7af7de2a28a 100644 --- a/libs/components/src/navigation/nav-divider.component.html +++ b/libs/components/src/navigation/nav-divider.component.html @@ -1,3 +1,3 @@ -@if (sideNavService.open$ | async) { +@if (sideNavService.open()) {
} diff --git a/libs/components/src/navigation/nav-divider.component.ts b/libs/components/src/navigation/nav-divider.component.ts index 2f33883fd58..05a69563312 100644 --- a/libs/components/src/navigation/nav-divider.component.ts +++ b/libs/components/src/navigation/nav-divider.component.ts @@ -1,15 +1,15 @@ -import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +import { ChangeDetectionStrategy, Component, inject } from "@angular/core"; import { SideNavService } from "./side-nav.service"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +/** + * A visual divider for separating navigation items in the side navigation. + */ @Component({ selector: "bit-nav-divider", templateUrl: "./nav-divider.component.html", - imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class NavDividerComponent { - constructor(protected sideNavService: SideNavService) {} + protected readonly sideNavService = inject(SideNavService); } diff --git a/libs/components/src/navigation/nav-group.component.html b/libs/components/src/navigation/nav-group.component.html index 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 @@ - + } @if (open) {
@@ -88,3 +59,27 @@
}
+ + + +
+ @if (icon()) { + + } + @if (open) { + {{ text() }} + } +
+
diff --git a/libs/components/src/navigation/nav-item.component.ts b/libs/components/src/navigation/nav-item.component.ts index 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>; } -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-nav-item", templateUrl: "./nav-item.component.html", providers: [{ provide: NavBaseComponent, useExisting: NavItemComponent }], - imports: [CommonModule, IconButtonModule, RouterModule], + imports: [NgTemplateOutlet, IconButtonModule, RouterModule], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + "(focusin)": "onFocusIn($event.target)", + "(focusout)": "onFocusOut()", + }, }) export class NavItemComponent extends NavBaseComponent { /** @@ -35,9 +45,14 @@ export class NavItemComponent extends NavBaseComponent { */ protected readonly TREE_DEPTH_PADDING = 1.75; - /** Forces active styles to be shown, regardless of the `routerLinkActiveOptions` */ + /** + * Forces active styles to be shown, regardless of the `routerLinkActiveOptions` + */ readonly forceActiveStyles = input(false); + protected readonly sideNavService = inject(SideNavService); + private readonly parentNavGroup = inject(NavGroupAbstraction, { optional: true }); + /** * Is `true` if `to` matches the current route */ @@ -56,7 +71,7 @@ export class NavItemComponent extends NavBaseComponent { * adding calculation for tree variant due to needing visual alignment on different indentation levels needed between the first level and subsequent levels */ protected readonly navItemIndentationPadding = computed(() => { - const open = this.sideNavService.open; + const open = this.sideNavService.open(); const depth = this.treeDepth() ?? 0; if (open && this.variant() === "tree") { @@ -87,25 +102,22 @@ export class NavItemComponent extends NavBaseComponent { * (denoted with the data-fvw attribute) matches :focus-visible. We then map that state to some * styles, so the entire component can have an outline. */ - protected focusVisibleWithin$ = new BehaviorSubject(false); - protected fvwStyles$ = this.focusVisibleWithin$.pipe( - map((value) => - value ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-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 @@ diff --git a/libs/components/src/navigation/nav-logo.component.ts b/libs/components/src/navigation/nav-logo.component.ts index 0602e8b753c..4b3dc471edb 100644 --- a/libs/components/src/navigation/nav-logo.component.ts +++ b/libs/components/src/navigation/nav-logo.component.ts @@ -1,34 +1,40 @@ -import { CommonModule } from "@angular/common"; -import { Component, input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input, inject } from "@angular/core"; import { RouterLinkActive, RouterLink } from "@angular/router"; -import { BitwardenShield, Icon } from "@bitwarden/assets/svg"; +import { BitwardenShield, BitSvg } from "@bitwarden/assets/svg"; -import { BitIconComponent } from "../icon/icon.component"; +import { SvgComponent } from "../svg/svg.component"; import { SideNavService } from "./side-nav.service"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-nav-logo", templateUrl: "./nav-logo.component.html", - imports: [CommonModule, RouterLinkActive, RouterLink, BitIconComponent], + imports: [RouterLinkActive, RouterLink, SvgComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class NavLogoComponent { - /** Icon that is displayed when the side nav is closed */ + protected readonly sideNavService = inject(SideNavService); + + /** + * Icon that is displayed when the side nav is closed + * + * @default BitwardenShield + */ readonly closedIcon = input(BitwardenShield); - /** Icon that is displayed when the side nav is open */ - readonly openIcon = input.required(); + /** + * Icon that is displayed when the side nav is open + */ + readonly openIcon = input.required(); /** * Route to be passed to internal `routerLink` */ readonly route = input.required(); - /** Passed to `attr.aria-label` and `attr.title` */ + /** + * Passed to `attr.aria-label` and `attr.title` + */ readonly label = input.required(); - - constructor(protected sideNavService: SideNavService) {} } diff --git a/libs/components/src/navigation/side-nav.component.html b/libs/components/src/navigation/side-nav.component.html index 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 -) { -
- +@let open = sideNavService.open(); +@let isOverlay = sideNavService.isOverlay(); + +
+
-} + class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-bg-sidenav" + > + + @if (open) { + + } +
+ +
+
+ + +
diff --git a/libs/components/src/navigation/side-nav.component.ts b/libs/components/src/navigation/side-nav.component.ts index b13920d9749..35835f1be96 100644 --- a/libs/components/src/navigation/side-nav.component.ts +++ b/libs/components/src/navigation/side-nav.component.ts @@ -1,7 +1,14 @@ import { CdkTrapFocus } from "@angular/cdk/a11y"; import { DragDropModule, CdkDragMove } from "@angular/cdk/drag-drop"; -import { CommonModule } from "@angular/common"; -import { Component, ElementRef, inject, input, viewChild } from "@angular/core"; +import { AsyncPipe } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + input, + viewChild, + inject, +} from "@angular/core"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -12,35 +19,42 @@ import { SideNavService } from "./side-nav.service"; export type SideNavVariant = "primary" | "secondary"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +/** + * Side navigation component that provides a collapsible navigation menu. + */ @Component({ selector: "bit-side-nav", templateUrl: "side-nav.component.html", imports: [ - CommonModule, CdkTrapFocus, NavDividerComponent, BitIconButtonComponent, I18nPipe, DragDropModule, + AsyncPipe, ], host: { class: "tw-block tw-h-full", }, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SideNavComponent { - protected sideNavService = inject(SideNavService); + protected readonly sideNavService = inject(SideNavService); + /** + * Visual variant of the side navigation + * + * @default "primary" + */ readonly variant = input("primary"); private readonly toggleButton = viewChild("toggleButton", { read: ElementRef }); private elementRef = inject>(ElementRef); - protected handleKeyDown = (event: KeyboardEvent) => { + protected readonly handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { - this.sideNavService.setClose(); + this.sideNavService.open.set(false); this.toggleButton()?.nativeElement.focus(); return false; } diff --git a/libs/components/src/navigation/side-nav.service.ts b/libs/components/src/navigation/side-nav.service.ts index 63e54c81fe5..05713006a43 100644 --- a/libs/components/src/navigation/side-nav.service.ts +++ b/libs/components/src/navigation/side-nav.service.ts @@ -1,15 +1,6 @@ -import { inject, Injectable } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { - BehaviorSubject, - Observable, - combineLatest, - fromEvent, - map, - startWith, - debounceTime, - first, -} from "rxjs"; +import { computed, effect, inject, Injectable, signal } from "@angular/core"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { BehaviorSubject, Observable, fromEvent, map, startWith, debounceTime, first } from "rxjs"; import { BIT_SIDE_NAV_DISK, GlobalStateProvider, KeyDefinition } from "@bitwarden/state"; @@ -32,16 +23,17 @@ export class SideNavService { private rootFontSizePx: number; - private _open$ = new BehaviorSubject(isAtOrLargerThanBreakpoint("md")); - open$ = this._open$.asObservable(); + /** + * Whether the side navigation is open or closed. + */ + readonly open = signal(isAtOrLargerThanBreakpoint("md")); private isLargeScreen$ = media(`(min-width: ${BREAKPOINTS.md}px)`); - private _userCollapsePreference$ = new BehaviorSubject(null); - userCollapsePreference$ = this._userCollapsePreference$.asObservable(); + readonly isLargeScreen = toSignal(this.isLargeScreen$, { requireSync: true }); - isOverlay$ = combineLatest([this.open$, this.isLargeScreen$]).pipe( - map(([open, isLargeScreen]) => open && !isLargeScreen), - ); + readonly userCollapsePreference = signal(null); + + readonly isOverlay = computed(() => this.open() && !this.isLargeScreen()); /** * Local component state width @@ -67,16 +59,14 @@ export class SideNavService { this.rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || "16"); // Handle open/close state - combineLatest([this.isLargeScreen$, this.userCollapsePreference$]) - .pipe(takeUntilDestroyed()) - .subscribe(([isLargeScreen, userCollapsePreference]) => { - if (!isLargeScreen) { - this.setClose(); - } else if (userCollapsePreference !== "closed") { - // Auto-open when user hasn't set preference (null) or prefers open - this.setOpen(); - } - }); + effect(() => { + if (!this.isLargeScreen()) { + this.open.set(false); + } else if (this.userCollapsePreference() !== "closed") { + // Auto-open when user hasn't set preference (null) or prefers open + this.open.set(true); + } + }); // Initialize the resizable width from state provider this.widthState$.pipe(first()).subscribe((width: number) => { @@ -89,31 +79,14 @@ export class SideNavService { }); } - get open() { - return this._open$.getValue(); - } - - setOpen() { - this._open$.next(true); - } - - setClose() { - this._open$.next(false); - } - /** * Toggle the open/close state of the side nav */ toggle() { - const curr = this._open$.getValue(); // Store user's preference based on what state they're toggling TO - this._userCollapsePreference$.next(curr ? "closed" : "open"); + this.userCollapsePreference.set(this.open() ? "closed" : "open"); - if (curr) { - this.setClose(); - } else { - this.setOpen(); - } + this.open.set(!this.open()); } /** diff --git a/libs/components/src/no-items/no-items.component.html b/libs/components/src/no-items/no-items.component.html index e728584a41a..46a5c25526a 100644 --- a/libs/components/src/no-items/no-items.component.html +++ b/libs/components/src/no-items/no-items.component.html @@ -1,7 +1,7 @@
- +

diff --git a/libs/components/src/no-items/no-items.component.ts b/libs/components/src/no-items/no-items.component.ts index c6e52a1f83d..d2cacfd2251 100644 --- a/libs/components/src/no-items/no-items.component.ts +++ b/libs/components/src/no-items/no-items.component.ts @@ -1,18 +1,17 @@ -import { Component, input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; import { NoResults } from "@bitwarden/assets/svg"; -import { BitIconComponent } from "../icon/icon.component"; +import { SvgComponent } from "../svg/svg.component"; /** * Component for displaying a message when there are no items to display. Expects title, description and button slots. */ -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-no-items", templateUrl: "./no-items.component.html", - imports: [BitIconComponent], + imports: [SvgComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class NoItemsComponent { readonly icon = input(NoResults); diff --git a/libs/components/src/stories/kitchen-sink/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 + + @@ -113,6 +115,11 @@ + + @@ -127,6 +134,7 @@ [unlockOptions]="unlockOptions" [biometricUnlockBtnText]="biometricUnlockBtnText" (successfulUnlock)="successfulMasterPasswordUnlock($event)" + (prfUnlockSuccess)="onPrfUnlockSuccess($event)" (logOut)="logOut()" > } diff --git a/libs/key-management-ui/src/lock/components/lock.component.spec.ts b/libs/key-management-ui/src/lock/components/lock.component.spec.ts index 054212f8851..47c4d14fc98 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.spec.ts @@ -51,6 +51,7 @@ import { UnlockOptionValue, UnlockOptions, } from "../services/lock-component.service"; +import { WebAuthnPrfUnlockService } from "../services/webauthn-prf-unlock.service"; import { LockComponent } from "./lock.component"; @@ -84,6 +85,7 @@ describe("LockComponent", () => { const mockLockComponentService = mock(); const mockAnonLayoutWrapperDataService = mock(); const mockBroadcasterService = mock(); + const mockWebAuthnPrfUnlockService = mock(); const mockEncryptedMigrator = mock(); const mockActivatedRoute = { snapshot: { @@ -149,6 +151,7 @@ describe("LockComponent", () => { { provide: LockComponentService, useValue: mockLockComponentService }, { provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService }, { provide: BroadcasterService, useValue: mockBroadcasterService }, + { provide: WebAuthnPrfUnlockService, useValue: mockWebAuthnPrfUnlockService }, { provide: ActivatedRoute, useValue: mockActivatedRoute }, { provide: EncryptedMigrator, useValue: mockEncryptedMigrator }, ], diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index 03ab6033441..9900aa6e827 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -6,7 +6,7 @@ import { BehaviorSubject, filter, firstValueFrom, - interval, + timer, mergeMap, Subject, switchMap, @@ -60,6 +60,7 @@ import { } from "../services/lock-component.service"; import { MasterPasswordLockComponent } from "./master-password-lock/master-password-lock.component"; +import { UnlockViaPrfComponent } from "./unlock-via-prf.component"; const BroadcasterSubscriptionId = "LockComponent"; @@ -98,6 +99,7 @@ const BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES = [ FormFieldModule, AsyncActionsModule, IconButtonModule, + UnlockViaPrfComponent, MasterPasswordLockComponent, TooltipDirective, ], @@ -197,7 +199,7 @@ export class LockComponent implements OnInit, OnDestroy { } private listenForUnlockOptionsChanges() { - interval(1000) + timer(0, 1000) .pipe( mergeMap(async () => { if (this.activeAccount?.id != null) { @@ -460,6 +462,14 @@ export class LockComponent implements OnInit, OnDestroy { } } + async onPrfUnlockSuccess(userKey: UserKey): Promise { + await this.setUserKeyAndContinue(userKey); + } + + togglePassword() { + this.showPassword = !this.showPassword; + } + private validatePin(): boolean { if (this.formGroup?.invalid) { this.toastService.showToast({ diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html index 4c7cdd48353..878915ec6ff 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html @@ -54,6 +54,11 @@ } + + diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts index dabab3e558a..6d0da1033b7 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts @@ -18,6 +18,7 @@ import { UserKey } from "@bitwarden/common/types/key"; import { AsyncActionsModule, ButtonModule, + DialogService, FormFieldModule, IconButtonModule, ToastService, @@ -27,6 +28,7 @@ import { CommandDefinition, MessageListener } from "@bitwarden/messaging"; import { UserId } from "@bitwarden/user-core"; import { UnlockOption, UnlockOptions } from "../../services/lock-component.service"; +import { WebAuthnPrfUnlockService } from "../../services/webauthn-prf-unlock.service"; import { MasterPasswordLockComponent } from "./master-password-lock.component"; @@ -41,6 +43,8 @@ describe("MasterPasswordLockComponent", () => { const logService = mock(); const platformUtilsService = mock(); const messageListener = mock(); + const webAuthnPrfUnlockService = mock(); + const dialogService = mock(); const mockMasterPassword = "testExample"; const activeAccount: Account = { @@ -64,6 +68,7 @@ describe("MasterPasswordLockComponent", () => { enabled: false, biometricsStatus: BiometricsStatus.NotEnabledLocally, }, + prf: { enabled: false }, }; accountService.activeAccount$ = of(account); @@ -110,6 +115,8 @@ describe("MasterPasswordLockComponent", () => { { provide: LogService, useValue: logService }, { provide: PlatformUtilsService, useValue: platformUtilsService }, { provide: MessageListener, useValue: messageListener }, + { provide: WebAuthnPrfUnlockService, useValue: webAuthnPrfUnlockService }, + { provide: DialogService, useValue: dialogService }, ], }).compileComponents(); diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts index 1237869717f..5229effd366 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts @@ -36,6 +36,7 @@ import { UnlockOptions, UnlockOptionValue, } from "../../services/lock-component.service"; +import { UnlockViaPrfComponent } from "../unlock-via-prf.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -49,6 +50,7 @@ import { FormFieldModule, AsyncActionsModule, IconButtonModule, + UnlockViaPrfComponent, ], }) export class MasterPasswordLockComponent implements OnInit, OnDestroy { @@ -76,6 +78,7 @@ export class MasterPasswordLockComponent implements OnInit, OnDestroy { }); successfulUnlock = output<{ userKey: UserKey; masterPassword: string }>(); + prfUnlockSuccess = output(); logOut = output(); protected showPassword = false; @@ -143,4 +146,8 @@ export class MasterPasswordLockComponent implements OnInit, OnDestroy { }); } } + + onPrfUnlockSuccess(userKey: UserKey): void { + this.prfUnlockSuccess.emit(userKey); + } } diff --git a/libs/key-management-ui/src/lock/components/unlock-via-prf.component.ts b/libs/key-management-ui/src/lock/components/unlock-via-prf.component.ts new file mode 100644 index 00000000000..7a0b99b232d --- /dev/null +++ b/libs/key-management-ui/src/lock/components/unlock-via-prf.component.ts @@ -0,0 +1,114 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit, input, output } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { UserKey } from "@bitwarden/common/types/key"; +import { AsyncActionsModule, ButtonModule, DialogService } from "@bitwarden/components"; + +import { WebAuthnPrfUnlockService } from "../services/webauthn-prf-unlock.service"; + +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "bit-unlock-via-prf", + standalone: true, + imports: [CommonModule, JslibModule, ButtonModule, AsyncActionsModule], + template: ` + @if (isAvailable) { + @if (formButton()) { + + } + @if (!formButton()) { + + } + } + `, +}) +export class UnlockViaPrfComponent implements OnInit { + readonly formButton = input(false); + readonly unlockSuccess = output(); + + unlocking = false; + isAvailable = false; + private userId: UserId | null = null; + + constructor( + private accountService: AccountService, + private webAuthnPrfUnlockService: WebAuthnPrfUnlockService, + private dialogService: DialogService, + private i18nService: I18nService, + private logService: LogService, + ) {} + + async ngOnInit(): Promise { + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + if (activeAccount?.id) { + this.userId = activeAccount.id; + this.isAvailable = await this.webAuthnPrfUnlockService.isPrfUnlockAvailable(this.userId); + } + } + + async unlockViaPrf(): Promise { + if (!this.userId || !this.isAvailable) { + return; + } + + this.unlocking = true; + + try { + const userKey = await this.webAuthnPrfUnlockService.unlockVaultWithPrf(this.userId); + this.unlockSuccess.emit(userKey); + } catch (error) { + this.logService.error("[UnlockViaPrfComponent] Failed to unlock via PRF:", error); + + let errorMessage = this.i18nService.t("unexpectedError"); + + // Handle specific PRF error cases + if (error instanceof Error) { + if (error.message.includes("No PRF credentials")) { + errorMessage = this.i18nService.t("noPrfCredentialsAvailable"); + } else if (error.message.includes("canceled")) { + // User canceled the operation, don't show error + this.unlocking = false; + return; + } + } + + await this.dialogService.openSimpleDialog({ + title: { key: "error" }, + content: errorMessage, + acceptButtonText: { key: "ok" }, + type: "danger", + }); + } finally { + this.unlocking = false; + } + } +} diff --git a/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts b/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts new file mode 100644 index 00000000000..b3bbf392d0a --- /dev/null +++ b/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts @@ -0,0 +1,290 @@ +import { firstValueFrom } from "rxjs"; + +import { + UserDecryptionOptions, + UserDecryptionOptionsServiceAbstraction, + WebAuthnPrfUserDecryptionOption, +} from "@bitwarden/auth/common"; +import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction"; +import { ClientType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils"; +import { UserId } from "@bitwarden/common/types/guid"; +import { PrfKey, UserKey } from "@bitwarden/common/types/key"; +import { KeyService } from "@bitwarden/key-management"; + +import { WebAuthnPrfUnlockService } from "./webauthn-prf-unlock.service"; + +export class DefaultWebAuthnPrfUnlockService implements WebAuthnPrfUnlockService { + private navigatorCredentials: CredentialsContainer; + + constructor( + private webAuthnLoginPrfKeyService: WebAuthnLoginPrfKeyServiceAbstraction, + private keyService: KeyService, + private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + private encryptService: EncryptService, + private environmentService: EnvironmentService, + private platformUtilsService: PlatformUtilsService, + private window: Window, + private logService: LogService, + private configService: ConfigService, + ) { + this.navigatorCredentials = this.window.navigator.credentials; + } + + async isPrfUnlockAvailable(userId: UserId): Promise { + try { + // Check if feature flag is enabled + const passkeyUnlockEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PasskeyUnlock, + ); + if (!passkeyUnlockEnabled) { + return false; + } + + // Check if browser supports WebAuthn + if (!this.navigatorCredentials || !this.navigatorCredentials.get) { + return false; + } + + // PRF unlock is only supported on Web and Chromium-based browser extensions + const clientType = this.platformUtilsService.getClientType(); + if (clientType === ClientType.Browser && !this.platformUtilsService.isChromium()) { + return false; + } + if (clientType !== ClientType.Web && clientType !== ClientType.Browser) { + return false; + } + + // Check if user has any WebAuthn PRF credentials registered + const credentials = await this.getPrfUnlockCredentials(userId); + if (credentials.length === 0) { + return false; + } + + return true; + } catch (error) { + this.logService.error("Error checking PRF unlock availability:", error); + return false; + } + } + + private async getPrfUnlockCredentials( + userId: UserId, + ): Promise<{ credentialId: string; transports: string[] }[]> { + try { + const userDecryptionOptions = await this.getUserDecryptionOptions(userId); + if (!userDecryptionOptions?.webAuthnPrfOptions) { + return []; + } + return userDecryptionOptions.webAuthnPrfOptions.map((option) => ({ + credentialId: option.credentialId, + transports: option.transports, + })); + } catch (error) { + this.logService.error("Error getting PRF unlock credentials:", error); + return []; + } + } + + /** + * Unlocks the vault using WebAuthn PRF. + * + * @param userId The user ID to unlock vault for + * @returns Promise the decrypted user key + * @throws Error if unlock fails for any reason + */ + async unlockVaultWithPrf(userId: UserId): Promise { + // Get offline PRF credentials from user decryption options + const credentials = await this.getPrfUnlockCredentials(userId); + if (credentials.length === 0) { + throw new Error("No PRF credentials available for unlock"); + } + + const response = await this.performWebAuthnGetWithPrf(credentials, userId); + const prfKey = await this.createPrfKeyFromResponse(response); + const prfOption = await this.getPrfOptionForCredential(response.id, userId); + + // PRF unlock follows the same key derivation process as PRF login: + // PRF key → decrypt private key → use private key to decrypt user key + + // Step 1: Decrypt PRF encrypted private key using the PRF key + const privateKey = await this.encryptService.unwrapDecapsulationKey( + new EncString(prfOption.encryptedPrivateKey), + prfKey, + ); + + // Step 2: Use private key to decrypt user key + const userKey = await this.encryptService.decapsulateKeyUnsigned( + new EncString(prfOption.encryptedUserKey), + privateKey, + ); + + if (!userKey) { + throw new Error("Failed to decrypt user key from private key"); + } + + return userKey as UserKey; + } + + /** + * Performs WebAuthn get operation with PRF extension. + * + * @param credentials Available PRF credentials for the user + * @returns PublicKeyCredential response from the authenticator + * @throws Error if WebAuthn operation fails or returns invalid response + */ + private async performWebAuthnGetWithPrf( + credentials: { credentialId: string; transports: string[] }[], + userId: UserId, + ): Promise { + const rpId = await this.getRpIdForUser(userId); + const prfSalt = await this.getUnlockWithPrfSalt(); + + const options: CredentialRequestOptions = { + publicKey: { + challenge: new Uint8Array(32), + allowCredentials: credentials.map(({ credentialId, transports }) => { + // The credential ID is already base64url encoded from login storage + // We need to decode it to ArrayBuffer for WebAuthn + const decodedId = Fido2Utils.stringToBuffer(credentialId); + return { + type: "public-key", + id: decodedId, + transports: (transports || []) as AuthenticatorTransport[], + }; + }), + rpId, + userVerification: "preferred", // Allow platform authenticators to work properly + extensions: { + prf: { eval: { first: prfSalt } }, + } as any, + }, + }; + + const response = await this.navigatorCredentials.get(options); + + if (!response) { + throw new Error("WebAuthn get() returned null/undefined"); + } + + if (!(response instanceof PublicKeyCredential)) { + throw new Error("Failed to get PRF credential for unlock"); + } + + return response; + } + + /** + * Extracts PRF result from WebAuthn response and creates a PrfKey. + * + * @param response PublicKeyCredential response from authenticator + * @returns PrfKey derived from the PRF extension output + * @throws Error if no PRF result is present in the response + */ + private async createPrfKeyFromResponse(response: PublicKeyCredential): Promise { + // Extract PRF result + // TODO: Remove `any` when typescript typings add support for PRF + const extensionResults = response.getClientExtensionResults() as any; + const prfResult = extensionResults.prf?.results?.first; + if (!prfResult) { + throw new Error("No PRF result received from authenticator"); + } + + try { + return await this.webAuthnLoginPrfKeyService.createSymmetricKeyFromPrf(prfResult); + } catch (error) { + this.logService.error("Failed to create unlock key from PRF:", error); + throw error; + } + } + + /** + * Gets the WebAuthn PRF option that matches the credential used in the response. + * + * @param credentialId Credential ID to match + * @param userId User ID to get decryption options for + * @returns Matching WebAuthnPrfUserDecryptionOption with encrypted keys + * @throws Error if no PRF options exist or no matching option is found + */ + private async getPrfOptionForCredential( + credentialId: string, + userId: UserId, + ): Promise { + const userDecryptionOptions = await this.getUserDecryptionOptions(userId); + + if ( + !userDecryptionOptions?.webAuthnPrfOptions || + userDecryptionOptions.webAuthnPrfOptions.length === 0 + ) { + throw new Error("No WebAuthn PRF option found for user - cannot perform PRF unlock"); + } + + const prfOption = userDecryptionOptions.webAuthnPrfOptions.find( + (option) => option.credentialId === credentialId, + ); + + if (!prfOption) { + throw new Error("No matching WebAuthn PRF option found for this credential"); + } + + return prfOption; + } + + private async getUnlockWithPrfSalt(): Promise { + try { + // Use the same salt as login to ensure PRF keys match + return await this.webAuthnLoginPrfKeyService.getLoginWithPrfSalt(); + } catch (error) { + this.logService.error("Error getting unlock PRF salt:", error); + throw error; + } + } + + /** + * Helper method to get user decryption options for a user + */ + private async getUserDecryptionOptions(userId: UserId): Promise { + try { + return (await firstValueFrom( + this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), + )) as UserDecryptionOptions; + } catch (error) { + this.logService.error("Error getting user decryption options:", error); + return null; + } + } + + /** + * Helper method to get the appropriate rpId for WebAuthn PRF operations + * Returns the hostname from the user's environment configuration + */ + private async getRpIdForUser(userId: UserId): Promise { + try { + const environment = await firstValueFrom(this.environmentService.getEnvironment$(userId)); + const hostname = Utils.getHost(environment.getWebVaultUrl()); + + // The navigator.credentials.get call will fail if rpId is set but is null/empty. Undefined uses the current host. + if (!hostname) { + return undefined; + } + + // Extract hostname using URL parsing to handle IPv6 and ports correctly + // This removes ports etc. + const url = new URL(`https://${hostname}`); + const rpId = url.hostname; + + return rpId; + } catch (error) { + this.logService.error("Error getting rpId", error); + return undefined; + } + } +} diff --git a/libs/key-management-ui/src/lock/services/lock-component.service.ts b/libs/key-management-ui/src/lock/services/lock-component.service.ts index 0fc25ca7dfb..53cb256f251 100644 --- a/libs/key-management-ui/src/lock/services/lock-component.service.ts +++ b/libs/key-management-ui/src/lock/services/lock-component.service.ts @@ -10,6 +10,7 @@ export const UnlockOption = Object.freeze({ MasterPassword: "masterPassword", Pin: "pin", Biometrics: "biometrics", + Prf: "prf", }) satisfies { [Prop in keyof UnlockOptions as Capitalize]: Prop }; export type UnlockOptions = { @@ -23,6 +24,9 @@ export type UnlockOptions = { enabled: boolean; biometricsStatus: BiometricsStatus; }; + prf: { + enabled: boolean; + }; }; /** diff --git a/libs/key-management-ui/src/lock/services/webauthn-prf-unlock.service.ts b/libs/key-management-ui/src/lock/services/webauthn-prf-unlock.service.ts new file mode 100644 index 00000000000..f0b02a0ed3f --- /dev/null +++ b/libs/key-management-ui/src/lock/services/webauthn-prf-unlock.service.ts @@ -0,0 +1,27 @@ +import { UserKey } from "@bitwarden/common/types/key"; +import { UserId } from "@bitwarden/user-core"; + +/** + * Service for unlocking vault using WebAuthn PRF. + * Provides offline vault unlock capabilities by deriving unlock keys from PRF outputs. + */ +export abstract class WebAuthnPrfUnlockService { + /** + * Check if PRF unlock is available for the current user + * @param userId The user ID to check PRF unlock availability for + * @returns Promise true if PRF unlock is available + */ + abstract isPrfUnlockAvailable(userId: UserId): Promise; + + /** + * Attempt to unlock the vault using WebAuthn PRF + * @param userId The user ID to unlock vault for + * @returns Promise the decrypted user key + * @throws Error if no PRF credentials are available + * @throws Error if the authenticator returns no PRF result + * @throws Error if the user cancels the WebAuthn operation + * @throws Error if decryption of the user key fails + * @throws Error if no matching PRF option is found for the credential + */ + abstract unlockVaultWithPrf(userId: UserId): Promise; +} diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index 508f45995db..c6c9c641bbd 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -69,20 +69,6 @@ export abstract class KeyService { * @param userId The desired user */ abstract setUserKey(key: UserKey, userId: UserId): Promise; - /** - * 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; /** * Gets the user key from memory and sets it again, * kicking off a refresh of any additional keys @@ -261,21 +247,6 @@ export abstract class KeyService { abstract makeOrgKey( userId: UserId, ): Promise<[UnsignedSharedKey, 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; - /** - * 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; /** * Gets an observable stream of the given users decrypted private key, will emit null if the user @@ -283,8 +254,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; @@ -429,7 +399,5 @@ export abstract class KeyService { */ abstract validateUserKey(key: UserKey, userId: UserId): Promise; - abstract setSignedPublicKey(signedPublicKey: SignedPublicKey, userId: UserId): Promise; - abstract userSignedPublicKey$(userId: UserId): Observable; } diff --git a/libs/key-management/src/biometrics/biometric-state.service.spec.ts b/libs/key-management/src/biometrics/biometric-state.service.spec.ts index 32043514ff7..2f1f189a897 100644 --- a/libs/key-management/src/biometrics/biometric-state.service.spec.ts +++ b/libs/key-management/src/biometrics/biometric-state.service.spec.ts @@ -179,18 +179,36 @@ describe("BiometricStateService", () => { }); describe("biometricUnlockEnabled$", () => { - it("emits when biometricUnlockEnabled state is updated", async () => { - const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); - state.nextState(true); + describe("no user id provided, active user", () => { + it("emits when biometricUnlockEnabled state is updated", async () => { + const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); + state.nextState(true); - expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(true); + expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(true); + }); + + it("emits false when biometricUnlockEnabled state is undefined", async () => { + const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); + state.nextState(undefined as unknown as boolean); + + expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(false); + }); }); - it("emits false when biometricUnlockEnabled state is undefined", async () => { - const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); - state.nextState(undefined as unknown as boolean); + describe("user id provided", () => { + it("returns biometricUnlockEnabled state for the given user", async () => { + stateProvider.singleUser.getFake(userId, BIOMETRIC_UNLOCK_ENABLED).nextState(true); - expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(false); + expect(await firstValueFrom(sut.biometricUnlockEnabled$(userId))).toBe(true); + }); + + it("returns false when the state is not set", async () => { + stateProvider.singleUser + .getFake(userId, BIOMETRIC_UNLOCK_ENABLED) + .nextState(undefined as unknown as boolean); + + expect(await firstValueFrom(sut.biometricUnlockEnabled$(userId))).toBe(false); + }); }); }); @@ -198,7 +216,7 @@ describe("BiometricStateService", () => { it("updates biometricUnlockEnabled$", async () => { await sut.setBiometricUnlockEnabled(true); - expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(true); + expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(true); }); it("updates state", async () => { @@ -210,22 +228,6 @@ describe("BiometricStateService", () => { }); }); - describe("getBiometricUnlockEnabled", () => { - it("returns biometricUnlockEnabled state for the given user", async () => { - stateProvider.singleUser.getFake(userId, BIOMETRIC_UNLOCK_ENABLED).nextState(true); - - expect(await sut.getBiometricUnlockEnabled(userId)).toBe(true); - }); - - it("returns false when the state is not set", async () => { - stateProvider.singleUser - .getFake(userId, BIOMETRIC_UNLOCK_ENABLED) - .nextState(undefined as unknown as boolean); - - expect(await sut.getBiometricUnlockEnabled(userId)).toBe(false); - }); - }); - describe("setFingerprintValidated", () => { it("updates fingerprintValidated$", async () => { await sut.setFingerprintValidated(true); diff --git a/libs/key-management/src/biometrics/biometric-state.service.ts b/libs/key-management/src/biometrics/biometric-state.service.ts index 1488f12b50b..ca1cbcfa871 100644 --- a/libs/key-management/src/biometrics/biometric-state.service.ts +++ b/libs/key-management/src/biometrics/biometric-state.service.ts @@ -18,9 +18,11 @@ import { export abstract class BiometricStateService { /** - * `true` if the currently active user has elected to store a biometric key to unlock their vault. + * Returns whether biometric unlock is enabled for a user. + * @param userId The user id to check. If not provided, returns the state for the currently active user. + * @returns An observable that emits `true` if the user has elected to store a biometric key to unlock their vault. */ - abstract biometricUnlockEnabled$: Observable; // used to be biometricUnlock + abstract biometricUnlockEnabled$(userId?: UserId): Observable; /** * If the user has elected to require a password on first unlock of an application instance, this key will store the * encrypted client key half used to unlock the vault. @@ -53,6 +55,7 @@ export abstract class BiometricStateService { /** * Gets the biometric unlock enabled state for the given user. + * @deprecated Use {@link biometricUnlockEnabled$} instead * @param userId user Id to check */ abstract getBiometricUnlockEnabled(userId: UserId): Promise; @@ -103,7 +106,6 @@ export class DefaultBiometricStateService implements BiometricStateService { private promptAutomaticallyState: ActiveUserState; private fingerprintValidatedState: GlobalState; private lastProcessReloadState: GlobalState; - biometricUnlockEnabled$: Observable; encryptedClientKeyHalf$: Observable; promptCancelled$: Observable; promptAutomatically$: Observable; @@ -112,7 +114,6 @@ export class DefaultBiometricStateService implements BiometricStateService { constructor(private stateProvider: StateProvider) { this.biometricUnlockEnabledState = this.stateProvider.getActive(BIOMETRIC_UNLOCK_ENABLED); - this.biometricUnlockEnabled$ = this.biometricUnlockEnabledState.state$.pipe(map(Boolean)); this.encryptedClientKeyHalfState = this.stateProvider.getActive(ENCRYPTED_CLIENT_KEY_HALF); this.encryptedClientKeyHalf$ = this.encryptedClientKeyHalfState.state$.pipe( @@ -142,6 +143,15 @@ export class DefaultBiometricStateService implements BiometricStateService { await this.biometricUnlockEnabledState.update(() => enabled); } + biometricUnlockEnabled$(userId?: UserId): Observable { + if (userId != null) { + return this.stateProvider.getUser(userId, BIOMETRIC_UNLOCK_ENABLED).state$.pipe(map(Boolean)); + } + // Backwards compatibility for active user state + // TODO remove with https://bitwarden.atlassian.net/browse/PM-12043 + return this.biometricUnlockEnabledState.state$.pipe(map(Boolean)); + } + async getBiometricUnlockEnabled(userId: UserId): Promise { return await firstValueFrom( this.stateProvider.getUser(userId, BIOMETRIC_UNLOCK_ENABLED).state$.pipe(map(Boolean)), diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 136c24ac6e1..b20edc8ce29 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 { @@ -50,7 +50,6 @@ import { import { UnsignedSharedKey } from "@bitwarden/sdk-internal"; import { KdfConfigService } from "./abstractions/kdf-config.service"; -import { UserPrivateKeyDecryptionFailedError } from "./abstractions/key.service"; import { DefaultKeyService } from "./key.service"; import { KdfConfig } from "./models/kdf-config"; @@ -64,6 +63,7 @@ describe("keyService", () => { const logService = mock(); const stateService = mock(); const kdfConfigService = mock(); + const accountCryptographicStateService = mock(); let stateProvider: FakeStateProvider; const mockUserId = Utils.newGuid() as UserId; @@ -88,6 +88,7 @@ describe("keyService", () => { accountService, stateProvider, kdfConfigService, + accountCryptographicStateService, ); }); @@ -260,7 +261,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); @@ -284,70 +296,6 @@ describe("keyService", () => { }); }); - describe("setUserKeys", () => { - let mockUserKey: UserKey; - let mockEncPrivateKey: EncryptedString; - let everHadUserKeyState: FakeSingleUserState; - - 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 () => { @@ -394,22 +342,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) => { - 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) => { + 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$", () => { @@ -422,9 +367,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); }); @@ -438,14 +383,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 () => { @@ -458,7 +402,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)); @@ -468,6 +412,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); @@ -481,7 +432,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)); @@ -489,52 +440,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; + + 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); @@ -576,11 +491,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) { @@ -1272,17 +1185,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 95a76fd32ae..de4b24876af 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"; @@ -66,7 +65,6 @@ import { KdfConfigService } from "./abstractions/kdf-config.service"; import { CipherDecryptionKeys, KeyService as KeyServiceAbstraction, - UserPrivateKeyDecryptionFailedError, } from "./abstractions/key.service"; import { KdfConfig } from "./models/kdf-config"; @@ -91,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)), @@ -122,30 +121,6 @@ export class DefaultKeyService implements KeyServiceAbstraction { } } - async setUserKeys( - userKey: UserKey, - encPrivateKey: EncryptedString, - userId: UserId, - ): Promise { - 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 { if (userId == null) { throw new Error("UserId is required."); @@ -469,16 +444,6 @@ export class DefaultKeyService implements KeyServiceAbstraction { return [encShareKey, shareKey as T]; } - async setPrivateKey(encPrivateKey: EncryptedString, userId: UserId): Promise { - if (encPrivateKey == null) { - return; - } - - await this.stateProvider - .getUser(userId, USER_ENCRYPTED_PRIVATE_KEY) - .update(() => encPrivateKey); - } - async getFingerprint(fingerprintMaterial: string, publicKey: Uint8Array): Promise { if (publicKey == null) { throw new Error("Public key is required to generate a fingerprint."); @@ -505,18 +470,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 { - await this.stateProvider.setUserState(USER_ENCRYPTED_PRIVATE_KEY, null, userId); - } - - private async clearSigningKey(userId: UserId): Promise { - await this.stateProvider.setUserState(USER_KEY_ENCRYPTED_SIGNING_KEY, null, userId); - } - async makeSendKey(keyMaterial: CsprngArray): Promise { return await this.keyGenerationService.deriveKeyFromMaterial( keyMaterial, @@ -538,9 +491,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 @@ -585,9 +537,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; @@ -645,9 +595,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, @@ -674,9 +629,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. @@ -686,11 +645,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( @@ -791,10 +753,26 @@ export class DefaultKeyService implements KeyServiceAbstraction { } userEncryptedPrivateKey$(userId: UserId): Observable { - 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) => { @@ -802,20 +780,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, + }); + }), ); }), ); @@ -869,23 +849,17 @@ export class DefaultKeyService implements KeyServiceAbstraction { ); } - async setUserSigningKey(userSigningKey: WrappedSigningKey, userId: UserId): Promise { - 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 { - 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; }), ); } @@ -1007,11 +981,18 @@ export class DefaultKeyService implements KeyServiceAbstraction { ); } - async setSignedPublicKey(signedPublicKey: SignedPublicKey, userId: UserId): Promise { - await this.stateProvider.setUserState(USER_SIGNED_PUBLIC_KEY, signedPublicKey, userId); - } - userSignedPublicKey$(userId: UserId): Observable { - 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; + abstract regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise; } 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; let configService: MockProxy; let encryptService: MockProxy; + let accountCryptographicStateService: MockProxy; beforeEach(() => { keyService = mock(); @@ -80,6 +82,7 @@ describe("regenerateIfNeeded", () => { apiService = mock(); configService = mock(); encryptService = mock(); + accountCryptographicStateService = mock(); 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; let configService: MockProxy; + let accountCryptographicStateService: MockProxy; beforeEach(() => { keyService = mock(); @@ -391,6 +396,7 @@ describe("regenerateUserPublicKeyEncryptionKeyPair", () => { sdkService = new MockSdkService(); apiService = mock(); configService = mock(); + accountCryptographicStateService = mock(); 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 { @@ -123,7 +125,7 @@ export class DefaultUserAsymmetricKeysRegenerationService implements UserAsymmet return false; } - async regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise { + async regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise { 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 { diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.html b/libs/pricing/src/components/cart-summary/cart-summary.component.html index e916de3995d..d3a0ad25e6c 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.html +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.html @@ -16,7 +16,7 @@ {{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD

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

- {{ "maxFileSizeSansPunctuation" | i18n }} -

- +

+ {{ "maxFileSizeSansPunctuation" | i18n }} +

+ + } diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts index 2e54d3b539a..002ad019653 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts @@ -51,6 +51,7 @@ describe("CipherAttachmentsComponent", () => { username: "username", password: "password", }, + edit: true, } as CipherView; const cipherDomain = { @@ -197,6 +198,10 @@ describe("CipherAttachmentsComponent", () => { let file: File; beforeEach(() => { + const nonEditableCipherView = { ...cipherView, edit: false }; + cipherServiceDecrypt.mockResolvedValue(nonEditableCipherView); + fixture.detectChanges(); + submitBtnFixture.componentInstance.disabled.set(undefined as unknown as boolean); file = new File([""], "attachment.txt", { type: "text/plain" }); @@ -371,6 +376,32 @@ describe("CipherAttachmentsComponent", () => { expect(emitSpy).toHaveBeenCalled(); }); }); + + describe("close", () => { + async function setup(): Promise { + fixture = TestBed.createComponent(CipherAttachmentsComponent); + component = fixture.componentInstance; + submitBtnFixture = TestBed.createComponent(ButtonComponent); + + // Set organizationId BEFORE cipherId so the effect picks it up + fixture.componentRef.setInput("organizationId", organization.id); + fixture.componentRef.setInput("submitBtn", submitBtnFixture.componentInstance); + fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId); + await waitForInitialization(); + const nonEditableCipherView = { ...cipherView, edit: false }; + cipherServiceDecrypt.mockResolvedValue(nonEditableCipherView); + fixture.detectChanges(); + } + + it('emits "onCloseButtonPress"', async () => { + await setup(); + const emitSpy = jest.spyOn(component.onCloseButtonPress, "emit"); + + await component.submit(); + + expect(emitSpy).toHaveBeenCalled(); + }); + }); }); describe("removeAttachment", () => { diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts index f75611b995e..e0a648e3107 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts @@ -105,6 +105,8 @@ export class CipherAttachmentsComponent { /** Emits after a file has been successfully removed */ readonly onRemoveSuccess = output(); + readonly onCloseButtonPress = output(); + protected readonly organization = signal(null); protected readonly cipher = signal(null); @@ -154,7 +156,7 @@ export class CipherAttachmentsComponent { // Update the initial state of the submit button const btn = this.submitBtn(); if (btn) { - btn.disabled.set(!this.attachmentForm.valid); + btn.disabled.set(!this.attachmentForm.valid && (this.cipher()?.edit ?? true)); } }); @@ -192,6 +194,12 @@ export class CipherAttachmentsComponent { /** Save the attachments to the cipher */ submit = async () => { + //user can't edit cipher and will close the bit-dialog + if (!(this.cipher()?.edit ?? false)) { + this.onCloseButtonPress.emit(); + return; + } + this.onUploadStarted.emit(); const file = this.attachmentForm.value.file; diff --git a/libs/vault/src/cipher-form/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) {} 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..0a46b83b086 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html +++ b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html @@ -9,7 +9,7 @@ {{ attachment.sizeName }} - + diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts b/libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts index a188d673601..03ddb386ad0 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts +++ b/libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts @@ -69,4 +69,12 @@ describe("AttachmentsV2Component", () => { expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Removed }); }); + + it("closes the dialog with 'closed' result on closedButtonPressed", () => { + const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close"); + + component.closeButtonPressed(); + + expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Closed }); + }); }); diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts b/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts index 218f5b2c6d3..9810aa929d6 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts +++ b/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts @@ -3,6 +3,7 @@ import { CommonModule } from "@angular/common"; import { Component, HostListener, Inject } from "@angular/core"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { @@ -18,6 +19,7 @@ import { CipherAttachmentsComponent } from "../../cipher-form/components/attachm export interface AttachmentsDialogParams { cipherId: CipherId; + canEditCipher?: boolean; admin?: boolean; organizationId?: OrganizationId; } @@ -51,7 +53,9 @@ export class AttachmentsV2Component { cipherId: CipherId; admin: boolean = false; organizationId?: OrganizationId; + canEditCipher: boolean; attachmentFormId = CipherAttachmentsComponent.attachmentFormID; + buttonText: string; private isUploading = false; /** @@ -62,10 +66,14 @@ export class AttachmentsV2Component { constructor( private dialogRef: DialogRef, @Inject(DIALOG_DATA) public params: AttachmentsDialogParams, + private i18nService: I18nService, ) { this.cipherId = params.cipherId; this.organizationId = params.organizationId; this.admin = params.admin ?? false; + this.canEditCipher = params?.canEditCipher ?? false; + this.buttonText = + this.canEditCipher || this.admin ? this.i18nService.t("upload") : this.i18nService.t("close"); } /** @@ -140,4 +148,10 @@ export class AttachmentsV2Component { action: AttachmentDialogResult.Removed, }); } + + closeButtonPressed() { + this.dialogRef.close({ + action: AttachmentDialogResult.Closed, + }); + } } diff --git a/libs/vault/src/cipher-view/cipher-view.component.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 @@ - + {{ "changeAtRiskPassword" | i18n }} - diff --git a/libs/vault/src/cipher-view/cipher-view.component.spec.ts b/libs/vault/src/cipher-view/cipher-view.component.spec.ts index 18a5132781b..2300565035e 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.spec.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.spec.ts @@ -8,7 +8,6 @@ import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -42,11 +41,9 @@ describe("CipherViewComponent", () => { let mockLogService: LogService; let mockCipherRiskService: CipherRiskService; let mockBillingAccountProfileStateService: BillingAccountProfileStateService; - let mockConfigService: ConfigService; // Mock data let mockCipherView: CipherView; - let featureFlagEnabled$: BehaviorSubject; let hasPremiumFromAnySource$: BehaviorSubject; let activeAccount$: BehaviorSubject; @@ -57,7 +54,6 @@ describe("CipherViewComponent", () => { email: "test@example.com", } as Account); - featureFlagEnabled$ = new BehaviorSubject(false); hasPremiumFromAnySource$ = new BehaviorSubject(true); // Create service mocks @@ -83,9 +79,6 @@ describe("CipherViewComponent", () => { .fn() .mockReturnValue(hasPremiumFromAnySource$); - mockConfigService = mock(); - mockConfigService.getFeatureFlag$ = jest.fn().mockReturnValue(featureFlagEnabled$); - // Setup mock cipher view mockCipherView = new CipherView(); mockCipherView.id = "cipher-id"; @@ -110,7 +103,6 @@ describe("CipherViewComponent", () => { provide: BillingAccountProfileStateService, useValue: mockBillingAccountProfileStateService, }, - { provide: ConfigService, useValue: mockConfigService }, ], schemas: [NO_ERRORS_SCHEMA], }) @@ -145,7 +137,6 @@ describe("CipherViewComponent", () => { beforeEach(() => { // Reset observables to default values for this test suite - featureFlagEnabled$.next(true); hasPremiumFromAnySource$.next(true); // Setup default mock for computeCipherRiskForUser (individual tests can override) @@ -162,18 +153,6 @@ describe("CipherViewComponent", () => { component = fixture.componentInstance; }); - it("returns false when feature flag is disabled", fakeAsync(() => { - featureFlagEnabled$.next(false); - - const cipher = createLoginCipherView(); - fixture.componentRef.setInput("cipher", cipher); - fixture.detectChanges(); - tick(); - - expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled(); - expect(component.passwordIsAtRisk()).toBe(false); - })); - it("returns false when cipher has no login password", fakeAsync(() => { const cipher = createLoginCipherView(); cipher.login = {} as any; // No password diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index 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( @@ -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/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()" > - + diff --git a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts index bef7f5b12d6..42fe082d5f8 100644 --- a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts +++ b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from "@angular/common"; import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from "@angular/core"; import { CarouselIcon } from "@bitwarden/assets/svg"; -import { IconModule } from "@bitwarden/components"; +import { SvgModule } from "@bitwarden/components"; import { VaultCarouselSlideComponent } from "../carousel-slide/carousel-slide.component"; @@ -12,7 +12,7 @@ import { VaultCarouselSlideComponent } from "../carousel-slide/carousel-slide.co @Component({ selector: "vault-carousel-button", templateUrl: "carousel-button.component.html", - imports: [CommonModule, IconModule], + imports: [CommonModule, SvgModule], }) export class VaultCarouselButtonComponent implements FocusableOption { /** Slide component that is associated with the individual button */ diff --git a/libs/vault/src/components/carousel/carousel.component.spec.ts b/libs/vault/src/components/carousel/carousel.component.spec.ts index abbfe963ddf..eb9480398e9 100644 --- a/libs/vault/src/components/carousel/carousel.component.spec.ts +++ b/libs/vault/src/components/carousel/carousel.component.spec.ts @@ -1,4 +1,4 @@ -import { Component } from "@angular/core"; +import { Component, ChangeDetectionStrategy } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; @@ -7,11 +7,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component"; import { VaultCarouselComponent } from "./carousel.component"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-test-carousel-slide", imports: [VaultCarouselComponent, VaultCarouselSlideComponent], + changeDetection: ChangeDetectionStrategy.OnPush, template: ` @@ -93,8 +92,7 @@ describe("VaultCarouselComponent", () => { const backButton = fixture.debugElement.queryAll(By.css("button"))[0]; middleSlideButton.nativeElement.click(); - await new Promise((r) => setTimeout(r, 100)); // Give time for the DOM to update. - + fixture.detectChanges(); jest.spyOn(component.slideChange, "emit"); backButton.nativeElement.click(); diff --git a/libs/vault/src/components/carousel/carousel.component.ts b/libs/vault/src/components/carousel/carousel.component.ts index 4e180f09f9b..c622f2e5d85 100644 --- a/libs/vault/src/components/carousel/carousel.component.ts +++ b/libs/vault/src/components/carousel/carousel.component.ts @@ -22,7 +22,6 @@ import { take } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ButtonModule, IconButtonModule } from "@bitwarden/components"; -import { I18nPipe } from "@bitwarden/ui-common"; import { VaultCarouselButtonComponent } from "./carousel-button/carousel-button.component"; import { VaultCarouselContentComponent } from "./carousel-content/carousel-content.component"; @@ -41,7 +40,6 @@ import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.com ButtonModule, VaultCarouselContentComponent, VaultCarouselButtonComponent, - I18nPipe, ], }) export class VaultCarouselComponent implements AfterViewInit { diff --git a/libs/vault/src/components/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" > } diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts index 3bbc375fdfc..a46ce28fca8 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts +++ b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts @@ -108,7 +108,7 @@ describe("DownloadAttachmentComponent", () => { it("renders delete button", () => { const deleteButton = fixture.debugElement.query(By.css("button")); - expect(deleteButton.attributes["aria-label"]).toBe("downloadAttachmentName"); + expect(deleteButton.attributes["aria-label"]).toBe("downloadAttachmentLabel"); }); describe("download attachment", () => { diff --git a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html index 00cfa701529..268f5b912d1 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 @@ -8,7 +8,7 @@ id="newItemDropdown" [appA11yTitle]="'new' | i18n" > - + {{ "new" | i18n }} 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 91% 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..1ffc1b119a8 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"; @@ -127,8 +126,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 +137,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 +148,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 +185,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 97% 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..0252ef13094 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, 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 96% 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..1d6d73ba7c5 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, 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(collection, {} as TreeNode); } 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 100% 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 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 5df1bff9a56..ea00f482987 100644 --- a/libs/vault/src/services/archive-cipher-utilities.service.spec.ts +++ b/libs/vault/src/services/archive-cipher-utilities.service.spec.ts @@ -120,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 5d3c5c33236..b747961a701 100644 --- a/libs/vault/src/services/archive-cipher-utilities.service.ts +++ b/libs/vault/src/services/archive-cipher-utilities.service.ts @@ -41,7 +41,8 @@ export class ArchiveCipherUtilitiesService { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "archiveItem" }, - content: { key: "archiveItemConfirmDesc" }, + content: { key: "archiveItemDialogContent" }, + acceptButtonText: { key: "archiveVerb" }, type: "info", }); @@ -73,7 +74,14 @@ export class ArchiveCipherUtilitiesService { * @param cipher The cipher to unarchive * @returns The unarchived cipher on success, or undefined on failure */ - async unarchiveCipher(cipher: CipherView) { + 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)); try { const cipherResponse = await this.cipherArchiveService.unarchiveWithServer( 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..f5da99cae61 100644 --- a/libs/vault/src/services/default-vault-items-transfer.service.spec.ts +++ b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts @@ -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; let mockEventCollectionService: MockProxy; let mockConfigService: MockProxy; + let mockOrganizationUserApiService: MockProxy; + let mockSyncService: MockProxy; const userId = "user-id" as UserId; const organizationId = "org-id" as OrganizationId; @@ -76,6 +80,8 @@ describe("DefaultVaultItemsTransferService", () => { mockToastService = mock(); mockEventCollectionService = mock(); mockConfigService = mock(); + mockOrganizationUserApiService = mock(); + mockSyncService = mock(); 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(); }); 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..3e65d3157f5 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,6 +54,8 @@ 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); @@ -162,7 +165,11 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi ); 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 +177,8 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi undefined, migrationInfo.enforcingOrganization.id, ); + // Sync to reflect organization removal + await this.syncService.fullSync(true); return; } diff --git a/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 93% 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..1bff764964e 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. 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("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(); + 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 94% 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..90af45e571f 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 = [ @@ -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 95% 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..445764827eb 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[] = []; orgs.forEach((org) => { const orgCopy = org as OrganizationFilter; - orgCopy.icon = "bwi-business"; + if ( + org?.productTierType === ProductTierType.Free || + org?.productTierType === ProductTierType.Families + ) { + orgCopy.icon = "bwi-family"; + } else { + orgCopy.icon = "bwi-business"; + } const node = new TreeNode(orgCopy, headNode, orgCopy.name); orgNodes.push(node); }); diff --git a/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 32d5abebb91..b4e6b27911d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,21 +14,21 @@ "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.527", + "@bitwarden/sdk-internal": "0.2.0-main.527", "@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", @@ -38,7 +38,7 @@ "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,7 +52,7 @@ "lunr": "2.3.9", "multer": "2.0.2", "ngx-toastr": "19.1.0", - "node-fetch": "2.6.12", + "node-fetch": "2.7.0", "node-forge": "1.3.2", "oidc-client-ts": "2.4.1", "open": "11.0.0", @@ -74,7 +74,7 @@ "@angular-devkit/build-angular": "20.3.12", "@angular-eslint/schematics": "20.7.0", "@angular/cli": "20.3.12", - "@angular/compiler-cli": "20.3.15", + "@angular/compiler-cli": "20.3.16", "@babel/core": "7.28.5", "@babel/preset-env": "7.28.5", "@compodoc/compodoc": "1.1.32", @@ -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", @@ -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,7 +216,7 @@ "lowdb": "1.0.0", "lunr": "2.3.9", "multer": "2.0.2", - "node-fetch": "2.6.12", + "node-fetch": "2.7.0", "node-forge": "1.3.2", "open": "11.0.0", "papaparse": "5.5.3", @@ -278,7 +277,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2025.12.1", + "version": "2026.2.0", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -491,7 +490,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2025.12.2" + "version": "2026.2.0" }, "libs/admin-console": { "name": "@bitwarden/admin-console", @@ -2203,9 +2202,9 @@ } }, "node_modules/@angular/animations": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.15.tgz", - "integrity": "sha512-ikyKfhkxoqQA6JcBN0B9RaN6369sM1XYX81Id0lI58dmWCe7gYfrTp8ejqxxKftl514psQO3pkW8Gn1nJ131Gw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.16.tgz", + "integrity": "sha512-N83/GFY5lKNyWgPV3xHHy2rb3/eP1ZLzSVI+dmMVbf3jbqwY1YPQcMiAG8UDzaILY1Dkus91kWLF8Qdr3nHAzg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2214,7 +2213,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.15" + "@angular/core": "20.3.16" } }, "node_modules/@angular/build": { @@ -2627,9 +2626,9 @@ } }, "node_modules/@angular/common": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.15.tgz", - "integrity": "sha512-k4mCXWRFiOHK3bUKfWkRQQ8KBPxW8TAJuKLYCsSHPCpMz6u0eA1F0VlrnOkZVKWPI792fOaEAWH2Y4PTaXlUHw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.16.tgz", + "integrity": "sha512-GRAziNlntwdnJy3F+8zCOvDdy7id0gITjDnM6P9+n2lXvtDuBLGJKU3DWBbvxcCjtD6JK/g/rEX5fbCxbUHkQQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2638,14 +2637,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.15", + "@angular/core": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.15.tgz", - "integrity": "sha512-lMicIAFAKZXa+BCZWs3soTjNQPZZXrF/WMVDinm8dQcggNarnDj4UmXgKSyXkkyqK5SLfnLsXVzrX6ndVT6z7A==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.16.tgz", + "integrity": "sha512-Pt9Ms9GwTThgzdxWBwMfN8cH1JEtQ2DK5dc2yxYtPSaD+WKmG9AVL1PrzIYQEbaKcWk2jxASUHpEWSlNiwo8uw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2655,9 +2654,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.15.tgz", - "integrity": "sha512-8sJoxodxsfyZ8eJ5r6Bx7BCbazXYgsZ1+dE8t5u5rTQ6jNggwNtYEzkyReoD5xvP+MMtRkos3xpwq4rtFnpI6A==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.16.tgz", + "integrity": "sha512-l3xF/fXfJAl/UrNnH9Ufkr79myjMgXdHq1mmmph2UnpeqilRB1b8lC9sLBV9MipQHVn3dwocxMIvtrcryfOaXw==", "dev": true, "license": "MIT", "dependencies": { @@ -2678,7 +2677,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.15", + "@angular/compiler": "20.3.16", "typescript": ">=5.8 <6.0" }, "peerDependenciesMeta": { @@ -2864,9 +2863,9 @@ } }, "node_modules/@angular/core": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.15.tgz", - "integrity": "sha512-NMbX71SlTZIY9+rh/SPhRYFJU0pMJYW7z/TBD4lqiO+b0DTOIg1k7Pg9ydJGqSjFO1Z4dQaA6TteNuF99TJCNw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.16.tgz", + "integrity": "sha512-KSFPKvOmWWLCJBbEO+CuRUXfecX2FRuO0jNi9c54ptXMOPHlK1lIojUnyXmMNzjdHgRug8ci9qDuftvC2B7MKg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2875,7 +2874,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.15", + "@angular/compiler": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0" }, @@ -2889,9 +2888,9 @@ } }, "node_modules/@angular/forms": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.15.tgz", - "integrity": "sha512-gS5hQkinq52pm/7mxz4yHPCzEcmRWjtUkOVddPH0V1BW/HMni/p4Y6k2KqKBeGb9p8S5EAp6PDxDVLOPukp3mg==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.16.tgz", + "integrity": "sha512-1yzbXpExTqATpVcqA3wGrq4ACFIP3mRxA4pbso5KoJU+/4JfzNFwLsDaFXKpm5uxwchVnj8KM2vPaDOkvtp7NA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2900,16 +2899,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.15", - "@angular/core": "20.3.15", - "@angular/platform-browser": "20.3.15", + "@angular/common": "20.3.16", + "@angular/core": "20.3.16", + "@angular/platform-browser": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/platform-browser": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.15.tgz", - "integrity": "sha512-TxRM/wTW/oGXv/3/Iohn58yWoiYXOaeEnxSasiGNS1qhbkcKtR70xzxW6NjChBUYAixz2ERkLURkpx3pI8Q6Dw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.16.tgz", + "integrity": "sha512-YsrLS6vyS77i4pVHg4gdSBW74qvzHjpQRTVQ5Lv/OxIjJdYYYkMmjNalCNgy1ZuyY6CaLIB11ccxhrNnxfKGOQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2918,9 +2917,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "20.3.15", - "@angular/common": "20.3.15", - "@angular/core": "20.3.15" + "@angular/animations": "20.3.16", + "@angular/common": "20.3.16", + "@angular/core": "20.3.16" }, "peerDependenciesMeta": { "@angular/animations": { @@ -2929,9 +2928,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.15.tgz", - "integrity": "sha512-RizuRdBt0d6ongQ2y8cr8YsXFyjF8f91vFfpSNw+cFj+oiEmRC1txcWUlH5bPLD9qSDied8qazUi0Tb8VPQDGw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.16.tgz", + "integrity": "sha512-5mECCV9YeKH6ue239GXRTGeDSd/eTbM1j8dDejhm5cGnPBhTxRw4o+GgSrWTYtb6VmIYdwUGBTC+wCBphiaQ2A==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2940,16 +2939,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.15", - "@angular/compiler": "20.3.15", - "@angular/core": "20.3.15", - "@angular/platform-browser": "20.3.15" + "@angular/common": "20.3.16", + "@angular/compiler": "20.3.16", + "@angular/core": "20.3.16", + "@angular/platform-browser": "20.3.16" } }, "node_modules/@angular/router": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.15.tgz", - "integrity": "sha512-6+qgk8swGSoAu7ISSY//GatAyCP36hEvvUgvjbZgkXLLH9yUQxdo77ij05aJ5s0OyB25q/JkqS8VTY0z1yE9NQ==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.16.tgz", + "integrity": "sha512-e1LiQFZaajKqc00cY5FboIrWJZSMnZ64GDp5R0UejritYrqorQQQNOqP1W85BMuY2owibMmxVfX+dJg/Mc8PuQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2958,9 +2957,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.15", - "@angular/core": "20.3.15", - "@angular/platform-browser": "20.3.15", + "@angular/common": "20.3.16", + "@angular/core": "20.3.16", + "@angular/platform-browser": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -4982,9 +4981,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.527", + "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.527.tgz", + "integrity": "sha512-4C4lwOgA2v184G2axUR5Jdb4UMXMhF52a/3c0lAZYbD/8Nid6jziE89nCa9hdfdazuPgWXhVFa3gPrhLZ4uTUQ==", "license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT", "dependencies": { "type-fest": "^4.41.0" @@ -5087,9 +5086,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.527", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.527.tgz", + "integrity": "sha512-dxPh4XjEGFDBASRBEd/JwUdoMAz10W/0QGygYkPwhKKGzJncfDEAgQ/KrT9wc36ycrDrOOspff7xs/vmmzI0+A==", "license": "GPL-3.0", "dependencies": { "type-fest": "^4.41.0" @@ -8748,18 +8747,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": { @@ -15737,16 +15764,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", @@ -15768,9 +15785,9 @@ } }, "node_modules/@types/koa-json": { - "version": "2.0.23", - "resolved": "https://registry.npmjs.org/@types/koa-json/-/koa-json-2.0.23.tgz", - "integrity": "sha512-LJKLFouztosawgU5xrtanK4neLCQKXl+vuVN96YMeVdKTYObLq2Qybggm9V426Jwam8Gi/zOrPw1g+QH0VaEHw==", + "version": "2.0.24", + "resolved": "https://registry.npmjs.org/@types/koa-json/-/koa-json-2.0.24.tgz", + "integrity": "sha512-FF+nQil6YO8vXMuLnOgGHYspSZVVpi+W79m9/s7LBSOQhlX7QY02X3Evk/g1GgWNLbO674AQaziX6OCCKzQ6Aw==", "dev": true, "license": "MIT", "dependencies": { @@ -15833,62 +15850,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": { @@ -18367,13 +18345,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": { @@ -20596,19 +20575,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", @@ -20621,6 +20599,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", @@ -20638,46 +20626,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": { @@ -20916,9 +20898,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": { @@ -21500,25 +21482,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" @@ -22304,16 +22286,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" @@ -22598,14 +22596,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" @@ -27031,16 +27048,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", @@ -29454,9 +29461,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" }, @@ -32049,16 +32056,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", @@ -32443,9 +32440,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, @@ -32806,9 +32803,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" @@ -34719,9 +34716,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 @@ -35536,12 +35533,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": { @@ -35776,85 +35774,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", @@ -36784,9 +36703,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": { @@ -40285,6 +40204,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", @@ -40598,6 +40534,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", @@ -42398,6 +42347,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", @@ -44305,6 +44267,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 7aba2035dce..e09aba142fd 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", @@ -153,21 +152,21 @@ "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.527", + "@bitwarden/sdk-internal": "0.2.0-main.527", "@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", @@ -177,7 +176,7 @@ "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,7 +190,7 @@ "lunr": "2.3.9", "multer": "2.0.2", "ngx-toastr": "19.1.0", - "node-fetch": "2.6.12", + "node-fetch": "2.7.0", "node-forge": "1.3.2", "oidc-client-ts": "2.4.1", "open": "11.0.0", @@ -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"