diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 00000000000..dd3b6445edd --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,108 @@ +# Bitwarden Clients - Claude Code Configuration + +## Project Context Files + +**Read these files before reviewing to ensure that you fully understand the project and contributing guidelines** + +1. @README.md +2. @CONTRIBUTING.md +3. @.github/PULL_REQUEST_TEMPLATE.md + +## Critical Rules + +- **NEVER** use code regions: If complexity suggests regions, refactor for better readability + +- **CRITICAL**: new encryption logic should not be added to this repo. + +- **NEVER** send unencrypted vault data to API services + +- **NEVER** commit secrets, credentials, or sensitive information. + +- **NEVER** log decrypted data, encryption keys, or PII + - No vault data in error messages or console logs + +- **ALWAYS** Respect configuration files at the root and within each app/library (e.g., `eslint.config.mjs`, `jest.config.js`, `tsconfig.json`). + +## Mono-Repo Architecture + +This repository is organized as a **monorepo** containing multiple applications and libraries. The +main directories are: + +- `apps/` – Contains all application projects (e.g., browser, cli, desktop, web). Each app is + self-contained with its own configuration, source code, and tests. +- `libs/` – Contains shared libraries and modules used across multiple apps. Libraries are organized + by team name, domain, functionality (e.g., common, ui, platform, key-management). + +**Strict boundaries** must be maintained between apps and libraries. Do not introduce +cross-dependencies that violate the intended modular structure. Always consult and respect the +dependency rules defined in `eslint.config.mjs`, `nx.json`, and other configuration files. + +## Angular Architecture Patterns + +**Observable Data Services (ADR-0003):** + +- Services expose RxJS Observable streams for state management +- Components subscribe using `async` pipe (NOT explicit subscriptions in most cases) + Pattern: + +```typescript +// Service +private _folders = new BehaviorSubject([]); +readonly folders$ = this._folders.asObservable(); + +// Component +folders$ = this.folderService.folders$; +// Template:
+``` + +For explicit subscriptions, MUST use `takeUntilDestroyed()`: + +```typescript +constructor() { + this.observable$.pipe(takeUntilDestroyed()).subscribe(...); +} +``` + +**Angular Signals (ADR-0027):** + +Encourage the use of Signals **only** in Angular components and presentational services. + +Use **RxJS** for: + +- Services used across Angular and non-Angular clients +- Complex reactive workflows +- Interop with existing Observable-based code + +**NO TypeScript Enums (ADR-0025):** + +- Use const objects with type aliases instead +- Legacy enums exist but don't add new ones + +Pattern: + +```typescript +// ✅ DO +export const CipherType = Object.freeze({ + Login: 1, + SecureNote: 2, +} as const); +export type CipherType = (typeof CipherType)[keyof typeof CipherType]; + +// ❌ DON'T +enum CipherType { + Login = 1, + SecureNote = 2, +} +``` + +Example: `/libs/common/src/vault/enums/cipher-type.ts` + +## References + +- [Web Clients Architecture](https://contributing.bitwarden.com/architecture/clients) +- [Architectural Decision Records (ADRs)](https://contributing.bitwarden.com/architecture/adr/) +- [Contributing Guide](https://contributing.bitwarden.com/) +- [Web Clients Setup Guide](https://contributing.bitwarden.com/getting-started/clients/) +- [Code Style](https://contributing.bitwarden.com/contributing/code-style/) +- [Security Whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper/) +- [Security Definitions](https://contributing.bitwarden.com/architecture/security/definitions) diff --git a/.claude/prompts/review-code.md b/.claude/prompts/review-code.md new file mode 100644 index 00000000000..1888b7cd503 --- /dev/null +++ b/.claude/prompts/review-code.md @@ -0,0 +1,57 @@ +# Bitwarden Clients Repo Code Review - Careful Consideration Required + +## Think Twice Before Recommending + +Angular has multiple valid patterns. Before suggesting changes: + +- **Consider the context** - Is this code part of an active modernization effort? +- **Check for established patterns** - Look for similar implementations in the codebase +- **Avoid premature optimization** - Don't suggest refactoring stable, working code without clear benefit +- **Respect incremental progress** - Teams may be modernizing gradually with feature flags + +## Angular Modernization - Handle with Care + +**Control Flow Syntax (@if, @for, @switch):** + +- When you see legacy structural directives (*ngIf, *ngFor), consider whether modernization is in scope +- Do not mandate changes to stable code unless part of the PR's objective +- If suggesting modernization, acknowledge it's optional unless required by PR goals + +**Standalone Components:** + +- New components should be standalone whenever feasible, but do not flag existing NgModule components as issues +- Legacy patterns exist for valid reasons - consider modernization effort vs benefit + +**Typed Forms:** + +- Recommend typed forms for NEW form code +- Don't suggest rewriting working untyped forms unless they're being modified + +## Tailwind CSS - Critical Pattern + +**tw- prefix is mandatory** - This is non-negotiable and should be flagged as ❌ major finding: + +- Missing tw- prefix breaks styling completely +- Check ALL Tailwind classes in modified files + +## Rust SDK Adoption - Tread Carefully + +When reviewing cipher operations: + +- Look for breaking changes in the TypeScript → Rust boundary +- Verify error handling matches established patterns +- Don't suggest alternative SDK patterns without strong justification + +## Component Library First + +Before suggesting custom implementations: + +- Check if Bitwarden's component library already provides the functionality +- Prefer existing components over custom Tailwind styling +- Don't add UI complexity that the component library already solves + +## When in Doubt + +- **Ask questions** (💭) rather than making definitive recommendations +- **Flag for human review** (⚠️) if you're uncertain +- **Acknowledge alternatives** exist when suggesting improvements diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 892fe0f5c0d..b58d1511dca 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,6 +8,7 @@ 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/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 @@ -28,8 +29,9 @@ libs/common/src/auth @bitwarden/team-auth-dev ## Tools team files ## apps/browser/src/tools @bitwarden/team-tools-dev apps/cli/src/tools @bitwarden/team-tools-dev +apps/desktop/desktop_native/bitwarden_chromium_import_helper @bitwarden/team-tools-dev +apps/desktop/desktop_native/chromium_importer @bitwarden/team-tools-dev apps/desktop/src/app/tools @bitwarden/team-tools-dev -apps/desktop/desktop_native/bitwarden_chromium_importer @bitwarden/team-tools-dev apps/web/src/app/tools @bitwarden/team-tools-dev libs/angular/src/tools @bitwarden/team-tools-dev libs/common/src/models/export @bitwarden/team-tools-dev @@ -45,12 +47,12 @@ bitwarden_license/bit-web/src/app/dirt @bitwarden/team-data-insights-and-reporti libs/dirt @bitwarden/team-data-insights-and-reporting-dev libs/common/src/dirt @bitwarden/team-data-insights-and-reporting-dev -## Localization/Crowdin (Platform and Tools team) -apps/browser/src/_locales @bitwarden/team-tools-dev @bitwarden/team-platform-dev -apps/browser/store/locales @bitwarden/team-tools-dev @bitwarden/team-platform-dev -apps/cli/src/locales @bitwarden/team-tools-dev @bitwarden/team-platform-dev -apps/desktop/src/locales @bitwarden/team-tools-dev @bitwarden/team-platform-dev -apps/web/src/locales @bitwarden/team-tools-dev @bitwarden/team-platform-dev +## Localization/Crowdin (Platform team) +apps/browser/src/_locales @bitwarden/team-platform-dev +apps/browser/store/locales @bitwarden/team-platform-dev +apps/cli/src/locales @bitwarden/team-platform-dev +apps/desktop/src/locales @bitwarden/team-platform-dev +apps/web/src/locales @bitwarden/team-platform-dev ## Vault team files ## apps/browser/src/vault @bitwarden/team-vault-dev @@ -162,6 +164,7 @@ apps/desktop/desktop_native/core/src/ssh_agent @bitwarden/team-autofill-desktop- libs/components @bitwarden/team-ui-foundation libs/assets @bitwarden/team-ui-foundation libs/ui @bitwarden/team-ui-foundation +libs/angular/src/scss @bitwarden/team-ui-foundation apps/browser/src/platform/popup/layout @bitwarden/team-ui-foundation apps/browser/src/popup/app-routing.animations.ts @bitwarden/team-ui-foundation apps/browser/src/popup/components/extension-anon-layout-wrapper @bitwarden/team-ui-foundation @@ -172,6 +175,7 @@ apps/desktop/src/key-management @bitwarden/team-key-management-dev apps/web/src/app/key-management @bitwarden/team-key-management-dev apps/browser/src/key-management @bitwarden/team-key-management-dev apps/cli/src/key-management @bitwarden/team-key-management-dev +bitwarden_license/bit-web/src/app/key-management @bitwarden/team-key-management-dev libs/key-management @bitwarden/team-key-management-dev libs/key-management-ui @bitwarden/team-key-management-dev libs/common/src/key-management @bitwarden/team-key-management-dev @@ -179,10 +183,15 @@ libs/common/src/key-management @bitwarden/team-key-management-dev libs/node @bitwarden/team-key-management-dev apps/desktop/desktop_native/core/src/biometric/ @bitwarden/team-key-management-dev +apps/desktop/desktop_native/core/src/biometric_v2/ @bitwarden/team-key-management-dev +apps/desktop/desktop_native/core/src/secure_memory/ @bitwarden/team-key-management-dev apps/desktop/src/services/native-messaging.service.ts @bitwarden/team-key-management-dev apps/browser/src/background/nativeMessaging.background.ts @bitwarden/team-key-management-dev apps/desktop/src/services/biometric-message-handler.service.ts @bitwarden/team-key-management-dev +## Architecture +libs/playwright-helpers @bitwarden/team-architecture + ## Locales ## apps/browser/src/_locales/en/messages.json apps/browser/store/locales/en @@ -219,4 +228,8 @@ apps/web/src/locales/en/messages.json **/jest.config.js @bitwarden/team-platform-dev **/project.jsons @bitwarden/team-platform-dev libs/pricing @bitwarden/team-billing-dev -libs/playwright-helpers @bitwarden/team-architecture + +# Claude related files +.claude/ @bitwarden/team-ai-sme +.github/workflows/respond.yml @bitwarden/team-ai-sme +.github/workflows/review-code.yml @bitwarden/team-ai-sme diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 8e31ab7a384..6e142edf8a7 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -139,6 +139,7 @@ "@babel/core", "@babel/preset-env", "@bitwarden/sdk-internal", + "@bitwarden/commercial-sdk-internal", "@electron/fuses", "@electron/notarize", "@electron/rebuild", @@ -147,6 +148,7 @@ "@nx/eslint", "@nx/jest", "@nx/js", + "@nx/webpack", "@types/chrome", "@types/firefox-webext-browser", "@types/glob", @@ -185,7 +187,6 @@ "json5", "keytar", "libc", - "log", "lowdb", "mini-css-extract-plugin", "napi", @@ -214,6 +215,8 @@ "simplelog", "style-loader", "sysinfo", + "tracing", + "tracing-subscriber", "ts-node", "ts-loader", "tsconfig-paths-webpack-plugin", @@ -228,6 +231,7 @@ "webpack-node-externals", "widestring", "windows", + "windows-core", "windows-future", "windows-registry", "zbus", @@ -252,6 +256,11 @@ groupName: "zbus", matchPackageNames: ["zbus", "zbus_polkit"], }, + { + // We need to group all windows-related packages together to avoid build errors caused by version incompatibilities. + groupName: "windows", + matchPackageNames: ["windows", "windows-core", "windows-future", "windows-registry"], + }, { // We group all webpack build-related minor and patch updates together to reduce PR noise. // We include patch updates here because we want PRs for webpack patch updates and it's in this group. @@ -323,6 +332,7 @@ "storybook", "tailwindcss", "zone.js", + "@tailwindcss/container-queries", ], description: "UI Foundation owned dependencies", commitMessagePrefix: "[deps] UI Foundation:", @@ -353,6 +363,8 @@ "@types/jsdom", "@types/papaparse", "@types/zxcvbn", + "async-trait", + "clap", "jsdom", "jszip", "oidc-client-ts", @@ -398,7 +410,16 @@ reviewers: ["team:team-vault-dev"], }, { - matchPackageNames: ["aes", "big-integer", "cbc", "rsa", "russh-cryptovec", "sha2"], + matchPackageNames: [ + "aes", + "big-integer", + "cbc", + "rsa", + "russh-cryptovec", + "sha2", + "memsec", + "linux-keyutils", + ], description: "Key Management owned dependencies", commitMessagePrefix: "[deps] KM:", reviewers: ["team:team-key-management-dev"], @@ -416,5 +437,11 @@ description: "Higher versions of lowdb do not need separate types", }, ], - ignoreDeps: ["@types/koa-bodyparser", "bootstrap", "node-ipc", "@bitwarden/sdk-internal"], + ignoreDeps: [ + "@types/koa-bodyparser", + "bootstrap", + "node-ipc", + "@bitwarden/sdk-internal", + "@bitwarden/commercial-sdk-internal", + ], } diff --git a/.github/workflows/alert-ddg-files-modified.yml b/.github/workflows/alert-ddg-files-modified.yml index c341de045eb..90c055a97b8 100644 --- a/.github/workflows/alert-ddg-files-modified.yml +++ b/.github/workflows/alert-ddg-files-modified.yml @@ -14,9 +14,10 @@ jobs: pull-requests: write steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 + persist-credentials: false - name: Get changed files id: changed-files @@ -30,7 +31,7 @@ jobs: - 'apps/desktop/src/services/encrypted-message-handler.service.ts' - name: Remove past BIT status comments - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | // Note: should match the first line of `message` in the communication steps @@ -67,10 +68,12 @@ jobs: - name: Comment on PR if monitored files changed if: steps.changed-files.outputs.monitored == 'true' - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + _MONITORED_FILES: ${{ steps.changed-files.outputs.monitored_files }} with: script: | - const changedFiles = `${{ steps.changed-files.outputs.monitored_files }}`.split(' ').filter(file => file.trim() !== ''); + const changedFiles = `$_MONITORED_FILES`.split(' ').filter(file => file.trim() !== ''); const message = ` ⚠️🦆 **DuckDuckGo Integration files have been modified in this PR:** diff --git a/.github/workflows/auto-branch-updater.yml b/.github/workflows/auto-branch-updater.yml index 3f67388fd0c..02176b3169e 100644 --- a/.github/workflows/auto-branch-updater.yml +++ b/.github/workflows/auto-branch-updater.yml @@ -27,17 +27,20 @@ jobs: steps: - name: Setup id: setup - run: echo "branch=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT + run: echo "branch=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: 'eu-web-${{ steps.setup.outputs.branch }}' fetch-depth: 0 + persist-credentials: true - name: Merge ${{ steps.setup.outputs.branch }} + env: + _BRANCH: ${{ steps.setup.outputs.branch }} run: | - git config --local user.email "${{ env._BOT_EMAIL }}" - git config --local user.name "${{ env._BOT_NAME }}" - git merge origin/${{ steps.setup.outputs.branch }} + git config --local user.email "$_BOT_EMAIL" + git config --local user.name "$_BOT_NAME" + git merge "origin/$_BRANCH" git push diff --git a/.github/workflows/auto-reply-discussions.yml b/.github/workflows/auto-reply-discussions.yml index 83970ab3619..a6d7e9c6dcf 100644 --- a/.github/workflows/auto-reply-discussions.yml +++ b/.github/workflows/auto-reply-discussions.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Get discussion label and template name id: discussion-label - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const discussion = context.payload.discussion; @@ -29,7 +29,7 @@ jobs: - name: Get selected topic id: get_selected_topic - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: result-encoding: string script: | @@ -45,7 +45,7 @@ jobs: } - name: Reply or close Discussion - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: TEMPLATE_NAME: ${{ steps.discussion-label.outputs.template_name }} TOPIC: ${{ steps.get_selected_topic.outputs.result }} diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 823cb7e25e0..ab932c561ba 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -55,18 +55,19 @@ jobs: has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Get Package Version id: gen_vars run: | - repo_url=https://github.com/$GITHUB_REPOSITORY.git + repo_url="https://github.com/$GITHUB_REPOSITORY.git" adj_build_num=${GITHUB_SHA:0:7} - echo "repo_url=$repo_url" >> $GITHUB_OUTPUT - echo "adj_build_number=$adj_build_num" >> $GITHUB_OUTPUT + echo "repo_url=$repo_url" >> "$GITHUB_OUTPUT" + echo "adj_build_number=$adj_build_num" >> "$GITHUB_OUTPUT" - name: Get Node Version id: retrieve-node-version @@ -74,13 +75,13 @@ jobs: run: | NODE_NVMRC=$(cat .nvmrc) NODE_VERSION=${NODE_NVMRC/v/''} - echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT + echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Check secrets id: check-secrets run: | has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }} - echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT + echo "has_secrets=$has_secrets" >> "$GITHUB_OUTPUT" locales-test: @@ -93,9 +94,10 @@ jobs: working-directory: apps/browser steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Testing locales - extName length run: | @@ -105,12 +107,14 @@ jobs: echo "============" echo "extName string must be 40 characters or less" echo - for locale in $(ls src/_locales/); do - string_length=$(jq '.extName.message | length' src/_locales/$locale/messages.json) - if [[ $string_length -gt 40 ]]; then - echo "$locale: $string_length" - found_error=true - fi + + for locale_path in src/_locales/*/messages.json; do + locale=$(basename "$(dirname "$locale_path")") + string_length=$(jq '.extName.message | length' "$locale_path") + if [ "$string_length" -gt 40 ]; then + echo "$locale: $string_length" + found_error=true + fi done if $found_error; then @@ -142,12 +146,13 @@ jobs: _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -188,7 +193,7 @@ jobs: zip -r browser-source.zip browser-source - name: Upload browser source - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: ${{matrix.license_type.archive_name_prefix}}browser-source-${{ env._BUILD_NUMBER }}.zip path: browser-source.zip @@ -213,18 +218,24 @@ jobs: source_archive_name_prefix: "" archive_name_prefix: "" npm_command_prefix: "dist:" + npm_package_dev_prefix: "package:dev:" readable: "open source license" + type: "oss" - build_prefix: "bit-" artifact_prefix: "bit-" source_archive_name_prefix: "bit-" archive_name_prefix: "bit-" npm_command_prefix: "dist:bit:" + npm_package_dev_prefix: "package:bit:dev:" readable: "commercial license" + type: "commercial" browser: - name: "chrome" npm_command_suffix: "chrome" archive_name: "dist-chrome.zip" artifact_name: "dist-chrome-MV3" + artifact_name_dev: "dev-chrome-MV3" + archive_name_dev: "dev-chrome.zip" - name: "edge" npm_command_suffix: "edge" archive_name: "dist-edge.zip" @@ -243,12 +254,13 @@ jobs: artifact_name: "dist-opera-MV3" steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -260,7 +272,7 @@ jobs: npm --version - name: Download browser source - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: ${{matrix.license_type.source_archive_name_prefix}}browser-source-${{ env._BUILD_NUMBER }}.zip @@ -273,6 +285,11 @@ jobs: run: npm ci working-directory: browser-source/ + - name: Remove commercial packages + if: ${{ matrix.license_type.type == 'oss' }} + run: rm -rf node_modules/@bitwarden/commercial-sdk-internal + working-directory: browser-source/ + - name: Download SDK artifacts if: ${{ inputs.sdk_branch != '' }} uses: bitwarden/gh-actions/download-artifacts@main @@ -301,13 +318,13 @@ jobs: TARGET_DIR='./browser-source/apps/browser' while IFS=' ' read -r RESULT; do FILES+=("$RESULT") - done < <(find $TARGET_DIR -size +5M) + done < <(find "$TARGET_DIR" -size +5M) # Validate results and provide messaging if [[ ${#FILES[@]} -ne 0 ]]; then echo "File(s) exceeds size limit: 5MB" - for FILE in ${FILES[@]}; do - echo "- $(du --si $FILE)" + for FILE in "${FILES[@]}"; do + echo "- $(du --si "$FILE")" done echo "ERROR Firefox rejects extension uploads that contain files larger than 5MB" # Invoke failure @@ -319,16 +336,29 @@ jobs: working-directory: browser-source/apps/browser - name: Upload extension artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: ${{ matrix.license_type.artifact_prefix }}${{ matrix.browser.artifact_name }}-${{ env._BUILD_NUMBER }}.zip path: browser-source/apps/browser/dist/${{matrix.license_type.archive_name_prefix}}${{ matrix.browser.archive_name }} if-no-files-found: error + - name: Package dev extension + if: ${{ matrix.browser.archive_name_dev != '' }} + run: npm run ${{ matrix.license_type.npm_package_dev_prefix }}${{ matrix.browser.npm_command_suffix }} + working-directory: browser-source/apps/browser + + - name: Upload dev extension artifact + if: ${{ matrix.browser.archive_name_dev != '' }} + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: ${{ matrix.license_type.artifact_prefix }}${{ matrix.browser.artifact_name_dev }}-${{ env._BUILD_NUMBER }}.zip + path: browser-source/apps/browser/dist/${{matrix.license_type.archive_name_prefix}}${{ matrix.browser.archive_name_dev }} + if-no-files-found: error + build-safari: name: Build Safari - ${{ matrix.license_type.readable }} - runs-on: macos-13 + runs-on: macos-15 permissions: contents: read id-token: write @@ -344,22 +374,25 @@ jobs: archive_name_prefix: "" npm_command_prefix: "dist:" readable: "open source license" + type: "oss" - build_prefix: "bit-" artifact_prefix: "bit-" archive_name_prefix: "bit-" npm_command_prefix: "dist:bit:" readable: "commercial license" + type: "commercial" env: _BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -389,34 +422,34 @@ jobs: ACCOUNT_NAME: bitwardenci CONTAINER_NAME: profiles run: | - mkdir -p $HOME/secrets + mkdir -p "$HOME/secrets" - az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ + az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \ --name bitwarden_desktop_appstore.provisionprofile \ - --file $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ + --file "$HOME/secrets/bitwarden_desktop_appstore.provisionprofile" \ --output none - name: Get certificates run: | - mkdir -p $HOME/certificates + mkdir -p "$HOME/certificates" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/bitwarden-desktop-key | - jq -r .value | base64 -d > $HOME/certificates/bitwarden-desktop-key.p12 + jq -r .value | base64 -d > "$HOME/certificates/bitwarden-desktop-key.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-app-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-app-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/appstore-app-cert.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-installer-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/appstore-installer-cert.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-app-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-app-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/devid-app-cert.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-installer-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/devid-installer-cert.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert | - jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/macdev-cert.p12" - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main @@ -425,9 +458,9 @@ jobs: env: KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} run: | - security create-keychain -p $KEYCHAIN_PASSWORD build.keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security default-keychain -s build.keychain - security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security set-keychain-settings -lut 1200 build.keychain security import "$HOME/certificates/bitwarden-desktop-key.p12" -k build.keychain -P "" \ @@ -448,12 +481,17 @@ jobs: security import "$HOME/certificates/macdev-cert.p12" -k build.keychain -P "" \ -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEYCHAIN_PASSWORD build.keychain + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain - name: NPM setup run: npm ci working-directory: ./ + - name: Remove commercial packages + if: ${{ matrix.license_type.type == 'oss' }} + run: rm -rf node_modules/@bitwarden/commercial-sdk-internal + working-directory: ./ + - name: Download SDK Artifacts if: ${{ inputs.sdk_branch != '' }} uses: bitwarden/gh-actions/download-artifacts@main @@ -485,7 +523,7 @@ jobs: ls -la - name: Upload Safari artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: ${{matrix.license_type.archive_name_prefix}}dist-safari-${{ env._BUILD_NUMBER }}.zip path: apps/browser/dist/${{matrix.license_type.archive_name_prefix}}dist-safari.zip @@ -504,9 +542,10 @@ jobs: - build-safari steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Log in to Azure uses: bitwarden/gh-actions/azure-login@main @@ -526,7 +565,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Upload Sources - uses: crowdin/github-action@f214c8723025f41fc55b2ad26e67b60b80b1885d # v2.7.1 + uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.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 22ba3a3e7be..964cbc834c5 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -59,29 +59,30 @@ jobs: has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Get Package Version id: retrieve-package-version run: | PKG_VERSION=$(jq -r .version package.json) - echo "package_version=$PKG_VERSION" >> $GITHUB_OUTPUT + echo "package_version=$PKG_VERSION" >> "$GITHUB_OUTPUT" - name: Get Node Version id: retrieve-node-version working-directory: ./ run: | NODE_NVMRC=$(cat .nvmrc) - NODE_VERSION=${NODE_NVMRC/v/''} - echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT + NODE_VERSION="${NODE_NVMRC/v/''}" + echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Check secrets id: check-secrets run: | has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }} - echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT + echo "has_secrets=$has_secrets" >> "$GITHUB_OUTPUT" cli: @@ -92,13 +93,13 @@ jobs: [ { base: "linux", distro: "ubuntu-22.04", target_suffix: "" }, { base: "linux", distro: "ubuntu-22.04-arm", target_suffix: "-arm64" }, - { base: "mac", distro: "macos-13", target_suffix: "" }, - { base: "mac", distro: "macos-14", target_suffix: "-arm64" } + { base: "mac", distro: "macos-15-intel", target_suffix: "" }, + { base: "mac", distro: "macos-15", target_suffix: "-arm64" } ] license_type: [ - { build_prefix: "oss", artifact_prefix: "-oss", readable: "open source license" }, - { build_prefix: "bit", artifact_prefix: "", readable: "commercial license" } + { type: "oss", build_prefix: "oss", artifact_prefix: "-oss", readable: "open source license" }, + { type: "commercial", build_prefix: "bit", artifact_prefix: "", readable: "commercial license" } ] runs-on: ${{ matrix.os.distro }} needs: setup @@ -113,18 +114,23 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Setup Unix Vars run: | - echo "LOWER_RUNNER_OS=$(echo $RUNNER_OS | awk '{print tolower($0)}')" >> $GITHUB_ENV - echo "SHORT_RUNNER_OS=$(echo $RUNNER_OS | awk '{print substr($0, 1, 3)}' | \ - awk '{print tolower($0)}')" >> $GITHUB_ENV + LOWER_RUNNER_OS="$(printf '%s' "$RUNNER_OS" | awk '{print tolower($0)}')" + SHORT_RUNNER_OS="$(printf '%s' "$RUNNER_OS" | awk '{print substr($0, 1, 3)}' | awk '{print tolower($0)}')" + + { + echo "LOWER_RUNNER_OS=$LOWER_RUNNER_OS" + echo "SHORT_RUNNER_OS=$SHORT_RUNNER_OS" + } >> "$GITHUB_ENV" - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -134,6 +140,11 @@ jobs: run: npm ci working-directory: ./ + - name: Remove commercial packages + if: ${{ matrix.license_type.type == 'oss' }} + run: rm -rf node_modules/@bitwarden/commercial-sdk-internal + working-directory: ./ + - name: Download SDK Artifacts if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }} uses: bitwarden/gh-actions/download-artifacts@main @@ -155,7 +166,9 @@ jobs: npm link ../sdk-internal - name: Build & Package Unix - run: npm run dist:${{ matrix.license_type.build_prefix }}:${{ env.SHORT_RUNNER_OS }}${{ matrix.os.target_suffix }} --quiet + env: + _SHORT_RUNNER_OS: ${{ env.SHORT_RUNNER_OS }} + run: npm run "dist:${{ matrix.license_type.build_prefix }}:$_SHORT_RUNNER_OS${{ matrix.os.target_suffix }}" --quiet - name: Login to Azure if: ${{ matrix.os.base == 'mac' && needs.setup.outputs.has_secrets == 'true' }} @@ -168,10 +181,10 @@ jobs: - name: Get certificates if: ${{ matrix.os.base == 'mac' && needs.setup.outputs.has_secrets == 'true' }} run: | - mkdir -p $HOME/certificates + mkdir -p "$HOME/certificates" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-app-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-app-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/devid-app-cert.p12" - name: Get Azure Key Vault secrets id: get-kv-secrets @@ -189,33 +202,39 @@ jobs: env: KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} run: | - security create-keychain -p $KEYCHAIN_PASSWORD build.keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security default-keychain -s build.keychain - security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security set-keychain-settings -lut 1200 build.keychain security import "$HOME/certificates/devid-app-cert.p12" -k build.keychain -P "" \ -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEYCHAIN_PASSWORD build.keychain + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain - name: Sign binary if: ${{ matrix.os.base == 'mac' && needs.setup.outputs.has_secrets == 'true' }} env: MACOS_CERTIFICATE_NAME: "Developer ID Application: 8bit Solutions LLC" - run: codesign --sign "$MACOS_CERTIFICATE_NAME" --verbose=3 --force --options=runtime --entitlements ./entitlements.plist --timestamp ./dist/${{ matrix.license_type.build_prefix }}/${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}/bw + _LOWER_RUNNER_OS: ${{ env.LOWER_RUNNER_OS }} + run: codesign --sign "$MACOS_CERTIFICATE_NAME" --verbose=3 --force --options=runtime --entitlements ./entitlements.plist --timestamp "./dist/${{ matrix.license_type.build_prefix }}/$_LOWER_RUNNER_OS${{ matrix.os.target_suffix }}/bw" - name: Zip Unix + env: + _LOWER_RUNNER_OS: ${{ env.LOWER_RUNNER_OS }} + _PACKAGE_VERSION: ${{ env._PACKAGE_VERSION }} run: | - cd ./dist/${{ matrix.license_type.build_prefix }}/${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }} - zip ../../bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}-${{ env._PACKAGE_VERSION }}.zip ./bw + cd "./dist/${{ matrix.license_type.build_prefix }}/$_LOWER_RUNNER_OS${{ matrix.os.target_suffix }}" + zip "../../bw${{ matrix.license_type.artifact_prefix }}-$_LOWER_RUNNER_OS${{ matrix.os.target_suffix }}-$_PACKAGE_VERSION.zip" ./bw - name: Set up private auth key if: ${{ matrix.os.base == 'mac' && needs.setup.outputs.has_secrets == 'true' }} + env: + _APP_STORE_CONNECT_AUTH_KEY: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }} run: | mkdir ~/private_keys cat << EOF > ~/private_keys/AuthKey_6TV9MKN3GP.p8 - ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }} + $_APP_STORE_CONNECT_AUTH_KEY EOF - name: Notarize app @@ -224,28 +243,32 @@ jobs: APP_STORE_CONNECT_TEAM_ISSUER: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }} APP_STORE_CONNECT_AUTH_KEY: 6TV9MKN3GP APP_STORE_CONNECT_AUTH_KEY_PATH: ~/private_keys/AuthKey_6TV9MKN3GP.p8 + _LOWER_RUNNER_OS: ${{ env.LOWER_RUNNER_OS }} run: | echo "Create keychain profile" xcrun notarytool store-credentials "notarytool-profile" --key-id "$APP_STORE_CONNECT_AUTH_KEY" --key "$APP_STORE_CONNECT_AUTH_KEY_PATH" --issuer "$APP_STORE_CONNECT_TEAM_ISSUER" - codesign --sign "Developer ID Application: 8bit Solutions LLC" --verbose=3 --force --options=runtime --timestamp ./dist/bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}-${{ env._PACKAGE_VERSION }}.zip + codesign --sign "Developer ID Application: 8bit Solutions LLC" --verbose=3 --force --options=runtime --timestamp "./dist/bw${{ matrix.license_type.artifact_prefix }}-$_LOWER_RUNNER_OS${{ matrix.os.target_suffix }}-$_PACKAGE_VERSION.zip" echo "Notarize app" - xcrun notarytool submit ./dist/bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}-${{ env._PACKAGE_VERSION }}.zip --keychain-profile "notarytool-profile" --wait + xcrun notarytool submit "./dist/bw${{ matrix.license_type.artifact_prefix }}-$_LOWER_RUNNER_OS${{ matrix.os.target_suffix }}-$_PACKAGE_VERSION.zip" --keychain-profile "notarytool-profile" --wait - name: Version Test + env: + _PACKAGE_VERSION: ${{ env._PACKAGE_VERSION }} + _LOWER_RUNNER_OS: ${{ env.LOWER_RUNNER_OS }} run: | - unzip "./dist/bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}-${{ env._PACKAGE_VERSION }}.zip" -d "./test" + unzip "./dist/bw${{ matrix.license_type.artifact_prefix }}-$_LOWER_RUNNER_OS${{ matrix.os.target_suffix }}-$_PACKAGE_VERSION.zip" -d "./test" testVersion=$(./test/bw -v) echo "version: $_PACKAGE_VERSION" echo "testVersion: $testVersion" - if [[ $testVersion != $_PACKAGE_VERSION ]]; then + if [[ $testVersion != "$_PACKAGE_VERSION" ]]; then echo "Version test failed." exit 1 fi - name: Upload unix zip asset - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}-${{ env._PACKAGE_VERSION }}.zip @@ -273,8 +296,8 @@ jobs: matrix: license_type: [ - { build_prefix: "oss", artifact_prefix: "-oss", readable: "open source license" }, - { build_prefix: "bit", artifact_prefix: "", readable: "commercial license" } + { type: "oss", build_prefix: "oss", artifact_prefix: "-oss", readable: "open source license" }, + { type: "commercial", build_prefix: "bit", artifact_prefix: "", readable: "commercial license" } ] runs-on: windows-2022 permissions: @@ -288,9 +311,10 @@ jobs: _WIN_PKG_VERSION: 3.5 steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Install AST run: dotnet tool install --global AzureSignTool --version 4.0.1 @@ -302,7 +326,7 @@ jobs: choco install nasm --no-progress - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -391,6 +415,11 @@ jobs: run: npm ci working-directory: ./ + - name: Remove commercial packages + if: ${{ matrix.license_type.type == 'oss' }} + run: Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "node_modules/@bitwarden/commercial-sdk-internal" + working-directory: ./ + - name: Download SDK Artifacts if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }} uses: bitwarden/gh-actions/download-artifacts@main @@ -429,11 +458,13 @@ jobs: - name: Package Chocolatey shell: pwsh if: ${{ matrix.license_type.build_prefix == 'bit' }} + env: + _PACKAGE_VERSION: ${{ env._PACKAGE_VERSION }} run: | Copy-Item -Path stores/chocolatey -Destination dist/chocolatey -Recurse Copy-Item dist/${{ matrix.license_type.build_prefix }}/windows/bw.exe -Destination dist/chocolatey/tools Copy-Item ${{ github.workspace }}/LICENSE.txt -Destination dist/chocolatey/tools - choco pack dist/chocolatey/bitwarden-cli.nuspec --version ${{ env._PACKAGE_VERSION }} --out dist/chocolatey + choco pack dist/chocolatey/bitwarden-cli.nuspec --version "$env:_PACKAGE_VERSION" --out dist/chocolatey - name: Zip Windows shell: cmd @@ -451,7 +482,7 @@ jobs: } - name: Upload windows zip asset - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bw${{ matrix.license_type.artifact_prefix }}-windows-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/bw${{ matrix.license_type.artifact_prefix }}-windows-${{ env._PACKAGE_VERSION }}.zip @@ -459,18 +490,20 @@ jobs: - name: Upload Chocolatey asset if: matrix.license_type.build_prefix == 'bit' - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden-cli.${{ env._PACKAGE_VERSION }}.nupkg path: apps/cli/dist/chocolatey/bitwarden-cli.${{ env._PACKAGE_VERSION }}.nupkg if-no-files-found: error - name: Zip NPM Build Artifact - run: Get-ChildItem -Path .\build | Compress-Archive -DestinationPath .\bitwarden-cli-${{ env._PACKAGE_VERSION }}-npm-build.zip + env: + _PACKAGE_VERSION: ${{ env._PACKAGE_VERSION }} + run: Get-ChildItem -Path .\build | Compress-Archive -DestinationPath ".\bitwarden-cli-${env:_PACKAGE_VERSION}-npm-build.zip" - name: Upload NPM Build Directory asset if: matrix.license_type.build_prefix == 'bit' - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden-cli-${{ env._PACKAGE_VERSION }}-npm-build.zip path: apps/cli/bitwarden-cli-${{ env._PACKAGE_VERSION }}-npm-build.zip @@ -487,11 +520,14 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Print environment + env: + _PACKAGE_VERSION: ${{ env._PACKAGE_VERSION }} run: | whoami echo "GitHub ref: $GITHUB_REF" @@ -499,15 +535,17 @@ jobs: echo "BW Package Version: $_PACKAGE_VERSION" - name: Get bw linux cli - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: bw-linux-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/snap - name: Setup Snap Package + env: + _PACKAGE_VERSION: ${{ env._PACKAGE_VERSION }} run: | cp -r stores/snap/* -t dist/snap - sed -i s/__version__/${{ env._PACKAGE_VERSION }}/g dist/snap/snapcraft.yaml + sed -i "s/__version__/$_PACKAGE_VERSION/g" "dist/snap/snapcraft.yaml" cd dist/snap ls -alth @@ -534,7 +572,7 @@ jobs: run: sudo snap remove bw - name: Upload snap asset - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bw_${{ env._PACKAGE_VERSION }}_amd64.snap path: apps/cli/dist/snap/bw_${{ env._PACKAGE_VERSION }}_amd64.snap diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 49d9d4c079f..03a09ac8c48 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -55,9 +55,10 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Verify run: | @@ -87,38 +88,41 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: true - name: Get Package Version id: retrieve-version run: | PKG_VERSION=$(jq -r .version src/package.json) echo "Setting version number to $PKG_VERSION" - echo "package_version=$PKG_VERSION" >> $GITHUB_OUTPUT + echo "package_version=$PKG_VERSION" >> "$GITHUB_OUTPUT" - name: Increment Version id: increment-version run: | - BUILD_NUMBER=$(expr 3000 + $GITHUB_RUN_NUMBER) + BUILD_NUMBER=$((3000 + GITHUB_RUN_NUMBER)) echo "Setting build number to $BUILD_NUMBER" - echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT + echo "build_number=$BUILD_NUMBER" >> "$GITHUB_OUTPUT" - name: Get Version Channel id: release-channel + env: + _PACKAGE_VERSION: ${{ steps.retrieve-version.outputs.package_version }} run: | - case "${{ steps.retrieve-version.outputs.package_version }}" in + case "$_PACKAGE_VERSION" in *"alpha"*) - echo "channel=alpha" >> $GITHUB_OUTPUT + echo "channel=alpha" >> "$GITHUB_OUTPUT" echo "[!] We do not yet support 'alpha'" exit 1 ;; *"beta"*) - echo "channel=beta" >> $GITHUB_OUTPUT + echo "channel=beta" >> "$GITHUB_OUTPUT" ;; *) - echo "channel=latest" >> $GITHUB_OUTPUT + echo "channel=latest" >> "$GITHUB_OUTPUT" ;; esac @@ -126,15 +130,15 @@ jobs: id: branch-check run: | if [[ $(git ls-remote --heads origin rc) ]]; then - echo "rc_branch_exists=1" >> $GITHUB_OUTPUT + echo "rc_branch_exists=1" >> "$GITHUB_OUTPUT" else - echo "rc_branch_exists=0" >> $GITHUB_OUTPUT + echo "rc_branch_exists=0" >> "$GITHUB_OUTPUT" fi if [[ $(git ls-remote --heads origin hotfix-rc-desktop) ]]; then - echo "hotfix_branch_exists=1" >> $GITHUB_OUTPUT + echo "hotfix_branch_exists=1" >> "$GITHUB_OUTPUT" else - echo "hotfix_branch_exists=0" >> $GITHUB_OUTPUT + echo "hotfix_branch_exists=0" >> "$GITHUB_OUTPUT" fi - name: Get Node Version @@ -143,13 +147,13 @@ jobs: run: | NODE_NVMRC=$(cat .nvmrc) NODE_VERSION=${NODE_NVMRC/v/''} - echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT + echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Check secrets id: check-secrets run: | has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }} - echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT + echo "has_secrets=$has_secrets" >> "$GITHUB_OUTPUT" linux: name: Linux Build @@ -169,17 +173,25 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' node-version: ${{ env._NODE_VERSION }} + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" + - name: Set up environment run: | sudo apt-get update @@ -220,7 +232,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 id: cache with: path: | @@ -239,48 +251,48 @@ jobs: TARGET: musl run: | rustup target add x86_64-unknown-linux-musl - node build.js --target=x86_64-unknown-linux-musl --release + node build.js --target=x86_64-unknown-linux-musl - name: Build application run: npm run dist:lin + - name: Upload tar.gz artifact + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: bitwarden_${{ env._PACKAGE_VERSION }}_x64.tar.gz + path: apps/desktop/dist/bitwarden_desktop_x64.tar.gz + if-no-files-found: error + - name: Upload .deb artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb if-no-files-found: error - name: Upload .rpm artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.rpm path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.rpm if-no-files-found: error - - name: Upload .freebsd artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64.freebsd - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.freebsd - if-no-files-found: error - - name: Upload .snap artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden_${{ env._PACKAGE_VERSION }}_amd64.snap path: apps/desktop/dist/bitwarden_${{ env._PACKAGE_VERSION }}_amd64.snap if-no-files-found: error - name: Upload .AppImage artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage if-no-files-found: error - name: Upload auto-update artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: ${{ needs.setup.outputs.release_channel }}-linux.yml path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-linux.yml @@ -293,13 +305,12 @@ jobs: sudo npm run pack:lin:flatpak - name: Upload flatpak artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: com.bitwarden.desktop.flatpak path: apps/desktop/dist/com.bitwarden.desktop.flatpak if-no-files-found: error - linux-arm64: name: Linux ARM64 Build # Note, before updating the ubuntu version of the workflow, ensure the snap base image @@ -318,28 +329,46 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' node-version: ${{ env._NODE_VERSION }} + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" + - name: Set up environment run: | sudo apt-get update - sudo apt-get -y install pkg-config libxss-dev rpm musl-dev musl-tools flatpak flatpak-builder + sudo apt-get -y install pkg-config libxss-dev rpm musl-dev musl-tools flatpak flatpak-builder squashfs-tools ruby ruby-dev rubygems build-essential + sudo gem install --no-document fpm + + - name: Set up Snap + run: sudo snap install snapcraft --classic + + - name: Install snaps required by snapcraft in destructive mode + run: | + sudo snap install core22 + sudo snap install gtk-common-themes + sudo snap install gnome-3-28-1804 - name: Print environment run: | node --version npm --version snap --version - snapcraft --version || echo 'snapcraft unavailable' + snapcraft --version - name: Install Node dependencies run: npm ci @@ -366,7 +395,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 id: cache with: path: | @@ -385,7 +414,7 @@ jobs: TARGET: musl run: | rustup target add aarch64-unknown-linux-musl - node build.js --target=aarch64-unknown-linux-musl --release + node build.js --target=aarch64-unknown-linux-musl - name: Check index.d.ts generated if: github.event_name == 'pull_request' && steps.cache.outputs.cache-hit != 'true' @@ -397,23 +426,47 @@ jobs: fi - name: Build application + env: + # Snapcraft environment variables to bypass LXD requirement on ARM64 + SNAPCRAFT_BUILD_ENVIRONMENT: host + USE_SYSTEM_FPM: true run: npm run dist:lin:arm64 + - name: Upload .snap artifact + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: bitwarden_${{ env._PACKAGE_VERSION }}_arm64.snap + path: apps/desktop/dist/bitwarden_${{ env._PACKAGE_VERSION }}_arm64.snap + if-no-files-found: error + - name: Upload tar.gz artifact - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden_${{ env._PACKAGE_VERSION }}_arm64.tar.gz path: apps/desktop/dist/bitwarden_desktop_arm64.tar.gz if-no-files-found: error + - name: Build flatpak + working-directory: apps/desktop + run: | + sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + sudo npm run pack:lin:flatpak + + - name: Upload flatpak artifact + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: com.bitwarden.desktop-arm64.flatpak + path: apps/desktop/dist/com.bitwarden.desktop.flatpak + if-no-files-found: error + windows: name: Windows Build runs-on: windows-2022 needs: - setup permissions: - contents: read - id-token: write + contents: read + id-token: write defaults: run: shell: pwsh @@ -424,17 +477,25 @@ jobs: NODE_OPTIONS: --max_old_space_size=4096 steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' node-version: ${{ env._NODE_VERSION }} + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" + - name: Install AST run: dotnet tool install --global AzureSignTool --version 4.0.1 @@ -497,7 +558,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 id: cache with: path: | @@ -533,21 +594,21 @@ jobs: - name: Rename appx files for store if: ${{ needs.setup.outputs.has_secrets == 'true' }} run: | - Copy-Item "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx" ` - -Destination "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx" - Copy-Item "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx" ` - -Destination "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx" - Copy-Item "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx" ` - -Destination "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx" + Copy-Item "./dist/Bitwarden-$env:_PACKAGE_VERSION-ia32.appx" ` + -Destination "./dist/Bitwarden-$env:_PACKAGE_VERSION-ia32-store.appx" + Copy-Item "./dist/Bitwarden-$env:_PACKAGE_VERSION-x64.appx" ` + -Destination "./dist/Bitwarden-$env:_PACKAGE_VERSION-x64-store.appx" + Copy-Item "./dist/Bitwarden-$env:_PACKAGE_VERSION-arm64.appx" ` + -Destination "./dist/Bitwarden-$env:_PACKAGE_VERSION-arm64-store.appx" - name: Package for Chocolatey if: ${{ needs.setup.outputs.has_secrets == 'true' }} run: | Copy-Item -Path ./stores/chocolatey -Destination ./dist/chocolatey -Recurse - Copy-Item -Path ./dist/nsis-web/Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe ` + Copy-Item -Path ./dist/nsis-web/Bitwarden-Installer-$env:_PACKAGE_VERSION.exe ` -Destination ./dist/chocolatey - $checksum = checksum -t sha256 ./dist/chocolatey/Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe + $checksum = checksum -t sha256 ./dist/chocolatey/Bitwarden-Installer-$env:_PACKAGE_VERSION.exe $chocoInstall = "./dist/chocolatey/tools/chocolateyinstall.ps1" (Get-Content $chocoInstall).replace('__version__', "$env:_PACKAGE_VERSION").replace('__checksum__', $checksum) | Set-Content $chocoInstall choco pack ./dist/chocolatey/bitwarden.nuspec --version "$env:_PACKAGE_VERSION" --out ./dist/chocolatey @@ -555,15 +616,15 @@ jobs: - name: Fix NSIS artifact names for auto-updater if: ${{ needs.setup.outputs.has_secrets == 'true' }} run: | - Rename-Item -Path .\dist\nsis-web\Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z ` - -NewName bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z - Rename-Item -Path .\dist\nsis-web\Bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z ` - -NewName bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z - Rename-Item -Path .\dist\nsis-web\Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z ` - -NewName bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z + Rename-Item -Path .\dist\nsis-web\Bitwarden-$env:_PACKAGE_VERSION-ia32.nsis.7z ` + -NewName bitwarden-$env:_PACKAGE_VERSION-ia32.nsis.7z + Rename-Item -Path .\dist\nsis-web\Bitwarden-$env:_PACKAGE_VERSION-x64.nsis.7z ` + -NewName bitwarden-$env:_PACKAGE_VERSION-x64.nsis.7z + Rename-Item -Path .\dist\nsis-web\Bitwarden-$env:_PACKAGE_VERSION-arm64.nsis.7z ` + -NewName bitwarden-$env:_PACKAGE_VERSION-arm64.nsis.7z - name: Upload portable exe artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe @@ -571,7 +632,7 @@ jobs: - name: Upload installer exe artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/nsis-web/Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe @@ -579,7 +640,7 @@ jobs: - name: Upload appx ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx @@ -587,7 +648,7 @@ jobs: - name: Upload store appx ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx @@ -595,7 +656,7 @@ jobs: - name: Upload NSIS ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z @@ -603,7 +664,7 @@ jobs: - name: Upload appx x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx @@ -611,7 +672,7 @@ jobs: - name: Upload store appx x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx @@ -619,7 +680,7 @@ jobs: - name: Upload NSIS x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z @@ -627,7 +688,7 @@ jobs: - name: Upload appx ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx @@ -635,7 +696,7 @@ jobs: - name: Upload store appx ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx @@ -643,7 +704,7 @@ jobs: - name: Upload NSIS ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z @@ -651,7 +712,7 @@ jobs: - name: Upload nupkg artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden.${{ env._PACKAGE_VERSION }}.nupkg path: apps/desktop/dist/chocolatey/bitwarden.${{ env._PACKAGE_VERSION }}.nupkg @@ -659,21 +720,263 @@ jobs: - name: Upload auto-update artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: ${{ needs.setup.outputs.release_channel }}.yml path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml if-no-files-found: error + windows-beta: + name: Windows Beta Build + runs-on: windows-2022 + needs: setup + permissions: + contents: read + id-token: write + defaults: + run: + shell: pwsh + working-directory: apps/desktop + env: + _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} + NODE_OPTIONS: --max_old_space_size=4096 + steps: + - name: Check out repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + cache: 'npm' + cache-dependency-path: '**/package-lock.json' + node-version: ${{ env._NODE_VERSION }} + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" + + - name: Install AST + run: dotnet tool install --global AzureSignTool --version 4.0.1 + + - name: Print environment + run: | + node --version + npm --version + choco --version + rustup show + + - name: Log in to Azure + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + + - name: Retrieve secrets + id: retrieve-secrets + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: "bitwarden-ci" + secrets: "code-signing-vault-url, + code-signing-client-id, + code-signing-tenant-id, + code-signing-client-secret, + code-signing-cert-name" + + - name: Log out from Azure + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: bitwarden/gh-actions/azure-logout@main + + - name: Install Node dependencies + run: npm ci + working-directory: ./ + + - name: Download SDK Artifacts + if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }} + 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: Override SDK + if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }} + working-directory: ./ + run: | + ls -l ../ + npm link ../sdk-internal + + - name: Cache Native Module + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + id: cache + with: + path: | + apps/desktop/desktop_native/napi/*.node + apps/desktop/desktop_native/dist/* + key: rust-${{ runner.os }}-${{ hashFiles('apps/desktop/desktop_native/**/*') }} + + - name: Build Native Module + if: steps.cache.outputs.cache-hit != 'true' + working-directory: apps/desktop/desktop_native + run: node build.js cross-platform + + - name: Build + run: npm run build + + - name: Pack + if: ${{ needs.setup.outputs.has_secrets == 'false' }} + run: npm run pack:win:beta + + - name: Pack & Sign + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + env: + ELECTRON_BUILDER_SIGN: 1 + SIGNING_VAULT_URL: ${{ steps.retrieve-secrets.outputs.code-signing-vault-url }} + SIGNING_CLIENT_ID: ${{ steps.retrieve-secrets.outputs.code-signing-client-id }} + SIGNING_TENANT_ID: ${{ steps.retrieve-secrets.outputs.code-signing-tenant-id }} + SIGNING_CLIENT_SECRET: ${{ steps.retrieve-secrets.outputs.code-signing-client-secret }} + SIGNING_CERT_NAME: ${{ steps.retrieve-secrets.outputs.code-signing-cert-name }} + run: npm run pack:win:beta + + - name: Rename appx files for store + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + run: | + Copy-Item "./dist/Bitwarden-Beta-$env:_PACKAGE_VERSION-ia32.appx" ` + -Destination "./dist/Bitwarden-Beta-$env:_PACKAGE_VERSION-ia32-store.appx" + Copy-Item "./dist/Bitwarden-Beta-$env:_PACKAGE_VERSION-x64.appx" ` + -Destination "./dist/Bitwarden-Beta-$env:_PACKAGE_VERSION-x64-store.appx" + Copy-Item "./dist/Bitwarden-Beta-$env:_PACKAGE_VERSION-arm64.appx" ` + -Destination "./dist/Bitwarden-Beta-$env:_PACKAGE_VERSION-arm64-store.appx" + + - name: Fix NSIS artifact names for auto-updater + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + run: | + Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-$env:_PACKAGE_VERSION-ia32.nsis.7z ` + -NewName bitwarden-beta-$env:_PACKAGE_VERSION-ia32.nsis.7z + Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-$env:_PACKAGE_VERSION-x64.nsis.7z ` + -NewName bitwarden-beta-$env:_PACKAGE_VERSION-x64.nsis.7z + Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-$env:_PACKAGE_VERSION-arm64.nsis.7z ` + -NewName bitwarden-beta-$env:_PACKAGE_VERSION-arm64.nsis.7z + Rename-Item -Path .\dist\nsis-web\latest.yml ` + -NewName latest-beta.yml + + - name: Upload portable exe artifact + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe + path: apps/desktop/dist/Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe + if-no-files-found: error + + - name: Upload installer exe artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe + path: apps/desktop/dist/nsis-web/Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe + if-no-files-found: error + + - name: Upload appx ia32 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx + if-no-files-found: error + + - name: Upload store appx ia32 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx + if-no-files-found: error + + - name: Upload NSIS ia32 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z + path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z + if-no-files-found: error + + - name: Upload appx x64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx + if-no-files-found: error + + - name: Upload store appx x64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx + if-no-files-found: error + + - name: Upload NSIS x64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z + path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z + if-no-files-found: error + + - name: Upload appx ARM64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx + if-no-files-found: error + + - name: Upload store appx ARM64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx + if-no-files-found: error + + - name: Upload NSIS ARM64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z + path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z + if-no-files-found: error + + - name: Upload auto-update artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: latest-beta.yml + path: apps/desktop/dist/nsis-web/latest-beta.yml + if-no-files-found: error macos-build: name: MacOS Build - runs-on: macos-13 + runs-on: macos-15 needs: - setup permissions: - contents: read - id-token: write + contents: read + id-token: write env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -683,20 +986,33 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.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 + with: + python-version: '3.14' + - name: Set up Node-gyp run: python3 -m pip install setuptools + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" + - name: Print environment run: | node --version @@ -707,14 +1023,14 @@ jobs: - name: Cache Build id: build-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Cache Safari id: safari-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -741,40 +1057,40 @@ jobs: ACCOUNT_NAME: bitwardenci CONTAINER_NAME: profiles run: | - mkdir -p $HOME/secrets + mkdir -p "$HOME/secrets" - az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ + az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \ --name bitwarden_desktop_appstore.provisionprofile \ - --file $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ + --file "$HOME/secrets/bitwarden_desktop_appstore.provisionprofile" \ --output none - az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ + az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \ --name bitwarden_desktop_autofill_app_store_2024.provisionprofile \ - --file $HOME/secrets/bitwarden_desktop_autofill_app_store_2024.provisionprofile \ + --file "$HOME/secrets/bitwarden_desktop_autofill_app_store_2024.provisionprofile" \ --output none - name: Get certificates if: ${{ needs.setup.outputs.has_secrets == 'true' }} run: | - mkdir -p $HOME/certificates + mkdir -p "$HOME/certificates" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/bitwarden-desktop-key | - jq -r .value | base64 -d > $HOME/certificates/bitwarden-desktop-key.p12 + jq -r .value | base64 -d > "$HOME/certificates/bitwarden-desktop-key.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-app-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-app-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/appstore-app-cert.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-installer-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/appstore-installer-cert.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-app-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-app-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/devid-app-cert.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-installer-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/devid-installer-cert.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert | - jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/macdev-cert.p12" - name: Log out from Azure if: ${{ needs.setup.outputs.has_secrets == 'true' }} @@ -785,9 +1101,9 @@ jobs: env: KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} run: | - security create-keychain -p $KEYCHAIN_PASSWORD build.keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security default-keychain -s build.keychain - security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security set-keychain-settings -lut 1200 build.keychain security import "$HOME/certificates/bitwarden-desktop-key.p12" -k build.keychain -P "" \ @@ -808,22 +1124,22 @@ jobs: security import "$HOME/certificates/macdev-cert.p12" -k build.keychain -P "" \ -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEYCHAIN_PASSWORD build.keychain + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain - name: Set up provisioning profiles if: ${{ needs.setup.outputs.has_secrets == 'true' }} run: | - cp $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - $GITHUB_WORKSPACE/apps/desktop/bitwarden_desktop_appstore.provisionprofile + cp "$HOME/secrets/bitwarden_desktop_appstore.provisionprofile" \ + "$GITHUB_WORKSPACE/apps/desktop/bitwarden_desktop_appstore.provisionprofile" - mkdir -p $HOME/Library/MobileDevice/Provisioning\ Profiles - export APP_UUID=`grep UUID -A1 -a $HOME/secrets/bitwarden_desktop_appstore.provisionprofile | grep -io "[-A-Z0-9]\{36\}"` - export AUTOFILL_UUID=`grep UUID -A1 -a $HOME/secrets/bitwarden_desktop_autofill_app_store_2024.provisionprofile | grep -io "[-A-Z0-9]\{36\}"` + mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" + APP_UUID=$(grep UUID -A1 -a "$HOME/secrets/bitwarden_desktop_appstore.provisionprofile" | grep -io "[-A-Z0-9]\{36\}") + AUTOFILL_UUID=$(grep UUID -A1 -a "$HOME/secrets/bitwarden_desktop_autofill_app_store_2024.provisionprofile" | grep -io "[-A-Z0-9]\{36\}") - cp $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - $HOME/Library/MobileDevice/Provisioning\ Profiles/$APP_UUID.provisionprofile - cp $HOME/secrets/bitwarden_desktop_autofill_app_store_2024.provisionprofile \ - $HOME/Library/MobileDevice/Provisioning\ Profiles/$AUTOFILL_UUID.provisionprofile + cp "$HOME/secrets/bitwarden_desktop_appstore.provisionprofile" \ + "$HOME/Library/MobileDevice/Provisioning Profiles/$APP_UUID.provisionprofile" + cp "$HOME/secrets/bitwarden_desktop_autofill_app_store_2024.provisionprofile" \ + "$HOME/Library/MobileDevice/Provisioning Profiles/$AUTOFILL_UUID.provisionprofile" - name: Increment version shell: pwsh @@ -860,7 +1176,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 id: cache with: path: | @@ -876,7 +1192,6 @@ jobs: - name: Build application (dev) run: npm run build - browser-build: name: Browser Build needs: setup @@ -888,18 +1203,17 @@ jobs: pull-requests: write id-token: write - macos-package-github: name: MacOS Package GitHub Release Assets - runs-on: macos-13 + runs-on: macos-15 if: ${{ needs.setup.outputs.has_secrets == 'true' }} needs: - browser-build - macos-build - setup permissions: - contents: read - id-token: write + contents: read + id-token: write env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -909,20 +1223,33 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.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 + with: + python-version: '3.14' + - name: Set up Node-gyp run: python3 -m pip install setuptools + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" + - name: Print environment run: | node --version @@ -933,14 +1260,14 @@ jobs: - name: Get Build Cache id: build-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Setup Safari Cache id: safari-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -964,39 +1291,39 @@ jobs: ACCOUNT_NAME: bitwardenci CONTAINER_NAME: profiles run: | - mkdir -p $HOME/secrets + mkdir -p "$HOME/secrets" - az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ + az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \ --name bitwarden_desktop_developer_id.provisionprofile \ - --file $HOME/secrets/bitwarden_desktop_developer_id.provisionprofile \ + --file "$HOME/secrets/bitwarden_desktop_developer_id.provisionprofile" \ --output none - az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ + az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \ --name bitwarden_desktop_autofill_developer_id.provisionprofile \ - --file $HOME/secrets/bitwarden_desktop_autofill_developer_id.provisionprofile \ + --file "$HOME/secrets/bitwarden_desktop_autofill_developer_id.provisionprofile" \ --output none - name: Get certificates run: | - mkdir -p $HOME/certificates + mkdir -p "$HOME/certificates" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/bitwarden-desktop-key | - jq -r .value | base64 -d > $HOME/certificates/bitwarden-desktop-key.p12 + jq -r .value | base64 -d > "$HOME/certificates/bitwarden-desktop-key.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-app-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-app-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/appstore-app-cert.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-installer-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/appstore-installer-cert.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-app-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-app-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/devid-app-cert.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-installer-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/devid-installer-cert.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert | - jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/macdev-cert.p12" - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main @@ -1005,9 +1332,9 @@ jobs: env: KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} run: | - security create-keychain -p $KEYCHAIN_PASSWORD build.keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security default-keychain -s build.keychain - security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security set-keychain-settings -lut 1200 build.keychain security import "$HOME/certificates/bitwarden-desktop-key.p12" -k build.keychain -P "" \ @@ -1019,21 +1346,21 @@ jobs: security import "$HOME/certificates/devid-installer-cert.p12" -k build.keychain -P "" \ -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEYCHAIN_PASSWORD build.keychain + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain - name: Set up provisioning profiles run: | - cp $HOME/secrets/bitwarden_desktop_developer_id.provisionprofile \ - $GITHUB_WORKSPACE/apps/desktop/bitwarden_desktop_developer_id.provisionprofile + cp "$HOME/secrets/bitwarden_desktop_developer_id.provisionprofile" \ + "$GITHUB_WORKSPACE/apps/desktop/bitwarden_desktop_developer_id.provisionprofile" - mkdir -p $HOME/Library/MobileDevice/Provisioning\ Profiles - export APP_UUID=`grep UUID -A1 -a $HOME/secrets/bitwarden_desktop_developer_id.provisionprofile | grep -io "[-A-Z0-9]\{36\}"` - export AUTOFILL_UUID=`grep UUID -A1 -a $HOME/secrets/bitwarden_desktop_autofill_developer_id.provisionprofile | grep -io "[-A-Z0-9]\{36\}"` + mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" + APP_UUID=$(grep UUID -A1 -a "$HOME/secrets/bitwarden_desktop_developer_id.provisionprofile" | grep -io "[-A-Z0-9]\{36\}") + AUTOFILL_UUID=$(grep UUID -A1 -a "$HOME/secrets/bitwarden_desktop_autofill_developer_id.provisionprofile" | grep -io "[-A-Z0-9]\{36\}") - cp $HOME/secrets/bitwarden_desktop_developer_id.provisionprofile \ - $HOME/Library/MobileDevice/Provisioning\ Profiles/$APP_UUID.provisionprofile - cp $HOME/secrets/bitwarden_desktop_autofill_developer_id.provisionprofile \ - $HOME/Library/MobileDevice/Provisioning\ Profiles/$AUTOFILL_UUID.provisionprofile + cp "$HOME/secrets/bitwarden_desktop_developer_id.provisionprofile" \ + "$HOME/Library/MobileDevice/Provisioning Profiles/$APP_UUID.provisionprofile" + cp "$HOME/secrets/bitwarden_desktop_autofill_developer_id.provisionprofile" \ + "$HOME/Library/MobileDevice/Provisioning Profiles/$AUTOFILL_UUID.provisionprofile" - name: Increment version shell: pwsh @@ -1070,7 +1397,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 id: cache with: path: | @@ -1088,26 +1415,28 @@ jobs: run: npm run build - name: Download Browser artifact - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: path: ${{ github.workspace }}/browser-build-artifacts - name: Unzip Safari artifact run: | - SAFARI_DIR=$(find $GITHUB_WORKSPACE/browser-build-artifacts -name 'dist-safari-*.zip') - echo $SAFARI_DIR - unzip $SAFARI_DIR/dist-safari.zip -d $GITHUB_WORKSPACE/browser-build-artifacts + SAFARI_DIR=$(find "$GITHUB_WORKSPACE/browser-build-artifacts" -name 'dist-safari-*.zip') + echo "$SAFARI_DIR" + unzip "$SAFARI_DIR/dist-safari.zip" -d "$GITHUB_WORKSPACE/browser-build-artifacts" - name: Load Safari extension for .dmg run: | mkdir PlugIns - cp -r $GITHUB_WORKSPACE/browser-build-artifacts/Safari/dmg/build/Release/safari.appex PlugIns/safari.appex + cp -r "$GITHUB_WORKSPACE/browser-build-artifacts/Safari/dmg/build/Release/safari.appex" PlugIns/safari.appex - name: Set up private auth key + env: + _APP_STORE_CONNECT_AUTH_KEY: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }} run: | mkdir ~/private_keys cat << EOF > ~/private_keys/AuthKey_6TV9MKN3GP.p8 - ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }} + $_APP_STORE_CONNECT_AUTH_KEY EOF - name: Build application (dist) @@ -1119,45 +1448,44 @@ jobs: run: npm run pack:mac - name: Upload .zip artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal-mac.zip path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal-mac.zip if-no-files-found: error - name: Upload .dmg artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg if-no-files-found: error - name: Upload .dmg blockmap artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg.blockmap path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg.blockmap if-no-files-found: error - name: Upload auto-update artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: ${{ needs.setup.outputs.release_channel }}-mac.yml path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-mac.yml if-no-files-found: error - macos-package-mas: name: MacOS Package Prod Release Asset - runs-on: macos-13 + runs-on: macos-15 if: ${{ needs.setup.outputs.has_secrets == 'true' }} needs: - browser-build - macos-build - setup permissions: - contents: read - id-token: write + contents: read + id-token: write env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -1167,20 +1495,33 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.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 + with: + python-version: '3.14' + - name: Set up Node-gyp run: python3 -m pip install setuptools + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" + - name: Print environment run: | node --version @@ -1191,14 +1532,14 @@ jobs: - name: Get Build Cache id: build-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Setup Safari Cache id: safari-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -1229,39 +1570,39 @@ jobs: ACCOUNT_NAME: bitwardenci CONTAINER_NAME: profiles run: | - mkdir -p $HOME/secrets + mkdir -p "$HOME/secrets" - az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ + az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \ --name bitwarden_desktop_appstore.provisionprofile \ - --file $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ + --file "$HOME/secrets/bitwarden_desktop_appstore.provisionprofile" \ --output none - az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ + az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \ --name bitwarden_desktop_autofill_app_store_2024.provisionprofile \ - --file $HOME/secrets/bitwarden_desktop_autofill_app_store_2024.provisionprofile \ + --file "$HOME/secrets/bitwarden_desktop_autofill_app_store_2024.provisionprofile" \ --output none - name: Get certificates run: | - mkdir -p $HOME/certificates + mkdir -p "$HOME/certificates" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/bitwarden-desktop-key | - jq -r .value | base64 -d > $HOME/certificates/bitwarden-desktop-key.p12 + jq -r .value | base64 -d > "$HOME/certificates/bitwarden-desktop-key.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-app-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-app-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/appstore-app-cert.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-installer-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/appstore-installer-cert.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-app-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-app-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/devid-app-cert.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-installer-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/devid-installer-cert.p12" az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert | - jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12 + jq -r .value | base64 -d > "$HOME/certificates/macdev-cert.p12" - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main @@ -1270,9 +1611,9 @@ jobs: env: KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} run: | - security create-keychain -p $KEYCHAIN_PASSWORD build.keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security default-keychain -s build.keychain - security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security set-keychain-settings -lut 1200 build.keychain security import "$HOME/certificates/bitwarden-desktop-key.p12" -k build.keychain -P "" \ @@ -1284,21 +1625,21 @@ jobs: security import "$HOME/certificates/appstore-installer-cert.p12" -k build.keychain -P "" \ -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEYCHAIN_PASSWORD build.keychain + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain - name: Set up provisioning profiles run: | - cp $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - $GITHUB_WORKSPACE/apps/desktop/bitwarden_desktop_appstore.provisionprofile + cp "$HOME/secrets/bitwarden_desktop_appstore.provisionprofile" \ + "$GITHUB_WORKSPACE/apps/desktop/bitwarden_desktop_appstore.provisionprofile" - mkdir -p $HOME/Library/MobileDevice/Provisioning\ Profiles - export APP_UUID=`grep UUID -A1 -a $HOME/secrets/bitwarden_desktop_appstore.provisionprofile | grep -io "[-A-Z0-9]\{36\}"` - export AUTOFILL_UUID=`grep UUID -A1 -a $HOME/secrets/bitwarden_desktop_autofill_app_store_2024.provisionprofile | grep -io "[-A-Z0-9]\{36\}"` + mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" + APP_UUID=$(grep UUID -A1 -a "$HOME/secrets/bitwarden_desktop_appstore.provisionprofile" | grep -io "[-A-Z0-9]\{36\}") + AUTOFILL_UUID=$(grep UUID -A1 -a "$HOME/secrets/bitwarden_desktop_autofill_app_store_2024.provisionprofile" | grep -io "[-A-Z0-9]\{36\}") - cp $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - $HOME/Library/MobileDevice/Provisioning\ Profiles/$APP_UUID.provisionprofile - cp $HOME/secrets/bitwarden_desktop_autofill_app_store_2024.provisionprofile \ - $HOME/Library/MobileDevice/Provisioning\ Profiles/$AUTOFILL_UUID.provisionprofile + cp "$HOME/secrets/bitwarden_desktop_appstore.provisionprofile" \ + "$HOME/Library/MobileDevice/Provisioning Profiles/$APP_UUID.provisionprofile" + cp "$HOME/secrets/bitwarden_desktop_autofill_app_store_2024.provisionprofile" \ + "$HOME/Library/MobileDevice/Provisioning Profiles/$AUTOFILL_UUID.provisionprofile" - name: Increment version shell: pwsh @@ -1336,7 +1677,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 id: cache with: path: | @@ -1354,26 +1695,28 @@ jobs: run: npm run build - name: Download Browser artifact - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: path: ${{ github.workspace }}/browser-build-artifacts - name: Unzip Safari artifact run: | - SAFARI_DIR=$(find $GITHUB_WORKSPACE/browser-build-artifacts -name 'dist-safari-*.zip') - echo $SAFARI_DIR - unzip $SAFARI_DIR/dist-safari.zip -d $GITHUB_WORKSPACE/browser-build-artifacts + SAFARI_DIR=$(find "$GITHUB_WORKSPACE/browser-build-artifacts" -name 'dist-safari-*.zip') + echo "$SAFARI_DIR" + unzip "$SAFARI_DIR/dist-safari.zip" -d "$GITHUB_WORKSPACE/browser-build-artifacts" - name: Load Safari extension for App Store run: | mkdir PlugIns - cp -r $GITHUB_WORKSPACE/browser-build-artifacts/Safari/mas/build/Release/safari.appex PlugIns/safari.appex + cp -r "$GITHUB_WORKSPACE/browser-build-artifacts/Safari/mas/build/Release/safari.appex" "PlugIns/safari.appex" - name: Set up private auth key + env: + _APP_STORE_CONNECT_AUTH_KEY: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }} run: | mkdir ~/private_keys cat << EOF > ~/private_keys/AuthKey_6TV9MKN3GP.p8 - ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }} + $_APP_STORE_CONNECT_AUTH_KEY EOF - name: Build application for App Store @@ -1395,14 +1738,14 @@ jobs: $buildInfo | ConvertTo-Json | Set-Content -Path dist/macos-build-number.json - name: Upload MacOS App Store build number artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: macos-build-number.json path: apps/desktop/dist/macos-build-number.json if-no-files-found: error - name: Upload .pkg artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg path: apps/desktop/dist/mas-universal/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg @@ -1412,6 +1755,8 @@ jobs: if: | github.event_name != 'pull_request_target' && (inputs.testflight_distribute || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop') + env: + _APP_STORE_CONNECT_TEAM_ISSUER: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }} run: | brew install gsed @@ -1419,7 +1764,7 @@ jobs: cat << EOF > ~/secrets/appstoreconnect-fastlane.json { - "issuer_id": "${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }}", + "issuer_id": "$_APP_STORE_CONNECT_TEAM_ISSUER", "key_id": "6TV9MKN3GP", "key": "$KEY_WITHOUT_NEWLINES" } @@ -1438,14 +1783,14 @@ jobs: GIT_CHANGE="$(git show -s --format=%s)" - BRANCH=$(echo $BRANCH | sed 's/refs\/heads\///') + BRANCH=$(echo "$BRANCH" | sed 's/refs\/heads\///') CHANGELOG="$BRANCH: $GIT_CHANGE" fastlane pilot upload \ --app_identifier "com.bitwarden.desktop" \ --changelog "$CHANGELOG" \ - --api_key_path $HOME/secrets/appstoreconnect-fastlane.json \ + --api_key_path "$HOME/secrets/appstoreconnect-fastlane.json" \ --pkg "$(find ./dist/mas-universal/Bitwarden*.pkg)" - name: Post message to a Slack channel @@ -1453,7 +1798,7 @@ jobs: if: | github.event_name != 'pull_request_target' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop') - uses: slackapi/slack-github-action@485a9d42d3a73031f12ec201c457e2162c45d02d # v2.0.0 + uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1 with: channel-id: C074F5UESQ0 method: chat.postMessage @@ -1482,15 +1827,16 @@ jobs: - macos-package-github - macos-package-mas permissions: - contents: write - pull-requests: write - id-token: write + contents: write + pull-requests: write + id-token: write runs-on: ubuntu-22.04 steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Log in to Azure uses: bitwarden/gh-actions/azure-login@main @@ -1510,7 +1856,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Upload Sources - uses: crowdin/github-action@f214c8723025f41fc55b2ad26e67b60b80b1885d # v2.7.1 + uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} @@ -1521,7 +1867,6 @@ jobs: upload_sources: true upload_translations: false - check-failures: name: Check for failures if: always() @@ -1537,8 +1882,8 @@ jobs: - macos-package-mas - crowdin-push permissions: - contents: read - id-token: write + contents: read + id-token: write steps: - name: Check if any job failed if: | @@ -1573,4 +1918,3 @@ jobs: SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }} with: status: ${{ job.status }} - diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 18887ce8fbc..02ab7727c24 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -64,26 +64,27 @@ jobs: has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Get GitHub sha as version id: version - run: echo "value=${GITHUB_SHA:0:7}" >> $GITHUB_OUTPUT + run: echo "value=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT" - 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 + echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Check secrets id: check-secrets run: | has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }} - echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT + echo "has_secrets=$has_secrets" >> "$GITHUB_OUTPUT" build-containers: @@ -98,34 +99,43 @@ jobs: matrix: include: - artifact_name: selfhosted-open-source + license_type: "oss" image_name: web-oss npm_command: dist:oss:selfhost - artifact_name: cloud-COMMERCIAL + license_type: "commercial" image_name: web-cloud npm_command: dist:bit:cloud - artifact_name: selfhosted-COMMERCIAL + license_type: "commercial" image_name: web npm_command: dist:bit:selfhost - artifact_name: selfhosted-DEV + license_type: "commercial" image_name: web npm_command: build:bit:selfhost:dev git_metadata: true - artifact_name: cloud-QA + license_type: "commercial" image_name: web-qa-cloud npm_command: build:bit:qa git_metadata: true - artifact_name: ee + license_type: "commercial" image_name: web-ee npm_command: build:bit:ee git_metadata: true - artifact_name: cloud-euprd + license_type: "commercial" image_name: web-euprd npm_command: build:bit:euprd - artifact_name: cloud-euqa + license_type: "commercial" image_name: web-euqa npm_command: build:bit:euqa git_metadata: true - artifact_name: cloud-usdev + license_type: "commercial" image_name: web-usdev npm_command: build:bit:usdev git_metadata: true @@ -134,9 +144,10 @@ jobs: _VERSION: ${{ needs.setup.outputs.version }} steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Get Latest Server Version id: latest-server-version @@ -147,8 +158,10 @@ jobs: - name: Set Server Ref id: set-server-ref + env: + _SERVER_VERSION: ${{ steps.latest-server-version.outputs.version }} run: | - SERVER_REF="${{ steps.latest-server-version.outputs.version }}" + SERVER_REF="$_SERVER_VERSION" echo "Latest server release version: $SERVER_REF" if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then SERVER_REF="$GITHUB_REF" @@ -158,26 +171,27 @@ jobs: SERVER_REF="refs/heads/main" fi echo "Server ref: $SERVER_REF" - echo "server_ref=$SERVER_REF" >> $GITHUB_OUTPUT + echo "server_ref=$SERVER_REF" >> "$GITHUB_OUTPUT" - name: Check out Server repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: path: server repository: bitwarden/server ref: ${{ steps.set-server-ref.outputs.server_ref }} + persist-credentials: false - name: Check Branch to Publish env: PUBLISH_BRANCHES: "main,rc,hotfix-rc-web" id: publish-branch-check run: | - IFS="," read -a publish_branches <<< $PUBLISH_BRANCHES + IFS="," read -a publish_branches <<< "$PUBLISH_BRANCHES" if [[ " ${publish_branches[*]} " =~ " ${GITHUB_REF:11} " ]]; then - echo "is_publish_branch=true" >> $GITHUB_ENV + echo "is_publish_branch=true" >> "$GITHUB_ENV" else - echo "is_publish_branch=false" >> $GITHUB_ENV + echo "is_publish_branch=false" >> "$GITHUB_ENV" fi - name: Add Git metadata to build version @@ -190,7 +204,7 @@ jobs: ########## Set up Docker ########## - name: Set up Docker - uses: docker/setup-docker-action@b60f85385d03ac8acfca6d9996982511d8620a19 # v4.3.0 + uses: docker/setup-docker-action@efe9e3891a4f7307e689f2100b33a155b900a608 # v4.5.0 with: daemon-config: | { @@ -201,10 +215,10 @@ jobs: } - name: Set up QEMU emulators - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 ########## ACRs ########## - name: Log in to Azure @@ -217,11 +231,13 @@ jobs: - name: Log into Prod container registry if: ${{ needs.setup.outputs.has_secrets == 'true' }} - run: az acr login -n ${_AZ_REGISTRY%.azurecr.io} + run: az acr login -n "${_AZ_REGISTRY%.azurecr.io}" ########## Generate image tag and build Docker image ########## - name: Generate container image tag id: tag + env: + _TAG_EXTENSION: ${{ github.event.inputs.custom_tag_extension }} run: | if [[ "${GITHUB_EVENT_NAME}" == "pull_request" || "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s/[^a-zA-Z0-9]/-/g") # Sanitize branch name to alphanumeric only @@ -231,7 +247,7 @@ jobs: if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then SANITIZED_REPO_NAME=$(echo "$_GITHUB_PR_REPO_NAME" | sed "s/[^a-zA-Z0-9]/-/g") # Sanitize repo name to alphanumeric only - IMAGE_TAG=$SANITIZED_REPO_NAME-$IMAGE_TAG # Add repo name to the tag + IMAGE_TAG="$SANITIZED_REPO_NAME-$IMAGE_TAG" # Add repo name to the tag IMAGE_TAG=${IMAGE_TAG:0:128} # Limit to 128 characters, as that's the max length for Docker image tags fi @@ -239,13 +255,13 @@ jobs: IMAGE_TAG=dev fi - TAG_EXTENSION=${{ github.event.inputs.custom_tag_extension }} + TAG_EXTENSION="$_TAG_EXTENSION" if [[ $TAG_EXTENSION ]]; then - IMAGE_TAG=$IMAGE_TAG-$TAG_EXTENSION + IMAGE_TAG="$IMAGE_TAG-$TAG_EXTENSION" fi - echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT + echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT" ########## Build Image ########## - name: Generate image full name @@ -253,15 +269,16 @@ jobs: env: IMAGE_TAG: ${{ steps.tag.outputs.image_tag }} PROJECT_NAME: ${{ matrix.image_name }} - run: echo "name=$_AZ_REGISTRY/${PROJECT_NAME}:${IMAGE_TAG}" >> $GITHUB_OUTPUT + run: echo "name=$_AZ_REGISTRY/${PROJECT_NAME}:${IMAGE_TAG}" >> "$GITHUB_OUTPUT" - name: Build Docker image id: build-container - uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: build-args: | NODE_VERSION=${{ env._NODE_VERSION }} NPM_COMMAND=${{ matrix.npm_command }} + LICENSE_TYPE=${{ matrix.license_type }} context: . file: apps/web/Dockerfile load: true @@ -276,7 +293,7 @@ jobs: if: ${{ needs.setup.outputs.has_secrets == 'true' }} env: IMAGE_NAME: ${{ steps.image-name.outputs.name }} - run: docker push $IMAGE_NAME + run: docker push "$IMAGE_NAME" - name: Zip project working-directory: apps/web @@ -284,13 +301,13 @@ jobs: IMAGE_NAME: ${{ steps.image-name.outputs.name }} run: | mkdir build - docker run --rm --volume $(pwd)/build:/temp --entrypoint sh \ - $IMAGE_NAME -c "cp -r ./ /temp" + docker run --rm --volume "$(pwd)/build":/temp --entrypoint sh \ + "$IMAGE_NAME" -c "cp -r ./ /temp" - zip -r web-${{ env._VERSION }}-${{ matrix.artifact_name }}.zip build + zip -r web-$_VERSION-${{ matrix.artifact_name }}.zip build - name: Upload ${{ matrix.artifact_name }} artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: web-${{ env._VERSION }}-${{ matrix.artifact_name }}.zip path: apps/web/web-${{ env._VERSION }}-${{ matrix.artifact_name }}.zip @@ -298,7 +315,7 @@ jobs: - name: Install Cosign if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' - uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2 + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 - name: Sign image with Cosign if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' @@ -306,17 +323,18 @@ jobs: DIGEST: ${{ steps.build-container.outputs.digest }} TAGS: ${{ steps.image-name.outputs.name }} run: | - IFS="," read -a tags <<< "${TAGS}" - images="" - for tag in "${tags[@]}"; do - images+="${tag}@${DIGEST} " + IFS=',' read -r -a tags_array <<< "${TAGS}" + images=() + for tag in "${tags_array[@]}"; do + images+=("${tag}@${DIGEST}") done - cosign sign --yes ${images} + cosign sign --yes "${images[@]}" + echo "images=${images[*]}" >> "$GITHUB_OUTPUT" - name: Scan Docker image if: ${{ needs.setup.outputs.has_secrets == 'true' }} id: container-scan - uses: anchore/scan-action@2c901ab7378897c01b8efaa2d0c9bf519cc64b9e # v6.2.0 + uses: anchore/scan-action@568b89d27fc18c60e56937bff480c91c772cd993 # v7.1.0 with: image: ${{ steps.image-name.outputs.name }} fail-build: false @@ -324,14 +342,14 @@ jobs: - name: Upload Grype results to GitHub if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: github/codeql-action/upload-sarif@d68b2d4edb4189fd2a5366ac14e72027bd4b37dd # v3.28.2 + uses: github/codeql-action/upload-sarif@573acd9552f33577783abde4acb66a1058e762e5 # codeql-bundle-v2.23.1 with: sarif_file: ${{ steps.container-scan.outputs.sarif }} sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} - name: Log out of Docker - run: docker logout $_AZ_REGISTRY + run: docker logout "$_AZ_REGISTRY" - name: Log out from Azure if: ${{ needs.setup.outputs.has_secrets == 'true' }} @@ -349,9 +367,10 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Log in to Azure uses: bitwarden/gh-actions/azure-login@main @@ -371,7 +390,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Upload Sources - uses: crowdin/github-action@f214c8723025f41fc55b2ad26e67b60b80b1885d # v2.7.1 + uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} @@ -409,7 +428,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Trigger web vault deploy using GitHub Run ID - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} script: | diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index dc4da7d37de..677d3dfc1df 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -31,10 +31,11 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 + persist-credentials: false - name: Get changed files id: get-changed-files-for-chromatic @@ -54,17 +55,17 @@ jobs: run: | NODE_NVMRC=$(cat .nvmrc) NODE_VERSION=${NODE_NVMRC/v/''} - echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT + echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.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@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: "~/.npm" key: ${{ runner.os }}-npm-chromatic-${{ hashFiles('**/package-lock.json') }} @@ -97,7 +98,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Publish to Chromatic - uses: chromaui/action@d0795df816d05c4a89c80295303970fddd247cce # v13.1.4 + uses: chromaui/action@ac86f2ff0a458ffbce7b40698abd44c0fa34d4b6 # v13.3.3 with: token: ${{ secrets.GITHUB_TOKEN }} projectToken: ${{ steps.get-kv-secrets.outputs.CHROMATIC-PROJECT-TOKEN }} diff --git a/.github/workflows/crowdin-pull.yml b/.github/workflows/crowdin-pull.yml index 0b891203855..5475c4dd692 100644 --- a/.github/workflows/crowdin-pull.yml +++ b/.github/workflows/crowdin-pull.yml @@ -49,16 +49,19 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3 + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 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 - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: token: ${{ steps.app-token.outputs.token }} + persist-credentials: false - name: Download translations uses: bitwarden/gh-actions/crowdin@main diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index d3788dc77b9..1deeea12f88 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -54,8 +54,7 @@ on: type: string required: false -permissions: - deployments: write +permissions: {} jobs: setup: @@ -74,56 +73,58 @@ jobs: steps: - name: Configure id: config + env: + _ENVIRONMENT: ${{ inputs.environment }} run: | - ENV_NAME_LOWER=$(echo "${{ inputs.environment }}" | awk '{print tolower($0)}') - echo "configuring the Web deploy for ${{ inputs.environment }}" - echo "environment=${{ inputs.environment }}" >> $GITHUB_OUTPUT + ENV_NAME_LOWER=$(echo "$_ENVIRONMENT" | awk '{print tolower($0)}') + echo "configuring the Web deploy for _ENVIRONMENT" + echo "environment=$_ENVIRONMENT" >> "$GITHUB_OUTPUT" - case ${{ inputs.environment }} in + case $_ENVIRONMENT in "USQA") - echo "azure_login_client_key_name=AZURE_CLIENT_ID_USQA" >> $GITHUB_OUTPUT - echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_USQA" >> $GITHUB_OUTPUT - echo "retrieve_secrets_keyvault=bw-webvault-rlktusqa-kv" >> $GITHUB_OUTPUT - echo "environment_artifact=web-*-cloud-QA.zip" >> $GITHUB_OUTPUT - echo "environment_name=Web Vault - US QA Cloud" >> $GITHUB_OUTPUT - echo "environment_url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT - echo "slack_channel_name=alerts-deploy-qa" >> $GITHUB_OUTPUT + echo "azure_login_client_key_name=AZURE_CLIENT_ID_USQA" >> "$GITHUB_OUTPUT" + echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_USQA" >> "$GITHUB_OUTPUT" + echo "retrieve_secrets_keyvault=bw-webvault-rlktusqa-kv" >> "$GITHUB_OUTPUT" + echo "environment_artifact=web-*-cloud-QA.zip" >> "$GITHUB_OUTPUT" + echo "environment_name=Web Vault - US QA Cloud" >> "$GITHUB_OUTPUT" + echo "environment_url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> "$GITHUB_OUTPUT" + echo "slack_channel_name=alerts-deploy-qa" >> "$GITHUB_OUTPUT" ;; "EUQA") - echo "azure_login_client_key_name=AZURE_CLIENT_ID_EUQA" >> $GITHUB_OUTPUT - echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_EUQA" >> $GITHUB_OUTPUT - echo "retrieve_secrets_keyvault=webvaulteu-westeurope-qa" >> $GITHUB_OUTPUT - echo "environment_artifact=web-*-cloud-euqa.zip" >> $GITHUB_OUTPUT - echo "environment_name=Web Vault - EU QA Cloud" >> $GITHUB_OUTPUT - echo "environment_url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT - echo "slack_channel_name=alerts-deploy-qa" >> $GITHUB_OUTPUT + echo "azure_login_client_key_name=AZURE_CLIENT_ID_EUQA" >> "$GITHUB_OUTPUT" + echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_EUQA" >> "$GITHUB_OUTPUT" + echo "retrieve_secrets_keyvault=webvaulteu-westeurope-qa" >> "$GITHUB_OUTPUT" + echo "environment_artifact=web-*-cloud-euqa.zip" >> "$GITHUB_OUTPUT" + echo "environment_name=Web Vault - EU QA Cloud" >> "$GITHUB_OUTPUT" + echo "environment_url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> "$GITHUB_OUTPUT" + echo "slack_channel_name=alerts-deploy-qa" >> "$GITHUB_OUTPUT" ;; "USPROD") - echo "azure_login_client_key_name=AZURE_CLIENT_ID_USPROD" >> $GITHUB_OUTPUT - echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_USPROD" >> $GITHUB_OUTPUT - echo "retrieve_secrets_keyvault=bw-webvault-klrt-kv" >> $GITHUB_OUTPUT - echo "environment_artifact=web-*-cloud-COMMERCIAL.zip" >> $GITHUB_OUTPUT - echo "environment_name=Web Vault - US Production Cloud" >> $GITHUB_OUTPUT - echo "environment_url=http://vault.bitwarden.com" >> $GITHUB_OUTPUT - echo "slack_channel_name=alerts-deploy-prd" >> $GITHUB_OUTPUT + echo "azure_login_client_key_name=AZURE_CLIENT_ID_USPROD" >> "$GITHUB_OUTPUT" + echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_USPROD" >> "$GITHUB_OUTPUT" + echo "retrieve_secrets_keyvault=bw-webvault-klrt-kv" >> "$GITHUB_OUTPUT" + echo "environment_artifact=web-*-cloud-COMMERCIAL.zip" >> "$GITHUB_OUTPUT" + echo "environment_name=Web Vault - US Production Cloud" >> "$GITHUB_OUTPUT" + echo "environment_url=http://vault.bitwarden.com" >> "$GITHUB_OUTPUT" + echo "slack_channel_name=alerts-deploy-prd" >> "$GITHUB_OUTPUT" ;; "EUPROD") - echo "azure_login_client_key_name=AZURE_CLIENT_ID_EUPROD" >> $GITHUB_OUTPUT - echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_EUPROD" >> $GITHUB_OUTPUT - echo "retrieve_secrets_keyvault=webvault-westeurope-prod" >> $GITHUB_OUTPUT - echo "environment_artifact=web-*-cloud-euprd.zip" >> $GITHUB_OUTPUT - echo "environment_name=Web Vault - EU Production Cloud" >> $GITHUB_OUTPUT - echo "environment_url=http://vault.bitwarden.eu" >> $GITHUB_OUTPUT - echo "slack_channel_name=alerts-deploy-prd" >> $GITHUB_OUTPUT + echo "azure_login_client_key_name=AZURE_CLIENT_ID_EUPROD" >> "$GITHUB_OUTPUT" + echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_EUPROD" >> "$GITHUB_OUTPUT" + echo "retrieve_secrets_keyvault=webvault-westeurope-prod" >> "$GITHUB_OUTPUT" + echo "environment_artifact=web-*-cloud-euprd.zip" >> "$GITHUB_OUTPUT" + echo "environment_name=Web Vault - EU Production Cloud" >> "$GITHUB_OUTPUT" + echo "environment_url=http://vault.bitwarden.eu" >> "$GITHUB_OUTPUT" + echo "slack_channel_name=alerts-deploy-prd" >> "$GITHUB_OUTPUT" ;; "USDEV") - echo "azure_login_client_key_name=AZURE_CLIENT_ID_USDEV" >> $GITHUB_OUTPUT - echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_USDEV" >> $GITHUB_OUTPUT - echo "retrieve_secrets_keyvault=webvault-eastus-dev" >> $GITHUB_OUTPUT - echo "environment_artifact=web-*-cloud-usdev.zip" >> $GITHUB_OUTPUT - echo "environment_name=Web Vault - US Development Cloud" >> $GITHUB_OUTPUT - echo "environment_url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT - echo "slack_channel_name=alerts-deploy-dev" >> $GITHUB_OUTPUT + echo "azure_login_client_key_name=AZURE_CLIENT_ID_USDEV" >> "$GITHUB_OUTPUT" + echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_USDEV" >> "$GITHUB_OUTPUT" + echo "retrieve_secrets_keyvault=webvault-eastus-dev" >> "$GITHUB_OUTPUT" + echo "environment_artifact=web-*-cloud-usdev.zip" >> "$GITHUB_OUTPUT" + echo "environment_name=Web Vault - US Development Cloud" >> "$GITHUB_OUTPUT" + echo "environment_url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> "$GITHUB_OUTPUT" + echo "slack_channel_name=alerts-deploy-dev" >> "$GITHUB_OUTPUT" ;; esac @@ -131,12 +132,14 @@ jobs: env: BUILD_WEB_RUN_ID: ${{ inputs.build-web-run-id }} GH_TOKEN: ${{ github.token }} + _ENVIRONMENT: ${{ inputs.environment }} + _BRANCH_OR_TAG: ${{ inputs.branch-or-tag }} run: | BRANCH_OR_TAG_LOWER="" if [[ "$BUILD_WEB_RUN_ID" == "" ]]; then - BRANCH_OR_TAG_LOWER=$(echo ${{ inputs.branch-or-tag }} | awk '{print tolower($0)}') + BRANCH_OR_TAG_LOWER=$(echo "$_BRANCH_OR_TAG" | awk '{print tolower($0)}') else - BRANCH_OR_TAG_LOWER=$(gh api /repos/bitwarden/clients/actions/runs/$BUILD_WEB_RUN_ID/artifacts --jq '.artifacts[0].workflow_run.head_branch' | awk '{print tolower($0)}') + BRANCH_OR_TAG_LOWER=$(gh api "/repos/bitwarden/clients/actions/runs/$BUILD_WEB_RUN_ID/artifacts" --jq '.artifacts[0].workflow_run.head_branch' | awk '{print tolower($0)}') fi echo "Branch/Tag: $BRANCH_OR_TAG_LOWER" @@ -151,23 +154,23 @@ jobs: DEV_ALLOWED_TAGS_PATTERN='main' if [[ \ - ${{ inputs.environment }} =~ \.*($PROD_ENV_PATTERN)\.* && \ + $_ENVIRONMENT =~ \.*($PROD_ENV_PATTERN)\.* && \ ! "$BRANCH_OR_TAG_LOWER" =~ ^($PROD_ALLOWED_TAGS_PATTERN).* \ ]] || [[ \ - ${{ inputs.environment }} =~ \.*($QA_ENV_PATTERN)\.* && \ + $_ENVIRONMENT =~ \.*($QA_ENV_PATTERN)\.* && \ ! "$BRANCH_OR_TAG_LOWER" =~ ^($QA_ALLOWED_TAGS_PATTERN).* \ ]] || [[ \ - ${{ inputs.environment }} =~ \.*($DEV_ENV_PATTERN)\.* && \ - $BRANCH_OR_TAG_LOWER != $DEV_ALLOWED_TAGS_PATTERN \ + $_ENVIRONMENT =~ \.*($DEV_ENV_PATTERN)\.* && \ + $BRANCH_OR_TAG_LOWER != "$DEV_ALLOWED_TAGS_PATTERN" \ ]]; then echo "!Deployment blocked!" - echo "Attempting to deploy a tag that is not allowed in ${{ inputs.environment }} environment" + echo "Attempting to deploy a tag that is not allowed in $_ENVIRONMENT environment" echo - echo "Environment: ${{ inputs.environment }}" + echo "Environment: $_ENVIRONMENT" echo "Tag: $BRANCH_OR_TAG_LOWER" exit 1 else - echo "The input Branch/Tag: '$BRANCH_OR_TAG_LOWER' is allowed to deploy on ${{ inputs.environment }} environment" + echo "The input Branch/Tag: '$BRANCH_OR_TAG_LOWER' is allowed to deploy on $_ENVIRONMENT environment" fi approval: @@ -251,19 +254,24 @@ jobs: id: set-artifact-commit env: GH_TOKEN: ${{ github.token }} + _BUILD_WEB_RUN_ID: ${{ inputs.build-web-run-id }} + _ARTIFACT_BUILD_COMMIT: ${{ steps.download-latest-artifacts-run-id.outputs.artifact-build-commit }} + _DOWNLOAD_LATEST_ARTIFACTS_OUTCOME: ${{ steps.download-latest-artifacts.outcome }} + _WORKFLOW_ID: ${{ steps.trigger-build-web.outputs.workflow_id}} + _ARTIFACT_COMMIT: ${{ steps.download-latest-artifacts.outputs.artifact-build-commit }} run: | # If run-id was used, get the commit from the download-latest-artifacts-run-id step - if [ "${{ inputs.build-web-run-id }}" ]; then - echo "commit=${{ steps.download-latest-artifacts-run-id.outputs.artifact-build-commit }}" >> $GITHUB_OUTPUT + if [ "$_BUILD_WEB_RUN_ID" ]; then + echo "commit=$_ARTIFACT_BUILD_COMMIT" >> "$GITHUB_OUTPUT" - elif [ "${{ steps.download-latest-artifacts.outcome }}" == "failure" ]; then + elif [ "$_DOWNLOAD_LATEST_ARTIFACTS_OUTCOME" == "failure" ]; then # If the download-latest-artifacts step failed, query the GH API to get the commit SHA of the artifact that was just built with trigger-build-web. - commit=$(gh api /repos/bitwarden/clients/actions/runs/${{ steps.trigger-build-web.outputs.workflow_id }}/artifacts --jq '.artifacts[0].workflow_run.head_sha') - echo "commit=$commit" >> $GITHUB_OUTPUT + commit=$(gh api "/repos/bitwarden/clients/actions/runs/$_WORKFLOW_ID/artifacts" --jq '.artifacts[0].workflow_run.head_sha') + echo "commit=$commit" >> "$GITHUB_OUTPUT" else # Set the commit to the output of step download-latest-artifacts. - echo "commit=${{ steps.download-latest-artifacts.outputs.artifact-build-commit }}" >> $GITHUB_OUTPUT + echo "commit=$_ARTIFACT_COMMIT" >> "$GITHUB_OUTPUT" fi notify-start: @@ -299,12 +307,14 @@ jobs: name: Display commit needs: artifact-check runs-on: ubuntu-22.04 + env: + _ARTIFACT_BUILD_COMMIT_SHA: ${{ needs.artifact-check.outputs.artifact_build_commit }} steps: - name: Display commit SHA run: | REPO_URL="https://github.com/bitwarden/clients/commit" - COMMIT_SHA="${{ needs.artifact-check.outputs.artifact_build_commit }}" - echo ":steam_locomotive: View [commit]($REPO_URL/$COMMIT_SHA)" >> $GITHUB_STEP_SUMMARY + COMMIT_SHA="$_ARTIFACT_BUILD_COMMIT_SHA" + echo ":steam_locomotive: View [commit]($REPO_URL/$COMMIT_SHA)" >> "$GITHUB_STEP_SUMMARY" azure-deploy: name: Deploy Web Vault to ${{ inputs.environment }} Storage Account @@ -358,14 +368,20 @@ jobs: - name: Unzip build asset working-directory: apps/web - run: unzip ${{ env._ENVIRONMENT_ARTIFACT }} + run: unzip "$_ENVIRONMENT_ARTIFACT" - name: Login to Azure uses: bitwarden/gh-actions/azure-login@main + env: + # The following 2 values are ignored in Zizmor, because they have to be dynamically mapped from secrets + # The only way around this is to create separate steps per environment with static secret references, which is not maintainable + SUBSCRIPTION_ID: ${{ secrets[ needs.setup.outputs.azure_login_subscription_id_key_name ] }} # zizmor: ignore[overprovisioned-secrets] + CLIENT_ID: ${{ secrets[ needs.setup.outputs.azure_login_client_key_name ] }} # zizmor: ignore[overprovisioned-secrets] + TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} with: - subscription_id: ${{ secrets[needs.setup.outputs.azure_login_subscription_id_key_name] }} - tenant_id: ${{ secrets.AZURE_TENANT_ID }} - client_id: ${{ secrets[needs.setup.outputs.azure_login_client_key_name] }} + subscription_id: ${{ env.SUBSCRIPTION_ID }} + tenant_id: ${{ env.TENANT_ID }} + client_id: ${{ env.CLIENT_ID }} - name: Retrieve Storage Account name id: retrieve-secrets-azcopy @@ -379,9 +395,10 @@ jobs: env: AZCOPY_AUTO_LOGIN_TYPE: AZCLI AZCOPY_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + _VAULT_NAME: ${{ steps.retrieve-secrets-azcopy.outputs.sa-bitwarden-web-vault-name }} run: | - azcopy sync ./build 'https://${{ steps.retrieve-secrets-azcopy.outputs.sa-bitwarden-web-vault-name }}.blob.core.windows.net/$web/' \ - --delete-destination=${{ inputs.force-delete-destination }} --compare-hash="MD5" + azcopy sync ./build "https://$_VAULT_NAME.blob.core.windows.net/\$web/" \ + --delete-destination="${{ inputs.force-delete-destination }}" --compare-hash="MD5" - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main diff --git a/.github/workflows/lint-crowdin-config.yml b/.github/workflows/lint-crowdin-config.yml index 38a3ef59ea7..b0efeb50823 100644 --- a/.github/workflows/lint-crowdin-config.yml +++ b/.github/workflows/lint-crowdin-config.yml @@ -22,9 +22,10 @@ jobs: ] steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 1 + persist-credentials: false - name: Log in to Azure uses: bitwarden/gh-actions/azure-login@main @@ -44,7 +45,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Lint ${{ matrix.app.name }} config - uses: crowdin/github-action@f214c8723025f41fc55b2ad26e67b60b80b1885d # v2.7.1 + uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.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 7eab45e5b1b..48d3eca2f4e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -31,7 +31,9 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Lint filenames (no capital characters) run: | @@ -49,6 +51,7 @@ jobs: ! -path "*/Cargo.toml" \ ! -path "*/Cargo.lock" \ ! -path "./apps/desktop/macos/*" \ + ! -path "*/CLAUDE.md" \ > tmp.txt diff <(sort .github/whitelist-capital-letters.txt) <(sort tmp.txt) @@ -57,10 +60,10 @@ jobs: run: | NODE_NVMRC=$(cat .nvmrc) NODE_VERSION=${NODE_NVMRC/v/''} - echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT + echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -72,6 +75,9 @@ jobs: - name: Lint unowned dependencies run: npm run lint:dep-ownership + - name: Lint sdk-internal versions + run: npm run lint:sdk-internal-versions + - name: Run linter run: npm run lint @@ -88,14 +94,31 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # stable + with: + toolchain: stable + components: rustfmt, clippy + + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # stable + with: + toolchain: nightly + components: rustfmt - name: Check Rust version run: rustup --version + - name: Cache cargo registry + uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2.7.7 + - name: Run cargo fmt working-directory: ./apps/desktop/desktop_native - run: cargo fmt --check + run: cargo +nightly fmt --check - name: Run Clippy working-directory: ./apps/desktop/desktop_native @@ -109,3 +132,19 @@ jobs: - name: Cargo sort working-directory: ./apps/desktop/desktop_native run: cargo sort --workspace --check + + - name: Install cargo-udeps + run: cargo install cargo-udeps --version 0.1.57 --locked + + - name: Cargo udeps + working-directory: ./apps/desktop/desktop_native + run: cargo +nightly udeps --workspace --all-features --all-targets + + - name: Install cargo-deny + uses: taiki-e/install-action@81ee1d48d9194cdcab880cbdc7d36e87d39874cb # v2.62.45 + with: + tool: cargo-deny@0.18.5 + + - name: Run cargo deny + working-directory: ./apps/desktop/desktop_native + run: cargo deny --log-level error --all-features check all diff --git a/.github/workflows/locales-lint.yml b/.github/workflows/locales-lint.yml index 0c8148d4c28..8335d6aacad 100644 --- a/.github/workflows/locales-lint.yml +++ b/.github/workflows/locales-lint.yml @@ -17,18 +17,20 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Checkout base branch repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.base.sha }} path: base + persist-credentials: false - name: Install dependencies run: npm ci - name: Compare run: | - npm run test:locales - if [ $? -eq 0 ]; then + if npm run test:locales; then echo "Lint check successful." else echo "Lint check failed." diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 9349239a134..0f01aa27899 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -12,9 +12,10 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 + persist-credentials: false - name: Get Node Version id: retrieve-node-version @@ -22,10 +23,10 @@ jobs: run: | NODE_NVMRC=$(cat .nvmrc) NODE_VERSION=${NODE_NVMRC/v/''} - echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT + echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.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 bef686592d4..8fcd1fe7c98 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -65,14 +65,18 @@ jobs: - name: Version output id: version-output + env: + INPUT_VERSION: ${{ inputs.version }} run: | - if [[ "${{ inputs.version }}" == "latest" || "${{ inputs.version }}" == "" ]]; then - VERSION=$(curl "https://api.github.com/repos/bitwarden/clients/releases" | jq -c '.[] | select(.tag_name | contains("cli")) | .tag_name' | head -1 | grep -ohE '20[0-9]{2}\.([1-9]|1[0-2])\.[0-9]+') + if [[ "$INPUT_VERSION" == "latest" || "$INPUT_VERSION" == "" ]]; then + TAG_NAME=$(curl -s "https://api.github.com/repos/bitwarden/clients/releases" \ + | jq -r '.[] | select(.tag_name | contains("cli")) | .tag_name' | head -1) + VERSION="${TAG_NAME#cli-v}" echo "Latest Released Version: $VERSION" - echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" else - echo "Release Version: ${{ inputs.version }}" - echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT + echo "Release Version: $INPUT_VERSION" + echo "version=$INPUT_VERSION" >> "$GITHUB_OUTPUT" fi - name: Create GitHub deployment @@ -99,7 +103,9 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Log in to Azure uses: bitwarden/gh-actions/azure-login@main @@ -119,17 +125,17 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Install Snap - uses: samuelmeuli/action-snapcraft@d33c176a9b784876d966f80fb1b461808edc0641 # v2.1.1 + uses: samuelmeuli/action-snapcraft@fceeb3c308e76f3487e72ef608618de625fb7fe8 # v3.0.1 - name: Download artifacts - run: wget https://github.com/bitwarden/clients/releases/download/cli-v${{ env._PKG_VERSION }}/bw_${{ env._PKG_VERSION }}_amd64.snap + run: wget "https://github.com/bitwarden/clients/releases/download/cli-v${_PKG_VERSION}/bw_${_PKG_VERSION}_amd64.snap" - name: Publish Snap & logout if: ${{ inputs.publish_type != 'Dry Run' }} env: SNAPCRAFT_STORE_CREDENTIALS: ${{ steps.retrieve-secrets.outputs.snapcraft-store-token }} run: | - snapcraft upload bw_${{ env._PKG_VERSION }}_amd64.snap --release stable + snapcraft upload "bw_${_PKG_VERSION}_amd64.snap" --release stable snapcraft logout choco: @@ -145,7 +151,9 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Log in to Azure uses: bitwarden/gh-actions/azure-login@main @@ -173,7 +181,7 @@ jobs: run: New-Item -ItemType directory -Path ./dist - name: Download artifacts - run: Invoke-WebRequest -Uri "https://github.com/bitwarden/clients/releases/download/cli-v${{ env._PKG_VERSION }}/bitwarden-cli.${{ env._PKG_VERSION }}.nupkg" -OutFile bitwarden-cli.${{ env._PKG_VERSION }}.nupkg + run: Invoke-WebRequest -Uri "https://github.com/bitwarden/clients/releases/download/cli-v$($env:_PKG_VERSION)/bitwarden-cli.$($env:_PKG_VERSION).nupkg" -OutFile bitwarden-cli.$($env:_PKG_VERSION).nupkg working-directory: apps/cli/dist - name: Push to Chocolatey @@ -195,27 +203,34 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + - name: Get Node version id: retrieve-node-version + working-directory: ./ run: | NODE_NVMRC=$(cat .nvmrc) NODE_VERSION=${NODE_NVMRC/v/''} - echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT + echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: ${{ steps.retrieve-node-version.outputs.node_version }} - npm-version: "11.5.1" # FIXME: npm 11.5.1 or later is required to publish w/ OIDC; move version management to somewhere maintainable by automation registry-url: "https://registry.npmjs.org/" + - name: Install NPM + run: | + npm install -g npm@latest # npm 11.5.1 or later is required to publish w/ OIDC + npm --version + - name: Download and set up artifact run: | mkdir -p build - wget https://github.com/bitwarden/clients/releases/download/cli-v${{ env._PKG_VERSION }}/bitwarden-cli-${{ env._PKG_VERSION }}-npm-build.zip - unzip bitwarden-cli-${{ env._PKG_VERSION }}-npm-build.zip -d build + wget "https://github.com/bitwarden/clients/releases/download/cli-v${_PKG_VERSION}/bitwarden-cli-${_PKG_VERSION}-npm-build.zip" + unzip "bitwarden-cli-${_PKG_VERSION}-npm-build.zip" -d build - name: Publish NPM if: ${{ inputs.publish_type != 'Dry Run' }} diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index 9fe8909f8d6..3d512d49559 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -72,39 +72,46 @@ jobs: - name: Check Publish Version id: version + env: + INPUT_VERSION: ${{ inputs.version }} run: | - if [[ "${{ inputs.version }}" == "latest" || "${{ inputs.version }}" == "" ]]; then - TAG_NAME=$(curl "https://api.github.com/repos/bitwarden/clients/releases" | jq -c '.[] | select(.tag_name | contains("desktop")) | .tag_name' | head -1 | cut -d '"' -f 2) - VERSION=$(echo $TAG_NAME | sed "s/desktop-v//") + if [[ "$INPUT_VERSION" == "latest" || "$INPUT_VERSION" == "" ]]; then + TAG_NAME=$(curl -s "https://api.github.com/repos/bitwarden/clients/releases" \ + | jq -r '.[] | select(.tag_name | contains("desktop")) | .tag_name' | head -1) + VERSION="${TAG_NAME#desktop-v}" + echo "Latest Released Version: $VERSION" - echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Tag name: $TAG_NAME" - echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT + echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" else - echo "Release Version: ${{ inputs.version }}" - echo "version=${{ inputs.version }}" + VERSION="$INPUT_VERSION" + TAG_NAME="desktop-v$VERSION" - TAG_NAME="desktop-v${{ inputs.version }}" + echo "Release Version: $VERSION" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Tag name: $TAG_NAME" - echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT + echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" fi - name: Get Version Channel id: release_channel + env: + VERSION: ${{ steps.version.outputs.version }} run: | - case "${{ steps.version.outputs.version }}" in + case "${VERSION}" in *"alpha"*) - echo "channel=alpha" >> $GITHUB_OUTPUT + echo "channel=alpha" >> "$GITHUB_OUTPUT" echo "[!] We do not yet support 'alpha'" exit 1 ;; *"beta"*) - echo "channel=beta" >> $GITHUB_OUTPUT + echo "channel=beta" >> "$GITHUB_OUTPUT" ;; *) - echo "channel=latest" >> $GITHUB_OUTPUT + echo "channel=latest" >> "$GITHUB_OUTPUT" ;; esac @@ -159,16 +166,16 @@ jobs: env: GH_TOKEN: ${{ github.token }} working-directory: apps/desktop/artifacts - run: gh release download ${{ env._RELEASE_TAG }} -R bitwarden/clients + run: gh release download "$_RELEASE_TAG" -R bitwarden/clients - name: Set staged rollout percentage env: RELEASE_CHANNEL: ${{ needs.setup.outputs.release_channel }} ROLLOUT_PCT: ${{ inputs.electron_rollout_percentage }} run: | - echo "stagingPercentage: ${ROLLOUT_PCT}" >> apps/desktop/artifacts/${RELEASE_CHANNEL}.yml - echo "stagingPercentage: ${ROLLOUT_PCT}" >> apps/desktop/artifacts/${RELEASE_CHANNEL}-linux.yml - echo "stagingPercentage: ${ROLLOUT_PCT}" >> apps/desktop/artifacts/${RELEASE_CHANNEL}-mac.yml + echo "stagingPercentage: ${ROLLOUT_PCT}" >> "apps/desktop/artifacts/${RELEASE_CHANNEL}.yml" + echo "stagingPercentage: ${ROLLOUT_PCT}" >> "apps/desktop/artifacts/${RELEASE_CHANNEL}-linux.yml" + echo "stagingPercentage: ${ROLLOUT_PCT}" >> "apps/desktop/artifacts/${RELEASE_CHANNEL}-mac.yml" - name: Publish artifacts to S3 if: ${{ inputs.publish_type != 'Dry Run' }} @@ -179,27 +186,11 @@ jobs: AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.aws-electron-bucket-name }} working-directory: apps/desktop/artifacts run: | - aws s3 cp ./ $AWS_S3_BUCKET_NAME/desktop/ \ + aws s3 cp ./ "$AWS_S3_BUCKET_NAME/desktop/" \ --acl "public-read" \ --recursive \ --quiet - - name: Update deployment status to Success - if: ${{ inputs.publish_type != 'Dry Run' && success() }} - uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3 - with: - token: '${{ secrets.GITHUB_TOKEN }}' - state: 'success' - deployment-id: ${{ needs.setup.outputs.deployment_id }} - - - name: Update deployment status to Failure - if: ${{ inputs.publish_type != 'Dry Run' && failure() }} - uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3 - with: - token: '${{ secrets.GITHUB_TOKEN }}' - state: 'failure' - deployment-id: ${{ needs.setup.outputs.deployment_id }} - snap: name: Deploy Snap runs-on: ubuntu-22.04 @@ -213,7 +204,9 @@ jobs: _RELEASE_TAG: ${{ needs.setup.outputs.tag_name }} steps: - name: Checkout Repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Log in to Azure uses: bitwarden/gh-actions/azure-login@main @@ -233,7 +226,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Install Snap - uses: samuelmeuli/action-snapcraft@d33c176a9b784876d966f80fb1b461808edc0641 # v2.1.1 + uses: samuelmeuli/action-snapcraft@fceeb3c308e76f3487e72ef608618de625fb7fe8 # v3.0.1 - name: Setup run: mkdir dist @@ -241,14 +234,14 @@ jobs: - name: Download artifacts working-directory: apps/desktop/dist - run: wget https://github.com/bitwarden/clients/releases/download/${{ env._RELEASE_TAG }}/bitwarden_${{ env._PKG_VERSION }}_amd64.snap + run: wget "https://github.com/bitwarden/clients/releases/download/${_RELEASE_TAG}/bitwarden_${_PKG_VERSION}_amd64.snap" - name: Deploy to Snap Store if: ${{ inputs.publish_type != 'Dry Run' }} env: SNAPCRAFT_STORE_CREDENTIALS: ${{ steps.retrieve-secrets.outputs.snapcraft-store-token }} run: | - snapcraft upload bitwarden_${{ env._PKG_VERSION }}_amd64.snap --release stable + snapcraft upload "bitwarden_${_PKG_VERSION}_amd64.snap" --release stable snapcraft logout working-directory: apps/desktop/dist @@ -265,7 +258,9 @@ jobs: _RELEASE_TAG: ${{ needs.setup.outputs.tag_name }} steps: - name: Checkout Repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Print Environment run: | @@ -300,7 +295,7 @@ jobs: - name: Download artifacts working-directory: apps/desktop/dist - run: Invoke-WebRequest -Uri "https://github.com/bitwarden/clients/releases/download/${{ env._RELEASE_TAG }}/bitwarden.${{ env._PKG_VERSION }}.nupkg" -OutFile bitwarden.${{ env._PKG_VERSION }}.nupkg + run: Invoke-WebRequest -Uri "https://github.com/bitwarden/clients/releases/download/$($env:_RELEASE_TAG)/bitwarden.$($env:_PKG_VERSION).nupkg" -OutFile "bitwarden.$($env:_PKG_VERSION).nupkg" - name: Push to Chocolatey if: ${{ inputs.publish_type != 'Dry Run' }} @@ -320,10 +315,12 @@ jobs: _RELEASE_TAG: ${{ needs.setup.outputs.tag_name }} steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Validate release notes for MAS - if: inputs.mas_publish && (inputs.release_notes == '' || inputs.release_notes == null) + if: inputs.release_notes == '' || inputs.release_notes == null run: | echo "❌ Release notes are required when publishing to Mac App Store" echo "Please provide release notes using the 'Release Notes' input field" @@ -331,15 +328,15 @@ jobs: - name: Download MacOS App Store build number working-directory: apps/desktop - run: wget https://github.com/bitwarden/clients/releases/download/${{ env._RELEASE_TAG }}/macos-build-number.json + 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@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0 + uses: ruby/setup-ruby@d5126b9b3579e429dd52e51e68624dda2e05be25 # v1.267.0 with: - ruby-version: '3.0' + ruby-version: '3.4.7' bundler-cache: false working-directory: apps/desktop - + - name: Install Fastlane working-directory: apps/desktop run: gem install fastlane @@ -365,33 +362,35 @@ jobs: env: APP_STORE_CONNECT_TEAM_ISSUER: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }} APP_STORE_CONNECT_AUTH_KEY: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }} + CHANGELOG: ${{ inputs.release_notes }} + PUBLISH_TYPE: ${{ inputs.publish_type }} working-directory: apps/desktop run: | BUILD_NUMBER=$(jq -r '.buildNumber' macos-build-number.json) - CHANGELOG="${{ inputs.release_notes }}" - IS_DRY_RUN="${{ inputs.publish_type == 'Dry Run' }}" - - if [ "$IS_DRY_RUN" = "true" ]; then + + if [ "$PUBLISH_TYPE" = "Dry Run" ]; then echo "🧪 DRY RUN MODE - Testing without actual App Store submission" echo "📦 Would publish build $BUILD_NUMBER to Mac App Store" + IS_DRY_RUN="true" else echo "🚀 PRODUCTION MODE - Publishing to Mac App Store" echo "📦 Publishing build $BUILD_NUMBER to Mac App Store" + IS_DRY_RUN="false" fi - + echo "📝 Release notes (${#CHANGELOG} chars): ${CHANGELOG:0:100}..." - + # Validate changelog length (App Store limit is 4000 chars) if [ ${#CHANGELOG} -gt 4000 ]; then echo "❌ Release notes too long: ${#CHANGELOG} characters (max 4000)" exit 1 fi - + fastlane publish --verbose \ - app_version:"${{ env._PKG_VERSION }}" \ - build_number:$BUILD_NUMBER \ + app_version:"${_PKG_VERSION}" \ + build_number:"$BUILD_NUMBER" \ changelog:"$CHANGELOG" \ - dry_run:$IS_DRY_RUN + dry_run:"$IS_DRY_RUN" update-deployment: name: Update Deployment Status diff --git a/.github/workflows/publish-web.yml b/.github/workflows/publish-web.yml index a6f0f1be066..62d9342cf61 100644 --- a/.github/workflows/publish-web.yml +++ b/.github/workflows/publish-web.yml @@ -28,7 +28,9 @@ jobs: contents: read steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Branch check if: ${{ inputs.publish_type != 'Dry Run' }} @@ -72,7 +74,9 @@ jobs: echo "Github Release Option: $_RELEASE_OPTION" - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false ########## ACR ########## - name: Log in to Azure @@ -100,33 +104,33 @@ jobs: - name: Pull branch image run: | if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then - docker pull $_AZ_REGISTRY/web:latest + docker pull "$_AZ_REGISTRY/web:latest" else - docker pull $_AZ_REGISTRY/web:$_BRANCH_NAME + docker pull "$_AZ_REGISTRY/web:$_BRANCH_NAME" fi - name: Tag version run: | if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then - docker tag $_AZ_REGISTRY/web:latest $_AZ_REGISTRY/web:dryrun - docker tag $_AZ_REGISTRY/web:latest $_AZ_REGISTRY/web-sh:dryrun + docker tag "$_AZ_REGISTRY/web:latest" "$_AZ_REGISTRY/web:dryrun" + docker tag "$_AZ_REGISTRY/web:latest" "$_AZ_REGISTRY/web-sh:dryrun" else - docker tag $_AZ_REGISTRY/web:$_BRANCH_NAME $_AZ_REGISTRY/web:$_RELEASE_VERSION - docker tag $_AZ_REGISTRY/web:$_BRANCH_NAME $_AZ_REGISTRY/web-sh:$_RELEASE_VERSION - docker tag $_AZ_REGISTRY/web:$_BRANCH_NAME $_AZ_REGISTRY/web:latest - docker tag $_AZ_REGISTRY/web:$_BRANCH_NAME $_AZ_REGISTRY/web-sh:latest + docker tag "$_AZ_REGISTRY/web:$_BRANCH_NAME" "$_AZ_REGISTRY/web:$_RELEASE_VERSION" + docker tag "$_AZ_REGISTRY/web:$_BRANCH_NAME" "$_AZ_REGISTRY/web-sh:$_RELEASE_VERSION" + docker tag "$_AZ_REGISTRY/web:$_BRANCH_NAME" "$_AZ_REGISTRY/web:latest" + docker tag "$_AZ_REGISTRY/web:$_BRANCH_NAME" "$_AZ_REGISTRY/web-sh:latest" fi - name: Push version run: | if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then - docker push $_AZ_REGISTRY/web:dryrun - docker push $_AZ_REGISTRY/web-sh:dryrun + docker push "$_AZ_REGISTRY/web:dryrun" + docker push "$_AZ_REGISTRY/web-sh:dryrun" else - docker push $_AZ_REGISTRY/web:$_RELEASE_VERSION - docker push $_AZ_REGISTRY/web-sh:$_RELEASE_VERSION - docker push $_AZ_REGISTRY/web:latest - docker push $_AZ_REGISTRY/web-sh:latest + docker push "$_AZ_REGISTRY/web:$_RELEASE_VERSION" + docker push "$_AZ_REGISTRY/web-sh:$_RELEASE_VERSION" + docker push "$_AZ_REGISTRY/web:latest" + docker push "$_AZ_REGISTRY/web-sh:latest" fi - name: Log out from Azure @@ -153,11 +157,10 @@ jobs: - name: Log out of Docker run: docker logout - self-host-unified-build: - name: Trigger self-host unified build + bitwarden-lite-build: + name: Trigger Bitwarden lite build runs-on: ubuntu-22.04 - needs: - - setup + needs: setup permissions: id-token: write steps: @@ -168,27 +171,35 @@ jobs: tenant_id: ${{ secrets.AZURE_TENANT_ID }} client_id: ${{ secrets.AZURE_CLIENT_ID }} - - name: Retrieve GitHub PAT secrets - id: retrieve-secret-pat + - name: Get Azure Key Vault secrets + id: get-kv-secrets uses: bitwarden/gh-actions/get-keyvault-secrets@main with: - keyvault: "bitwarden-ci" - secrets: "github-pat-bitwarden-devops-bot-repo-scope" + keyvault: gh-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main - - name: Trigger self-host build - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + - name: Generate GH App token + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + id: app-token with: - github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + + - name: Trigger Bitwarden lite build + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ steps.app-token.outputs.token }} script: | await github.rest.actions.createWorkflowDispatch({ owner: 'bitwarden', repo: 'self-host', - workflow_id: 'build-unified.yml', + workflow_id: 'build-bitwarden-lite.yml', ref: 'main', inputs: { - use_latest_core_version: true + use_latest_core_version: true, + web_branch: process.env.GITHUB_REF } }); diff --git a/.github/workflows/release-browser.yml b/.github/workflows/release-browser.yml index ac79287f84d..ff5fb669faf 100644 --- a/.github/workflows/release-browser.yml +++ b/.github/workflows/release-browser.yml @@ -28,7 +28,9 @@ jobs: release_version: ${{ steps.version.outputs.version }} steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Branch check if: ${{ github.event.inputs.release_type != 'Dry Run' }} @@ -59,7 +61,9 @@ jobs: contents: read steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Testing locales - extName length run: | @@ -69,9 +73,11 @@ jobs: echo "============" echo "extName string must be 40 characters or less" echo - for locale in $(ls src/_locales/); do - string_length=$(jq '.extName.message | length' src/_locales/$locale/messages.json) - if [[ $string_length -gt 40 ]]; then + + for locale_path in src/_locales/*/messages.json; do + locale=$(basename "$(dirname "$locale_path")") + string_length=$(jq '.extName.message | length' "$locale_path") + if [ "$string_length" -gt 40 ]; then echo "$locale: $string_length" found_error=true fi @@ -126,15 +132,15 @@ jobs: env: PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} run: | - mv browser-source.zip browser-source-$PACKAGE_VERSION.zip - mv dist-chrome.zip dist-chrome-$PACKAGE_VERSION.zip - mv dist-opera.zip dist-opera-$PACKAGE_VERSION.zip - mv dist-firefox.zip dist-firefox-$PACKAGE_VERSION.zip - mv dist-edge.zip dist-edge-$PACKAGE_VERSION.zip + mv browser-source.zip "browser-source-${PACKAGE_VERSION}.zip" + mv dist-chrome.zip "dist-chrome-${PACKAGE_VERSION}.zip" + mv dist-opera.zip "dist-opera-${PACKAGE_VERSION}.zip" + mv dist-firefox.zip "dist-firefox-${PACKAGE_VERSION}.zip" + mv dist-edge.zip "dist-edge-${PACKAGE_VERSION}.zip" - name: Create release if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0 + uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 with: artifacts: 'browser-source-${{ needs.setup.outputs.release_version }}.zip, dist-chrome-${{ needs.setup.outputs.release_version }}.zip, diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 2d7be2e186e..08045b8d3c7 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -29,7 +29,9 @@ jobs: release_version: ${{ steps.version.outputs.version }} steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Branch check if: ${{ inputs.release_type != 'Dry Run' }} @@ -78,7 +80,7 @@ jobs: - name: Create release if: ${{ inputs.release_type != 'Dry Run' }} - uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0 + uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 env: PKG_VERSION: ${{ needs.setup.outputs.release_version }} with: diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index bfd6115a1a9..7f87a1e5628 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -31,7 +31,9 @@ jobs: release_channel: ${{ steps.release_channel.outputs.channel }} steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Branch check if: ${{ github.event.inputs.release_type != 'Dry Run' }} @@ -55,18 +57,20 @@ jobs: - name: Get Version Channel id: release_channel + env: + VERSION: ${{ steps.version.outputs.version }} run: | - case "${{ steps.version.outputs.version }}" in + case "$VERSION" in *"alpha"*) - echo "channel=alpha" >> $GITHUB_OUTPUT + echo "channel=alpha" >> "$GITHUB_OUTPUT" echo "[!] We do not yet support 'alpha'" exit 1 ;; *"beta"*) - echo "channel=beta" >> $GITHUB_OUTPUT + echo "channel=beta" >> "$GITHUB_OUTPUT" ;; *) - echo "channel=latest" >> $GITHUB_OUTPUT + echo "channel=latest" >> "$GITHUB_OUTPUT" ;; esac @@ -92,10 +96,10 @@ jobs: env: PKG_VERSION: ${{ steps.version.outputs.version }} working-directory: apps/desktop/artifacts - run: mv Bitwarden-${{ env.PKG_VERSION }}-universal.pkg Bitwarden-${{ env.PKG_VERSION }}-universal.pkg.archive + run: mv "Bitwarden-${PKG_VERSION}-universal.pkg" "Bitwarden-${PKG_VERSION}-universal.pkg.archive" - name: Create Release - uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0 + uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 if: ${{ steps.release_channel.outputs.channel == 'latest' && github.event.inputs.release_type != 'Dry Run' }} env: PKG_VERSION: ${{ steps.version.outputs.version }} @@ -103,8 +107,10 @@ jobs: with: artifacts: "apps/desktop/artifacts/Bitwarden-${{ env.PKG_VERSION }}-amd64.deb, apps/desktop/artifacts/Bitwarden-${{ env.PKG_VERSION }}-x86_64.rpm, - apps/desktop/artifacts/Bitwarden-${{ env.PKG_VERSION }}-x64.freebsd, apps/desktop/artifacts/bitwarden_${{ env.PKG_VERSION }}_amd64.snap, + apps/desktop/artifacts/bitwarden_${{ env.PKG_VERSION }}_arm64.snap, + apps/desktop/artifacts/bitwarden_${{ env.PKG_VERSION }}_arm64.tar.gz, + apps/desktop/artifacts/bitwarden_${{ env.PKG_VERSION }}_x64.tar.gz, apps/desktop/artifacts/Bitwarden-${{ env.PKG_VERSION }}-x86_64.AppImage, apps/desktop/artifacts/Bitwarden-Portable-${{ env.PKG_VERSION }}.exe, apps/desktop/artifacts/Bitwarden-Installer-${{ env.PKG_VERSION }}.exe, diff --git a/.github/workflows/release-web.yml b/.github/workflows/release-web.yml index 5a3c29d29fc..fc0ac340234 100644 --- a/.github/workflows/release-web.yml +++ b/.github/workflows/release-web.yml @@ -25,7 +25,9 @@ jobs: tag_version: ${{ steps.version.outputs.tag }} steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Branch check if: ${{ github.event.inputs.release_type != 'Dry Run' }} @@ -50,8 +52,7 @@ jobs: release: name: Create GitHub Release runs-on: ubuntu-22.04 - needs: - - setup + needs: setup permissions: contents: write steps: @@ -79,13 +80,15 @@ jobs: - name: Rename assets working-directory: apps/web/artifacts + env: + RELEASE_VERSION: ${{ needs.setup.outputs.release_version }} run: | - mv web-*-selfhosted-COMMERCIAL.zip web-${{ needs.setup.outputs.release_version }}-selfhosted-COMMERCIAL.zip - mv web-*-selfhosted-open-source.zip web-${{ needs.setup.outputs.release_version }}-selfhosted-open-source.zip + mv web-*-selfhosted-COMMERCIAL.zip "web-${RELEASE_VERSION}-selfhosted-COMMERCIAL.zip" + mv web-*-selfhosted-open-source.zip "web-${RELEASE_VERSION}-selfhosted-open-source.zip" - name: Create release if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0 + uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 with: name: "Web v${{ needs.setup.outputs.release_version }}" commit: ${{ github.sha }} diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index ecb8e448a8a..faf119cce2b 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -57,7 +57,7 @@ jobs: BRANCH="rc" fi - echo "branch=$BRANCH" >> $GITHUB_OUTPUT + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" bump_version: name: Bump Version @@ -97,17 +97,18 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3 + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - name: Check out branch - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: main token: ${{ steps.app-token.outputs.token }} + persist-credentials: true - name: Configure Git run: | @@ -124,7 +125,7 @@ jobs: id: current-browser-version run: | CURRENT_VERSION=$(cat package.json | jq -r '.version') - echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" working-directory: apps/browser - name: Browser - Verify input version @@ -140,8 +141,7 @@ jobs: fi # Check if version is newer. - printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V - if [ $? -eq 0 ]; then + if printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V; then echo "Version check successful." else echo "Version check failed." @@ -161,14 +161,14 @@ jobs: id: bump-browser-version-override env: VERSION: ${{ inputs.version_number_override }} - run: npm version --workspace=@bitwarden/browser $VERSION + run: npm version --workspace=@bitwarden/browser "$VERSION" - name: Bump Browser Version - Automatic Calculation if: ${{ inputs.bump_browser == true && inputs.version_number_override == '' }} id: bump-browser-version-automatic env: VERSION: ${{ steps.calculate-next-browser-version.outputs.version }} - run: npm version --workspace=@bitwarden/browser $VERSION + run: npm version --workspace=@bitwarden/browser "$VERSION" - name: Bump Browser Version - Manifest - Version Override if: ${{ inputs.bump_browser == true && inputs.version_number_override != '' }} @@ -211,7 +211,7 @@ jobs: id: current-cli-version run: | CURRENT_VERSION=$(cat package.json | jq -r '.version') - echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" working-directory: apps/cli - name: CLI - Verify input version @@ -227,8 +227,7 @@ jobs: fi # Check if version is newer. - printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V - if [ $? -eq 0 ]; then + if printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V; then echo "Version check successful." else echo "Version check failed." @@ -248,14 +247,14 @@ jobs: id: bump-cli-version-override env: VERSION: ${{ inputs.version_number_override }} - run: npm version --workspace=@bitwarden/cli $VERSION + run: npm version --workspace=@bitwarden/cli "$VERSION" - name: Bump CLI Version - Automatic Calculation if: ${{ inputs.bump_cli == true && inputs.version_number_override == '' }} id: bump-cli-version-automatic env: VERSION: ${{ steps.calculate-next-cli-version.outputs.version }} - run: npm version --workspace=@bitwarden/cli $VERSION + run: npm version --workspace=@bitwarden/cli "$VERSION" ### Desktop - name: Get current Desktop version @@ -263,7 +262,7 @@ jobs: id: current-desktop-version run: | CURRENT_VERSION=$(cat package.json | jq -r '.version') - echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" working-directory: apps/desktop - name: Desktop - Verify input version @@ -279,8 +278,7 @@ jobs: fi # Check if version is newer. - printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V - if [ $? -eq 0 ]; then + if printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V; then echo "Version check successful." else echo "Version check failed." @@ -300,27 +298,27 @@ jobs: id: bump-desktop-version-override env: VERSION: ${{ inputs.version_number_override }} - run: npm version --workspace=@bitwarden/desktop $VERSION + run: npm version --workspace=@bitwarden/desktop "$VERSION" - name: Bump Desktop Version - Root - Automatic Calculation if: ${{ inputs.bump_desktop == true && inputs.version_number_override == '' }} id: bump-desktop-version-automatic env: VERSION: ${{ steps.calculate-next-desktop-version.outputs.version }} - run: npm version --workspace=@bitwarden/desktop $VERSION + run: npm version --workspace=@bitwarden/desktop "$VERSION" - name: Bump Desktop Version - App - Version Override if: ${{ inputs.bump_desktop == true && inputs.version_number_override != '' }} env: VERSION: ${{ inputs.version_number_override }} - run: npm version $VERSION + run: npm version "$VERSION" working-directory: "apps/desktop/src" - name: Bump Desktop Version - App - Automatic Calculation if: ${{ inputs.bump_desktop == true && inputs.version_number_override == '' }} env: VERSION: ${{ steps.calculate-next-desktop-version.outputs.version }} - run: npm version $VERSION + run: npm version "$VERSION" working-directory: "apps/desktop/src" ### Web @@ -329,7 +327,7 @@ jobs: id: current-web-version run: | CURRENT_VERSION=$(cat package.json | jq -r '.version') - echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" working-directory: apps/web - name: Web - Verify input version @@ -345,8 +343,7 @@ jobs: fi # Check if version is newer. - printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V - if [ $? -eq 0 ]; then + if printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V; then echo "Version check successful." else echo "Version check failed." @@ -366,14 +363,14 @@ jobs: id: bump-web-version-override env: VERSION: ${{ inputs.version_number_override }} - run: npm version --workspace=@bitwarden/web-vault $VERSION + run: npm version --workspace=@bitwarden/web-vault "$VERSION" - name: Bump Web Version - Automatic Calculation if: ${{ inputs.bump_web == true && inputs.version_number_override == '' }} id: bump-web-version-automatic env: VERSION: ${{ steps.calculate-next-web-version.outputs.version }} - run: npm version --workspace=@bitwarden/web-vault $VERSION + run: npm version --workspace=@bitwarden/web-vault "$VERSION" ######################## @@ -381,38 +378,50 @@ jobs: id: set-final-version-output env: VERSION: ${{ inputs.version_number_override }} + _BUMP_BROWSER_VERSION_OVERRIDE_OUTCOME: ${{ steps.bump-browser-version-override.outcome }} + _BUMP_BROWSER_VERSION_AUTOMATIC_OUTCOME: ${{ steps.bump-browser-version-automatic.outcome }} + _CALCULATE_NEXT_BROWSER_VERSION: ${{ steps.calculate-next-browser-version.outputs.version }} + _BUMP_CLI_VERSION_OVERRIDE_OUTCOME: ${{ steps.bump-cli-version-override.outcome }} + _BUMP_CLI_VERSION_AUTOMATIC_OUTCOME: ${{ steps.bump-cli-version-automatic.outcome }} + _CALCULATE_NEXT_CLI_VERSION: ${{ steps.calculate-next-cli-version.outputs.version }} + _BUMP_DESKTOP_VERSION_OVERRIDE_OUTCOME: ${{ steps.bump-desktop-version-override.outcome }} + _BUMP_DESKTOP_VERSION_AUTOMATIC_OUTCOME: ${{ steps.bump-desktop-version-automatic.outcome }} + _CALCULATE_NEXT_DESKTOP_VERSION: ${{ steps.calculate-next-desktop-version.outputs.version }} + _BUMP_WEB_VERSION_OVERRIDE_OUTCOME: ${{ steps.bump-web-version-override.outcome }} + _BUMP_WEB_VERSION_AUTOMATIC_OUTCOME: ${{ steps.bump-web-version-automatic.outcome }} + _CALCULATE_NEXT_WEB_VERSION: ${{ steps.calculate-next-web-version.outputs.version }} run: | - if [[ "${{ steps.bump-browser-version-override.outcome }}" = "success" ]]; then - echo "version_browser=$VERSION" >> $GITHUB_OUTPUT - elif [[ "${{ steps.bump-browser-version-automatic.outcome }}" = "success" ]]; then - echo "version_browser=${{ steps.calculate-next-browser-version.outputs.version }}" >> $GITHUB_OUTPUT + if [[ "$_BUMP_BROWSER_VERSION_OVERRIDE_OUTCOME" = "success" ]]; then + echo "version_browser=$VERSION" >> "$GITHUB_OUTPUT" + elif [[ "$_BUMP_BROWSER_VERSION_AUTOMATIC_OUTCOME" = "success" ]]; then + echo "version_browser=$_CALCULATE_NEXT_BROWSER_VERSION" >> "$GITHUB_OUTPUT" fi - if [[ "${{ steps.bump-cli-version-override.outcome }}" = "success" ]]; then - echo "version_cli=$VERSION" >> $GITHUB_OUTPUT - elif [[ "${{ steps.bump-cli-version-automatic.outcome }}" = "success" ]]; then - echo "version_cli=${{ steps.calculate-next-cli-version.outputs.version }}" >> $GITHUB_OUTPUT + if [[ "$_BUMP_CLI_VERSION_OVERRIDE_OUTCOME" = "success" ]]; then + echo "version_cli=$VERSION" >> "$GITHUB_OUTPUT" + elif [[ "$_BUMP_CLI_VERSION_AUTOMATIC_OUTCOME" = "success" ]]; then + echo "version_cli=$_CALCULATE_NEXT_CLI_VERSION" >> "$GITHUB_OUTPUT" fi - if [[ "${{ steps.bump-desktop-version-override.outcome }}" = "success" ]]; then - echo "version_desktop=$VERSION" >> $GITHUB_OUTPUT - elif [[ "${{ steps.bump-desktop-version-automatic.outcome }}" = "success" ]]; then - echo "version_desktop=${{ steps.calculate-next-desktop-version.outputs.version }}" >> $GITHUB_OUTPUT + if [[ "$_BUMP_DESKTOP_VERSION_OVERRIDE_OUTCOME" = "success" ]]; then + echo "version_desktop=$VERSION" >> "$GITHUB_OUTPUT" + elif [[ "$_BUMP_DESKTOP_VERSION_AUTOMATIC_OUTCOME" = "success" ]]; then + echo "version_desktop=$_CALCULATE_NEXT_DESKTOP_VERSION" >> "$GITHUB_OUTPUT" fi - if [[ "${{ steps.bump-web-version-override.outcome }}" = "success" ]]; then - echo "version_web=$VERSION" >> $GITHUB_OUTPUT - elif [[ "${{ steps.bump-web-version-automatic.outcome }}" = "success" ]]; then - echo "version_web=${{ steps.calculate-next-web-version.outputs.version }}" >> $GITHUB_OUTPUT + if [[ "$_BUMP_WEB_VERSION_OVERRIDE_OUTCOME" = "success" ]]; then + echo "version_web=$VERSION" >> "$GITHUB_OUTPUT" + elif [[ "$_BUMP_WEB_VERSION_AUTOMATIC_OUTCOME" = "success" ]]; then + echo "version_web=$_CALCULATE_NEXT_WEB_VERSION" >> "$GITHUB_OUTPUT" fi - name: Check if version changed id: version-changed run: | if [ -n "$(git status --porcelain)" ]; then - echo "changes_to_commit=TRUE" >> $GITHUB_OUTPUT + echo "changes_to_commit=TRUE" >> "$GITHUB_OUTPUT" else - echo "changes_to_commit=FALSE" >> $GITHUB_OUTPUT + echo "changes_to_commit=FALSE" >> "$GITHUB_OUTPUT" echo "No changes to commit!"; fi @@ -453,24 +462,25 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3 + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - name: Check out target ref - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ inputs.target_ref }} token: ${{ steps.app-token.outputs.token }} + persist-credentials: true - name: Check if ${{ needs.setup.outputs.branch }} branch exists env: BRANCH_NAME: ${{ needs.setup.outputs.branch }} run: | - if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then - echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY + if [[ $(git ls-remote --heads origin "$BRANCH_NAME") ]]; then + echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> "$GITHUB_STEP_SUMMARY" exit 1 fi @@ -478,5 +488,5 @@ jobs: env: BRANCH_NAME: ${{ needs.setup.outputs.branch }} run: | - git switch --quiet --create $BRANCH_NAME - git push --quiet --set-upstream origin $BRANCH_NAME + git switch --quiet --create "$BRANCH_NAME" + git push --quiet --set-upstream origin "$BRANCH_NAME" diff --git a/.github/workflows/respond.yml b/.github/workflows/respond.yml new file mode 100644 index 00000000000..d940ceee756 --- /dev/null +++ b/.github/workflows/respond.yml @@ -0,0 +1,28 @@ +name: Respond + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +permissions: {} + +jobs: + respond: + name: Respond + uses: bitwarden/gh-actions/.github/workflows/_respond.yml@main + secrets: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + permissions: + actions: read + contents: write + id-token: write + issues: write + pull-requests: write diff --git a/.github/workflows/retrieve-current-desktop-rollout.yml b/.github/workflows/retrieve-current-desktop-rollout.yml index c45453ed9d0..30aef41e649 100644 --- a/.github/workflows/retrieve-current-desktop-rollout.yml +++ b/.github/workflows/retrieve-current-desktop-rollout.yml @@ -39,10 +39,10 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.aws-electron-access-key }} AWS_DEFAULT_REGION: 'us-west-2' AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.aws-electron-bucket-name }} - run: aws s3 cp $AWS_S3_BUCKET_NAME/desktop/latest.yml . --quiet + run: aws s3 cp "$AWS_S3_BUCKET_NAME/desktop/latest.yml" . --quiet - name: Get current rollout percentage run: | CURRENT_PCT=$(sed -r -n "s/stagingPercentage:\s([0-9]+)/\1/p" latest.yml) CURRENT_VERSION=$(sed -r -n "s/version:\s(.*)/\1/p" latest.yml) - echo "Desktop ${CURRENT_VERSION} rollout percentage is ${CURRENT_PCT}%" >> $GITHUB_STEP_SUMMARY + echo "Desktop ${CURRENT_VERSION} rollout percentage is ${CURRENT_PCT}%" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/review-code.yml b/.github/workflows/review-code.yml new file mode 100644 index 00000000000..0e0597fccf0 --- /dev/null +++ b/.github/workflows/review-code.yml @@ -0,0 +1,21 @@ +name: Code Review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: {} + +jobs: + review: + name: Review + uses: bitwarden/gh-actions/.github/workflows/_review-code.yml@main + secrets: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + permissions: + actions: read + contents: read + id-token: write + pull-requests: write diff --git a/.github/workflows/sdk-breaking-change-check.yml b/.github/workflows/sdk-breaking-change-check.yml new file mode 100644 index 00000000000..14547b3942f --- /dev/null +++ b/.github/workflows/sdk-breaking-change-check.yml @@ -0,0 +1,166 @@ +# This workflow runs TypeScript compatibility checks when the SDK is updated. +# Triggered automatically by the SDK repository via workflow_dispatch when SDK PRs are created/updated. +name: SDK Breaking Change Check +run-name: "SDK breaking change check (${{ github.event.inputs.sdk_version }})" +on: + workflow_dispatch: + inputs: + sdk_version: + description: "SDK version being tested" + required: true + type: string + source_repo: + description: "Source repository" + required: true + type: string + artifacts_run_id: + description: "Artifacts run ID" + required: true + type: string + artifact_name: + description: "Artifact name" + required: true + type: string + +permissions: + contents: read + actions: read + id-token: write + +jobs: + type-check: + name: TypeScript compatibility check + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + _SOURCE_REPO: ${{ github.event.inputs.source_repo }} + _SDK_VERSION: ${{ github.event.inputs.sdk_version }} + _ARTIFACTS_RUN_ID: ${{ github.event.inputs.artifacts_run_id }} + _ARTIFACT_NAME: ${{ github.event.inputs.artifact_name }} + + steps: + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + - name: Get Azure Key Vault secrets + id: get-kv-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: gh-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" + + - name: Generate GH App token + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + 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-actions: read # for reading and downloading the artifacts for a workflow run + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + + - name: Check out clients repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + cache: 'npm' + cache-dependency-path: '**/package-lock.json' + node-version: ${{ steps.retrieve-node-version.outputs.node_version }} + + - name: Install Node dependencies + run: | + echo "📦 Installing Node dependencies with retry logic..." + + RETRY_COUNT=0 + MAX_RETRIES=3 + while [ ${RETRY_COUNT} -lt ${MAX_RETRIES} ]; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + echo "🔄 npm ci attempt ${RETRY_COUNT} of ${MAX_RETRIES}..." + + if npm ci; then + echo "✅ npm ci successful" + break + else + echo "❌ npm ci attempt ${RETRY_COUNT} failed" + [ ${RETRY_COUNT} -lt ${MAX_RETRIES} ] && sleep 5 + fi + done + + if [ ${RETRY_COUNT} -eq ${MAX_RETRIES} ]; then + echo "::error::npm ci failed after ${MAX_RETRIES} attempts" + exit 1 + fi + + - name: Download SDK artifacts + uses: bitwarden/gh-actions/download-artifacts@main + with: + github_token: ${{ steps.app-token.outputs.token }} + workflow: build-wasm-internal.yml + workflow_conclusion: success + run_id: ${{ env._ARTIFACTS_RUN_ID }} + artifacts: ${{ env._ARTIFACT_NAME }} + repo: ${{ env._SOURCE_REPO }} + path: ./sdk-internal + if_no_artifact_found: fail + + - name: Override SDK using npm link + working-directory: ./ + run: | + echo "🔧 Setting up SDK override using npm link..." + echo "📊 SDK Version: ${_SDK_VERSION}" + echo "📦 Artifact Source: ${_SOURCE_REPO} run ${_ARTIFACTS_RUN_ID}" + + echo "📋 SDK package contents:" + ls -la ./sdk-internal/ + + echo "🔗 Creating npm link to SDK package..." + if ! npm link ./sdk-internal; then + echo "::error::Failed to link SDK package" + exit 1 + fi + + - name: Run TypeScript compatibility check + run: | + + echo "🔍 Running TypeScript type checking with SDK version: ${_SDK_VERSION}" + echo "🎯 Type checking command: npm run test:types" + + # Add GitHub Step Summary output + echo "## 📊 TypeScript Compatibility Check" >> $GITHUB_STEP_SUMMARY + echo "- **SDK Version**: ${_SDK_VERSION}" >> $GITHUB_STEP_SUMMARY + echo "- **Source Repository**: ${_SOURCE_REPO}" >> $GITHUB_STEP_SUMMARY + echo "- **Artifacts Run ID**: ${_ARTIFACTS_RUN_ID}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + TYPE_CHECK_START=$(date +%s) + + # Run type check with timeout - exit code determines gh run watch result + if timeout 10m npm run test:types; then + TYPE_CHECK_END=$(date +%s) + TYPE_CHECK_DURATION=$((TYPE_CHECK_END - TYPE_CHECK_START)) + echo "✅ TypeScript compilation successful (${TYPE_CHECK_DURATION}s)" + echo "✅ **Result**: TypeScript compilation successful" >> $GITHUB_STEP_SUMMARY + echo "No breaking changes detected for SDK version ${_SDK_VERSION}" >> $GITHUB_STEP_SUMMARY + else + TYPE_CHECK_END=$(date +%s) + TYPE_CHECK_DURATION=$((TYPE_CHECK_END - TYPE_CHECK_START)) + echo "❌ TypeScript compilation failed after ${TYPE_CHECK_DURATION}s - breaking changes detected" + echo "❌ **Result**: TypeScript compilation failed" >> $GITHUB_STEP_SUMMARY + echo "Breaking changes detected for SDK version ${_SDK_VERSION}" >> $GITHUB_STEP_SUMMARY + exit 1 + fi diff --git a/.github/workflows/staged-rollout-desktop.yml b/.github/workflows/staged-rollout-desktop.yml index 4adf81100bd..3d4f0376b39 100644 --- a/.github/workflows/staged-rollout-desktop.yml +++ b/.github/workflows/staged-rollout-desktop.yml @@ -47,11 +47,11 @@ jobs: AWS_DEFAULT_REGION: 'us-west-2' AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.aws-electron-bucket-name }} run: | - aws s3 cp $AWS_S3_BUCKET_NAME/desktop/latest.yml . \ + aws s3 cp "$AWS_S3_BUCKET_NAME/desktop/latest.yml" . \ --quiet - aws s3 cp $AWS_S3_BUCKET_NAME/desktop/latest-linux.yml . \ + aws s3 cp "$AWS_S3_BUCKET_NAME/desktop/latest-linux.yml" . \ --quiet - aws s3 cp $AWS_S3_BUCKET_NAME/desktop/latest-mac.yml . \ + aws s3 cp "$AWS_S3_BUCKET_NAME/desktop/latest-mac.yml" . \ --quiet - name: Check new rollout percentage @@ -86,11 +86,11 @@ jobs: AWS_DEFAULT_REGION: 'us-west-2' AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.aws-electron-bucket-name }} run: | - aws s3 cp latest.yml $AWS_S3_BUCKET_NAME/desktop/ \ + aws s3 cp latest.yml "$AWS_S3_BUCKET_NAME/desktop/" \ --acl "public-read" - aws s3 cp latest-linux.yml $AWS_S3_BUCKET_NAME/desktop/ \ + aws s3 cp latest-linux.yml "$AWS_S3_BUCKET_NAME/desktop/" \ --acl "public-read" - aws s3 cp latest-mac.yml $AWS_S3_BUCKET_NAME/desktop/ \ + aws s3 cp latest-mac.yml "$AWS_S3_BUCKET_NAME/desktop/" \ --acl "public-read" diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml index 13acde2b0fc..246e0d48c5d 100644 --- a/.github/workflows/stale-bot.yml +++ b/.github/workflows/stale-bot.yml @@ -15,7 +15,7 @@ jobs: pull-requests: write steps: - name: 'Run stale action' - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 + uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 with: stale-issue-label: 'needs-reply' stale-pr-label: 'needs-changes' diff --git a/.github/workflows/test-browser-interactions.yml b/.github/workflows/test-browser-interactions.yml index c6427b2e0d8..dfc0f28b9c6 100644 --- a/.github/workflows/test-browser-interactions.yml +++ b/.github/workflows/test-browser-interactions.yml @@ -11,15 +11,17 @@ jobs: check-files: name: Check files runs-on: ubuntu-22.04 + if: ${{ github.event.workflow_run.conclusion == 'success' }} permissions: actions: write contents: read id-token: write steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 + persist-credentials: false - name: Check for job requirements if: ${{ !github.event.workflow_run.pull_requests || !github.event.workflow_run.head_branch }} @@ -47,6 +49,8 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token + # NOTE: versions of actions/create-github-app-token after 2.0.3 break this workflow + # Remediation is tracked in https://bitwarden.atlassian.net/browse/PM-28174 uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3 id: app-token with: @@ -71,7 +75,7 @@ jobs: - name: Trigger test-all workflow in browser-interactions-testing if: steps.changed-files.outputs.monitored == 'true' - uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0 + uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0 with: token: ${{ steps.app-token.outputs.token }} repository: "bitwarden/browser-interactions-testing" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 64c4e0dff13..f53bfc39d36 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,17 +24,19 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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 + echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -60,7 +62,7 @@ jobs: run: npm test -- --coverage --maxWorkers=3 - name: Report test results - uses: dorny/test-reporter@6e6a65b7a0bd2c9197df7d0ae36ac5cee784230c # v2.0.0 + uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 # v2.1.1 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: name: Test Results @@ -69,10 +71,10 @@ jobs: fail-on-error: true - name: Upload results to codecov.io - uses: codecov/test-results-action@f2dba722c67b86c6caa034178c6e4d35335f6706 # v1.1.0 + uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 - name: Upload test coverage - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: jest-coverage path: ./coverage/lcov.info @@ -101,7 +103,9 @@ jobs: sudo apt-get install -y gnome-keyring dbus-x11 - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Build working-directory: ./apps/desktop/desktop_native @@ -125,15 +129,17 @@ jobs: - name: Test Windows if: ${{ matrix.os=='windows-2022'}} - working-directory: ./apps/desktop/desktop_native/core - run: cargo test -- --test-threads=1 + working-directory: ./apps/desktop/desktop_native + run: cargo test --workspace --exclude=desktop_napi -- --test-threads=1 rust-coverage: name: Rust Coverage runs-on: macos-14 steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Install rust uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable @@ -142,7 +148,7 @@ jobs: components: llvm-tools - name: Cache cargo registry - uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2.7.5 + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 with: workspaces: "apps/desktop/desktop_native -> target" @@ -154,7 +160,7 @@ jobs: run: cargo llvm-cov --all-features --lcov --output-path lcov.info --workspace --no-cfg-coverage - name: Upload test coverage - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: rust-coverage path: ./apps/desktop/desktop_native/lcov.info @@ -167,22 +173,24 @@ jobs: - rust-coverage steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Download jest coverage - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: jest-coverage path: ./ - name: Download rust coverage - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: rust-coverage path: ./apps/desktop/desktop_native - name: Upload coverage to codecov.io - uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2 + uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 with: files: | ./lcov.info diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml index 3cb5646886a..65f004149de 100644 --- a/.github/workflows/version-auto-bump.yml +++ b/.github/workflows/version-auto-bump.yml @@ -31,17 +31,19 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3 + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 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 committing and pushing to the current branch - name: Check out target ref - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: main token: ${{ steps.app-token.outputs.token }} + persist-credentials: true - name: Configure Git run: | @@ -52,7 +54,7 @@ jobs: id: current-desktop-version run: | CURRENT_VERSION=$(cat package.json | jq -r '.version') - echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" working-directory: apps/desktop - name: Calculate next Desktop release version @@ -65,12 +67,12 @@ jobs: id: bump-desktop-version-automatic env: VERSION: ${{ steps.calculate-next-desktop-version.outputs.version }} - run: npm version --workspace=@bitwarden/desktop $VERSION + run: npm version --workspace=@bitwarden/desktop "$VERSION" - name: Bump Desktop Version - App - Automatic Calculation env: VERSION: ${{ steps.calculate-next-desktop-version.outputs.version }} - run: npm version $VERSION + run: npm version "$VERSION" working-directory: "apps/desktop/src" - name: Commit files diff --git a/.gitignore b/.gitignore index 34f047f17a6..a2beeb61a08 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ Thumbs.db *.launch .settings/ *.sublime-workspace -.claude +.serena # Visual Studio Code .vscode/* @@ -27,6 +27,7 @@ npm-debug.log # Build directories dist build +target .angular/cache .flatpak .flatpak-repo diff --git a/.npmrc b/.npmrc index 421cf18217d..38a7eb153c0 100644 --- a/.npmrc +++ b/.npmrc @@ -1,4 +1,4 @@ save-exact=true # Increase available heap size to avoid running out of memory when compiling. # This applies to all npm scripts in this repository. -node-options=--max-old-space-size=8192 \ No newline at end of file +node-options=--max-old-space-size=8192 diff --git a/.storybook/format-args-for-code-snippet.ts b/.storybook/format-args-for-code-snippet.ts index bf36c153c0a..8fc44ebfb06 100644 --- a/.storybook/format-args-for-code-snippet.ts +++ b/.storybook/format-args-for-code-snippet.ts @@ -25,6 +25,11 @@ export const formatArgsForCodeSnippet = `'${v}'`).join(", "); return `[${key}]="[${formattedArray}]"`; } + + if (typeof value === "number") { + return `[${key}]="${value}"`; + } + return `${key}="${value}"`; }) .join(" "); diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 00000000000..85b8b839182 --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 59b5287f3a3..0b14f9d7444 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -4,11 +4,12 @@ import { componentWrapperDecorator } from "@storybook/angular"; import type { Preview } from "@storybook/angular"; import docJson from "../documentation.json"; + setCompodocJson(docJson); const wrapperDecorator = componentWrapperDecorator((story) => { return /*html*/ ` -
+
${story}
`; diff --git a/apps/browser/CLAUDE.md b/apps/browser/CLAUDE.md new file mode 100644 index 00000000000..a718f5bfd7c --- /dev/null +++ b/apps/browser/CLAUDE.md @@ -0,0 +1,22 @@ +# Browser Extension - Critical Rules + +- **NEVER** use `chrome.*` or `browser.*` APIs directly in business logic + - Always use `BrowserApi` abstraction: `/apps/browser/src/platform/browser/browser-api.ts` + - Required for cross-browser compatibility (Chrome/Firefox/Safari/Opera) + +- **ALWAYS** use `BrowserApi.addListener()` for event listeners in popup context + - Safari requires manual cleanup to prevent memory leaks + - DON'T use native `chrome.*.addListener()` or `browser.*.addListener()` directly + +- **CRITICAL**: Safari has tab query bugs + - Use `BrowserApi.tabsQueryFirstCurrentWindowForSafari()` when querying current window tabs + - Safari can return tabs from multiple windows incorrectly + +## Manifest V3 + +- Extension uses Web Extension API Manifest V3 +- **Service workers replace background pages** + - Background context runs as service worker (can be terminated anytime) + - DON'T assume background page persists indefinitely + - Use message passing for communication between contexts + - `chrome.extension.getBackgroundPage()` returns `null` in MV3 diff --git a/apps/browser/package.json b/apps/browser/package.json index 24a53f43f66..cf2be624a22 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,11 +1,13 @@ { "name": "@bitwarden/browser", - "version": "2025.9.0", + "version": "2025.12.0", "scripts": { "build": "npm run build:chrome", "build:bit": "npm run build:bit:chrome", "build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack", "build:bit:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js", + "build:dev:chrome": "npm run build:chrome && npm run update:dev:chrome", + "build:bit:dev:chrome": "npm run build:bit:chrome && npm run update:dev:chrome", "build:edge": "cross-env BROWSER=edge MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack", "build:bit:edge": "cross-env BROWSER=edge MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js", "build:firefox": "cross-env BROWSER=firefox NODE_OPTIONS=\"--max-old-space-size=8192\" webpack", @@ -55,9 +57,12 @@ "dist:bit:opera:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:bit:opera", "dist:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:safari", "dist:bit:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:bit:safari", + "package:dev:chrome": "npm run update:dev:chrome && ./scripts/compress.sh dev-chrome.zip", + "package:bit:dev:chrome": "npm run update:dev:chrome && ./scripts/compress.sh bit-dev-chrome.zip", "test": "jest", "test:watch": "jest --watch", "test:watch:all": "jest --watchAll", - "test:clearCache": "jest --clear-cache" + "test:clearCache": "jest --clear-cache", + "update:dev:chrome": "./scripts/update-manifest-dev.sh" } } diff --git a/apps/browser/project.json b/apps/browser/project.json new file mode 100644 index 00000000000..e0297df773b --- /dev/null +++ b/apps/browser/project.json @@ -0,0 +1,383 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "browser", + "projectType": "application", + "sourceRoot": "apps/browser/src", + "tags": ["scope:browser", "type:app"], + "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "chrome-dev", + "options": { + "outputPath": "dist/apps/browser", + "webpackConfig": "apps/browser/webpack.config.js", + "tsConfig": "apps/browser/tsconfig.json", + "main": "apps/browser/src/popup/main.ts", + "target": "web", + "compiler": "tsc" + }, + "configurations": { + "chrome": { + "mode": "production", + "outputPath": "dist/apps/browser/chrome", + "env": { + "BROWSER": "chrome", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "chrome-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/chrome-dev", + "env": { + "BROWSER": "chrome", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "edge": { + "mode": "production", + "outputPath": "dist/apps/browser/edge", + "env": { + "BROWSER": "edge", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "edge-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/edge-dev", + "env": { + "BROWSER": "edge", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "firefox": { + "mode": "production", + "outputPath": "dist/apps/browser/firefox", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "firefox-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/firefox-dev", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "firefox-mv2": { + "mode": "production", + "outputPath": "dist/apps/browser/firefox-mv2", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "2", + "NODE_ENV": "production" + } + }, + "firefox-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/firefox-mv2-dev", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + }, + "opera": { + "mode": "production", + "outputPath": "dist/apps/browser/opera", + "env": { + "BROWSER": "opera", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "opera-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/opera-dev", + "env": { + "BROWSER": "opera", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "safari": { + "mode": "production", + "outputPath": "dist/apps/browser/safari", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "safari-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/safari-dev", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "safari-mv2": { + "mode": "production", + "outputPath": "dist/apps/browser/safari-mv2", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "2", + "NODE_ENV": "production" + } + }, + "safari-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/safari-mv2-dev", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + }, + "commercial-chrome": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-chrome", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "chrome", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "commercial-chrome-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-chrome-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "chrome", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-edge": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-edge", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "edge", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "commercial-edge-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-edge-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "edge", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-firefox": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-firefox", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "commercial-firefox-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-firefox-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-firefox-mv2": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-firefox-mv2", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "2", + "NODE_ENV": "production" + } + }, + "commercial-firefox-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-firefox-mv2-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + }, + "commercial-opera": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-opera", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "opera", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "commercial-opera-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-opera-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "opera", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-safari": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-safari", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "commercial-safari-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-safari-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-safari-mv2": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-safari-mv2", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "2", + "NODE_ENV": "production" + } + }, + "commercial-safari-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-safari-mv2-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + } + } + }, + "serve": { + "executor": "nx:run-commands", + "defaultConfiguration": "chrome-dev", + "options": { + "cwd": "apps/browser" + }, + "configurations": { + "chrome-dev": { + "command": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack --watch --output-path=../../dist/apps/browser/chrome-dev" + }, + "firefox-dev": { + "command": "cross-env BROWSER=firefox MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack --watch --output-path=../../dist/apps/browser/firefox-dev" + }, + "firefox-mv2-dev": { + "command": "cross-env BROWSER=firefox MANIFEST_VERSION=2 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack --watch --output-path=../../dist/apps/browser/firefox-mv2-dev" + }, + "safari-dev": { + "command": "cross-env BROWSER=safari MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack --watch --output-path=../../dist/apps/browser/safari-dev" + }, + "safari-mv2-dev": { + "command": "cross-env BROWSER=safari MANIFEST_VERSION=2 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack --watch --output-path=../../dist/apps/browser/safari-mv2-dev" + }, + "edge-dev": { + "command": "cross-env BROWSER=edge MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack --watch --output-path=../../dist/apps/browser/edge-dev" + }, + "opera-dev": { + "command": "cross-env BROWSER=opera MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack --watch --output-path=../../dist/apps/browser/opera-dev" + }, + "commercial-chrome-dev": { + "command": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js --watch --output-path=../../dist/apps/browser/commercial-chrome-dev" + }, + "commercial-firefox-dev": { + "command": "cross-env BROWSER=firefox MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js --watch --output-path=../../dist/apps/browser/commercial-firefox-dev" + }, + "commercial-firefox-mv2-dev": { + "command": "cross-env BROWSER=firefox MANIFEST_VERSION=2 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js --watch --output-path=../../dist/apps/browser/commercial-firefox-mv2-dev" + }, + "commercial-safari-dev": { + "command": "cross-env BROWSER=safari MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js --watch --output-path=../../dist/apps/browser/commercial-safari-dev" + }, + "commercial-safari-mv2-dev": { + "command": "cross-env BROWSER=safari MANIFEST_VERSION=2 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js --watch --output-path=../../dist/apps/browser/commercial-safari-mv2-dev" + }, + "commercial-edge-dev": { + "command": "cross-env BROWSER=edge MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js --watch --output-path=../../dist/apps/browser/commercial-edge-dev" + }, + "commercial-opera-dev": { + "command": "cross-env BROWSER=opera MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js --watch --output-path=../../dist/apps/browser/commercial-opera-dev" + } + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/browser/jest.config.js" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/browser/**/*.ts", "apps/browser/**/*.html"] + } + } + } +} diff --git a/apps/browser/scripts/update-manifest-dev.sh b/apps/browser/scripts/update-manifest-dev.sh new file mode 100755 index 00000000000..2823d4cb510 --- /dev/null +++ b/apps/browser/scripts/update-manifest-dev.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +#### +# Update the manifest key in the build directory. +#### + +set -e +set -u +set -x +set -o pipefail + +SCRIPT_ROOT="$(dirname "$0")" +BUILD_DIR="$SCRIPT_ROOT/../build" + +# Check if build directory exists +if [ -d "$BUILD_DIR" ]; then + cd "$BUILD_DIR" + + # Update manifest with dev public key + MANIFEST_PATH="./manifest.json" + + # Generated arbitrary public key from Chrome Dev Console to pin side-loaded extension IDs during development + DEV_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuIvjtsAVWZM0i5jFhSZcrmwgaf3KWcxM5F16LNDNeivC1EqJ+H5xNZ5R9UN5ueHA2xyyYAOlxY07OcY6CKTGJRJyefbUhszb66sdx26SV5gVkCois99fKBlsbSbd6und/BJYmoFUWvFCNNVH+OxLMqMQWjMMhM2ItLqTYi7dxRE5qd+7LwQpnGG2vTkm/O7nu8U3CtkfcIAGLsiTd7/iuytcMDnC0qFM5tJyY/5I+9QOhpUJ7Ybj3C18BDWDORhqxutWv+MSw//SgUn2/lPQrnrKq7FIVQL7FxxEPqkv4QwFvaixps1cBbMdJ1Ygit1z5JldoSyNxzCa5vVcJLecMQIDAQAB' + + MANIFEST_PATH_TMP="${MANIFEST_PATH}.tmp" + if jq --arg key "$DEV_PUBLIC_KEY" '.key = $key' "$MANIFEST_PATH" > "$MANIFEST_PATH_TMP"; then + mv "$MANIFEST_PATH_TMP" "$MANIFEST_PATH" + echo "Updated manifest key in $MANIFEST_PATH" + else + echo "ERROR: Failed to update manifest with jq" + rm -f "$MANIFEST_PATH_TMP" + exit 1 + fi +fi diff --git a/apps/browser/spec/mock-port.spec-util.ts b/apps/browser/spec/mock-port.spec-util.ts index b5f7825d8e9..39239ba8817 100644 --- a/apps/browser/spec/mock-port.spec-util.ts +++ b/apps/browser/spec/mock-port.spec-util.ts @@ -12,6 +12,13 @@ export function mockPorts() { (chrome.runtime.connect as jest.Mock).mockImplementation((portInfo) => { const port = mockDeep(); port.name = portInfo.name; + port.sender = { url: chrome.runtime.getURL("") }; + + // convert to internal port + delete (port as any).tab; + delete (port as any).documentId; + delete (port as any).documentLifecycle; + delete (port as any).frameId; // set message broadcast (port.postMessage as jest.Mock).mockImplementation((message) => { diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index 397ea877cb5..03d5eb0a9f6 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "استخدام تسجيل الدخول الأحادي" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "مرحبًا بعودتك" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "تعديل" }, "view": { "message": "عرض" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "كلمة المرور الرئيسية غير صالحة" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "نفذ وقت الخزانة" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "عند قفل النظام" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "عند إعادة تشغيل المتصفح" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "تم تعديل العنصر" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "هل تريد حقاً أن ترسل إلى سلة المهملات؟" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "اسأل عن القياسات الحيوية عند الإطلاق" }, - "premiumRequired": { - "message": "حساب البريميوم مطلوب" - }, - "premiumRequiredDesc": { - "message": "هذه المِيزة متاحة فقط للعضوية المميزة." - }, "authenticationTimeout": { "message": "مهلة المصادقة" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "قراءة مفتاح الأمان" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "في انتظار التفاعل مع مفتاح الأمن..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "يجب عليك إضافة رابط الخادم الأساسي أو على الأقل بيئة مخصصة." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "بيئة مخصصة" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "إظهار اقتراحات التعبئة التلقائية في حقول النموذج" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "عرض الهويات كاقتراحات" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "سنة الإنتهاء" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "تاريخ الانتهاء" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "تعيين كلمة مرور رئيسية" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "خطأ" }, "decryptionError": { "message": "خطأ فك التشفير" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "تعذر على بتواردن فك تشفير العنصر (العناصر) المدرجة أدناه." }, @@ -3970,6 +4071,15 @@ "message": "ملء تلقائي عند تعيين تحميل الصفحة لاستخدام الإعداد الافتراضي.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 639e6c87d36..7dbd1ba3e7c 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Vahid daxil olma üsulunu istifadə et" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Təşkilatınız, vahid daxil olma tələb edir." + }, "welcomeBack": { "message": "Yenidən xoş gəlmisiniz" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Axtarışı sıfırla" }, - "archive": { - "message": "Arxivlə" + "archiveNoun": { + "message": "Arxiv", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Arxivlə", + "description": "Verb" + }, + "unArchive": { "message": "Arxivdən çıxart" }, "itemsInArchive": { @@ -565,10 +573,10 @@ "noItemsInArchiveDesc": { "message": "Arxivlənmiş elementlər burada görünəcək, ümumi axtarış nəticələrindən və avto-doldurma təkliflərindən xaric ediləcək." }, - "itemSentToArchive": { + "itemWasSentToArchive": { "message": "Element arxivə göndərildi" }, - "itemRemovedFromArchive": { + "itemUnarchived": { "message": "Element arxivdən çıxarıldı" }, "archiveItem": { @@ -577,12 +585,24 @@ "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?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Düzəliş et" }, "view": { "message": "Bax" }, + "viewAll": { + "message": "Hamısına bax" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "Daha azına bax" + }, "viewLogin": { "message": "Girişə bax" }, @@ -614,7 +634,7 @@ "message": "Element sevimlilərə əlavə edildi" }, "itemRemovedFromFavorites": { - "message": "Element sevimlilərdən çıxarıldı" + "message": "Element sevimlilərdən xaric edildi" }, "notes": { "message": "Notlar" @@ -674,7 +694,7 @@ "message": "Ayarlarda bir kilid açma üsulu qurun" }, "sessionTimeoutHeader": { - "message": "Seans vaxt bitməsi" + "message": "Sessiya vaxt bitməsi" }, "vaultTimeoutHeader": { "message": "Seyf vaxtının bitməsi" @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Yararsız ana parol" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Yararsız ana parol. E-poçtunuzun doğru olduğunu və hesabınızın $HOST$ üzərində yaradıldığını təsdiqləyin.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Seyf vaxtının bitməsi" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Sistem kilidlənəndə" }, + "onIdle": { + "message": "Sistem boşda olduqda" + }, + "onSleep": { + "message": "Sistem yuxu rejimində olduqda" + }, "onRestart": { "message": "Brauzer yenidən başladılanda" }, @@ -896,7 +931,7 @@ "message": "Hesabınızdan çıxış etmisiniz." }, "loginExpired": { - "message": "Giriş seansınızın müddəti bitdi." + "message": "Giriş sessiyanızın müddəti bitdi." }, "logIn": { "message": "Giriş et" @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Element saxlanıldı" }, + "savedWebsite": { + "message": "Saxlanılan veb sayt" + }, + "savedWebsites": { + "message": "Saxlanılan veb sayt ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Həqiqətən tullantı qutusuna göndərmək istəyirsiniz?" }, @@ -1207,7 +1254,7 @@ "description": "Detailed error message shown when saving login details fails." }, "changePasswordWarning": { - "message": "Parolunuzu dəyişdirdikdən sonra yeni parolunuzla giriş etməli olacaqsınız. Digər cihazlardakı aktiv seanslar bir saat ərzində çıxış sonlandırılacaq." + "message": "Parolunuzu dəyişdirdikdən sonra yeni parolunuzla giriş etməli olacaqsınız. Digər cihazlardakı aktiv sessiyalar bir saat ərzində çıxış sonlandırılacaq." }, "accountRecoveryUpdateMasterPasswordSubtitle": { "message": "Hesabın geri qaytarılması prosesini tamamlamaq üçün ana parolunuzu dəyişdirin." @@ -1491,17 +1538,11 @@ "enableAutoBiometricsPrompt": { "message": "Açılışda biometrik soruşulsun" }, - "premiumRequired": { - "message": "Premium üzvlük lazımdır" - }, - "premiumRequiredDesc": { - "message": "Bu özəlliyi istifadə etmək üçün premium üzvlük lazımdır." - }, "authenticationTimeout": { "message": "Kimlik doğrulama vaxtı bitdi" }, "authenticationSessionTimedOut": { - "message": "Kimlik doğrulama seansının vaxtı bitdi. Lütfən giriş prosesini yenidən başladın." + "message": "Kimlik doğrulama sessiyasının vaxtı bitdi. Lütfən giriş prosesini yenidən başladın." }, "verificationCodeEmailSent": { "message": "Doğrulama poçtu $EMAIL$ ünvanına göndərildi.", @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Güvənlik açarını oxu" }, + "readingPasskeyLoading": { + "message": "Keçid açarı oxunur..." + }, + "passkeyAuthenticationFailed": { + "message": "Keçid açarı kimlik doğrulaması uğursuzdur" + }, + "useADifferentLogInMethod": { + "message": "Fərqli bir giriş üsulu istifadə edin" + }, "awaitingSecurityKeyInteraction": { "message": "Güvənlik açarı ilə əlaqə gözlənilir..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Təməl server URL-sini və ya ən azı bir özəl mühiti əlavə etməlisiniz." }, + "selfHostedEnvMustUseHttps": { + "message": "URL-lər, HTTPS istifadə etməlidir." + }, "customEnvironment": { "message": "Özəl mühit" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Avto-doldurmanı söndür" }, + "confirmAutofill": { + "message": "Avto-doldurmanı təsdiqlə" + }, + "confirmAutofillDesc": { + "message": "Bu sayt, saxlanılmış giriş məlumatlarınızla uyuşmur. Giriş məlumatlarınızı doldurmazdan əvvəl, güvənli sayt olduğuna əmin olun." + }, "showInlineMenuLabel": { "message": "Avto-doldurma təkliflərini form xanalarında göstər" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Bitwarden verilərinizi fişinqdən necə qoruyur?" + }, + "currentWebsite": { + "message": "Hazırkı veb sayt" + }, + "autofillAndAddWebsite": { + "message": "Avto-doldur və bu veb saytı əlavə et" + }, + "autofillWithoutAdding": { + "message": "Əlavə etmədən avto-doldur" + }, + "doNotAutofill": { + "message": "Avto-doldurulmasın" + }, "showInlineMenuIdentitiesLabel": { "message": "Kimlikləri təklif kimi göstər" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Son istifadə ili" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Bitmə vaxtı" }, @@ -2090,7 +2167,7 @@ } }, "passwordSafe": { - "message": "Bu parol, veri pozuntularında qeydə alınmayıb. Rahatlıqla istifadə edə bilərsiniz." + "message": "Bu parol, veri pozuntularında qeydə alınmayıb. Əmniyyətlə istifadə edə bilərsiniz." }, "baseDomain": { "message": "Baza domeni", @@ -2160,7 +2237,7 @@ "message": "Təzəlikcə heç nə yaratmamısınız" }, "remove": { - "message": "Çıxart" + "message": "Xaric et" }, "default": { "message": "İlkin" @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Ana parolu ayarla" }, @@ -2999,10 +3079,10 @@ "message": "Ana parolu güncəllə" }, "updateMasterPasswordWarning": { - "message": "Ana parolunuz təzəlikcə təşkilatınızdakı bir inzibatçı tərəfindən dəyişdirildi. Seyfə erişmək üçün onu indi güncəlləməlisiniz. Davam etsəniz, hazırkı seansdan çıxış edəcəksiniz və təkrar giriş etməli olacaqsınız. Digər cihazlardakı aktiv seanslar bir saata qədər aktiv qalmağa davam edə bilər." + "message": "Ana parolunuz təzəlikcə təşkilatınızdakı bir inzibatçı tərəfindən dəyişdirildi. Seyfə erişmək üçün onu indi güncəlləməlisiniz. Davam etsəniz, hazırkı sessiyadan çıxış edəcəksiniz və təkrar giriş etməli olacaqsınız. Digər cihazlardakı aktiv sessiyalar bir saata qədər aktiv qalmağa davam edə bilər." }, "updateWeakMasterPasswordWarning": { - "message": "Ana parolunuz təşkilatınızdakı siyasətlərdən birinə və ya bir neçəsinə uyğun gəlmir. Seyfə erişmək üçün ana parolunuzu indi güncəlləməlisiniz. Davam etsəniz, hazırkı seansdan çıxış etmiş və təkrar giriş etməli olacaqsınız. Digər cihazlardakı aktiv seanslar bir saata qədər aktiv qalmağa davam edə bilər." + "message": "Ana parolunuz təşkilatınızdakı siyasətlərdən birinə və ya bir neçəsinə uyğun gəlmir. Seyfə erişmək üçün ana parolunuzu indi güncəlləməlisiniz. Davam etsəniz, hazırkı sessiyadan çıxış etmiş və təkrar giriş etməli olacaqsınız. Digər cihazlardakı aktiv sessiyalar bir saata qədər aktiv qalmağa davam edə bilər." }, "tdeDisabledMasterPasswordRequired": { "message": "Təşkilatınız, güvənli cihaz şifrələməsini sıradan çıxartdı. Seyfinizə erişmək üçün lütfən ana parol təyin edin." @@ -3158,7 +3238,7 @@ "message": "Simvol sayını dəyişdir" }, "sessionTimeout": { - "message": "Seansınızın vaxtı bitdi. Lütfən geri qayıdıb yenidən giriş etməyə cəhd edin." + "message": "Sessiyanızın vaxtı bitdi. Lütfən geri qayıdıb yenidən giriş etməyə cəhd edin." }, "exportingPersonalVaultTitle": { "message": "Fərdi seyfin xaricə köçürülməsi" @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Yalnız $ORGANIZATION$ ilə əlaqələndirilmiş təşkilat seyfi xaricə köçürüləcək.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Yalnız $ORGANIZATION$ ilə əlaqələndirilmiş təşkilat seyfi xaricə köçürüləcək. Element kolleksiyalarım daxil edilməyəcək.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Xəta" }, "decryptionError": { "message": "Şifrə açma xətası" }, + "errorGettingAutoFillData": { + "message": "Avto-doldurma verilərini alma xətası" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden, aşağıda sadalanan seyf element(lər)inin şifrəsini aça bilmədi." }, @@ -3643,7 +3744,7 @@ "message": "Cihazları idarə et" }, "currentSession": { - "message": "Hazırkı seans" + "message": "Hazırkı sessiya" }, "mobile": { "message": "Mobil", @@ -3840,7 +3941,7 @@ "message": "İstifadəçiyə güvən" }, "sendsTitleNoItems": { - "message": "Send, həssas məlumatlar təhlükəsizdir", + "message": "Send ilə həssas məlumatlar əmniyyətdədir", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsBodyNoItems": { @@ -3970,6 +4071,15 @@ "message": "\"Səhifə yüklənəndə avto-doldurma\" özəlliyi ilkin ayarı istifadə etmək üzrə ayarlandı.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Avto-doldurula bilmir" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Oldu" + }, "toggleSideNavigation": { "message": "Yan naviqasiyanı aç/bağla" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Ödənişsiz təşkilatlar qoşmaları istifadə edə bilməz" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "İlkin ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "$WEBSITE$ ilə uyuşma aşkarlamasını göstər", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Seyfinizə xoş gəlmisiniz!" }, - "phishingPageTitle": { - "message": "Fişinq veb sayt" + "phishingPageTitleV2": { + "message": "Fişinq cəhdi aşkarlandı" }, - "phishingPageCloseTab": { - "message": "Vərəqi bağla" + "phishingPageSummary": { + "message": "Ziyarət etməyə cəhd etdiyiniz sayt, zərərli sayt kimi tanınır və təhlükəsizlik baxımından risklidir." }, - "phishingPageContinue": { - "message": "Davam" + "phishingPageCloseTabV2": { + "message": "Bu vərəqi bağla" }, - "phishingPageLearnWhy": { - "message": "Bunu niyə görürürsünüz?" + "phishingPageContinueV2": { + "message": "Bu sayta davam et (tövsiyə olunmur)" + }, + "phishingPageExplanation1": { + "message": "Bu sayt burada üzə çıxdı ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": "Bu mənbədə fərdi və həssas məlumatların oğurlanması üçün istifadə olunan məlum fişinq saytların açıq mənbəli siyahısıdır.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Fişinq aşkarlaması haqqında daha ətraflı" + }, + "protectedBy": { + "message": "$PRODUCT$ tərəfindən qorunur", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Hazırkı səhifə üçün elementləri avto-doldur" @@ -5590,7 +5733,7 @@ "message": "Kimliklərinizlə, uzun qeydiyyat və ya əlaqə xanalarını daha tez avtomatik doldurun." }, "newNoteNudgeTitle": { - "message": "Həssas verilərinizi güvənli şəkildə saxlayın" + "message": "Həssas verilərinizi əmniyyətdə saxlayın" }, "newNoteNudgeBody": { "message": "Notlarla, bankçılıq və ya sığorta təfsilatları kimi həssas veriləri təhlükəsiz saxlayın." @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Key Connector domenini təsdiqlə" + }, + "atRiskLoginsSecured": { + "message": "Riskli girişlərinizi güvənli hala gətirməyiniz əladır!" + }, + "upgradeNow": { + "message": "İndi yüksəlt" + }, + "builtInAuthenticator": { + "message": "Daxili kimlik doğrulayıcı" + }, + "secureFileStorage": { + "message": "Güvənli fayl anbarı" + }, + "emergencyAccess": { + "message": "Fövqəladə hal erişimi" + }, + "breachMonitoring": { + "message": "Pozuntu monitorinqi" + }, + "andMoreFeatures": { + "message": "Və daha çoxu!" + }, + "planDescPremium": { + "message": "Tam onlayn təhlükəsizlik" + }, + "upgradeToPremium": { + "message": "\"Premium\"a yüksəlt" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Premium-u kəşf et" + }, + "loadingVault": { + "message": "Seyf yüklənir" + }, + "vaultLoaded": { + "message": "Seyf yükləndi" + }, + "settingDisabledByPolicy": { + "message": "Bu ayar, təşkilatınızın siyasəti tərəfindən sıradan çıxarılıb.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Poçt kodu" + }, + "cardNumberLabel": { + "message": "Kart nömrəsi" + }, + "sessionTimeoutSettingsAction": { + "message": "Vaxt bitmə əməliyyatı" } } diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 44c82ef85b4..89651f0038e 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Выкарыстаць аднаразовы ўваход" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "З вяртаннем" }, @@ -189,7 +192,7 @@ "message": "Скапіяваць нататкі" }, "copy": { - "message": "Copy", + "message": "Скапіяваць", "description": "Copy to clipboard" }, "fill": { @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Рэдагаваць" }, "view": { "message": "Прагляд" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Памылковы асноўны пароль" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Час чакання сховішча" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Пры блакіраванні сістэмы" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Пры перазапуску браўзера" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Элемент адрэдагаваны" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Вы сапраўды хочаце адправіць гэты элемент у сметніцу?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Пытацца пра біяметрыю пры запуску" }, - "premiumRequired": { - "message": "Патрабуецца прэміяльны статус" - }, - "premiumRequiredDesc": { - "message": "Для выкарыстання гэтай функцыі патрабуецца прэміяльны статус." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Карыстальніцкае асяроддзе" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Год завяршэння" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Тэрмін дзеяння" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Прызначыць асноўны пароль" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Памылка" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index a440690cee1..b40f4b91cb4 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Използване на еднократна идентификация" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Вашата организация изисква еднократно удостоверяване." + }, "welcomeBack": { "message": "Добре дошли отново" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Нулиране на търсенето" }, - "archive": { - "message": "Архивиране" + "archiveNoun": { + "message": "Архив", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Архивиране", + "description": "Verb" + }, + "unArchive": { "message": "Изваждане от архива" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Архивираните елементи ще се показват тук и ще бъдат изключени от общите резултати при търсене и от предложенията за автоматично попълване." }, - "itemSentToArchive": { - "message": "Елементът е преместен в архива" + "itemWasSentToArchive": { + "message": "Елементът беше преместен в архива" }, - "itemRemovedFromArchive": { - "message": "Елементът е изваден от архива" + "itemUnarchived": { + "message": "Елементът беше изваден от архива" }, "archiveItem": { "message": "Архивиране на елемента" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Архивираните елементи са изключени от общите резултати при търсене и от предложенията за автоматично попълване. Наистина ли искате да архивирате този елемент?" }, + "upgradeToUseArchive": { + "message": "За да се възползвате от архивирането, трябва да ползвате платен абонамент." + }, "edit": { "message": "Редактиране" }, "view": { "message": "Преглед" }, + "viewAll": { + "message": "Показване на всички" + }, + "showAll": { + "message": "Показване на всички" + }, + "viewLess": { + "message": "Преглед на по-малко" + }, "viewLogin": { "message": "Преглед на елемента за вписване" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Грешна главна парола" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Грешна главна парола. Проверете дали е-пощата е правилна и дали акаунтът Ви е създаден в $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Време за достъп" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "При заключване на системата" }, + "onIdle": { + "message": "При бездействие на системата" + }, + "onSleep": { + "message": "При заспиване на системата" + }, "onRestart": { "message": "При повторно пускане на браузъра" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Елементът е редактиран" }, + "savedWebsite": { + "message": "Запазен уеб сайт" + }, + "savedWebsites": { + "message": "Запазени уеб сайтове ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Наистина ли искате да изтриете елемента?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Питане за биометрични данни при пускане" }, - "premiumRequired": { - "message": "Изисква се платен абонамент" - }, - "premiumRequiredDesc": { - "message": "За да се възползвате от тази възможност, трябва да ползвате платен абонамент." - }, "authenticationTimeout": { "message": "Време на давност за удостоверяването" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Прочитане на ключа за сигурност" }, + "readingPasskeyLoading": { + "message": "Прочитане на секретния ключ…" + }, + "passkeyAuthenticationFailed": { + "message": "Удостоверяването чрез секретен ключ беше неуспешно" + }, + "useADifferentLogInMethod": { + "message": "Използване на друг метод на вписване" + }, "awaitingSecurityKeyInteraction": { "message": "Изчакване на действие с ключ за сигурност…" }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Трябва да добавите или основния адрес на сървъра, или поне една специална среда." }, + "selfHostedEnvMustUseHttps": { + "message": "Адресите трябва да ползват HTTPS." + }, "customEnvironment": { "message": "Специална среда" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Изключване на автоматичното попълване" }, + "confirmAutofill": { + "message": "Потвърждаване на автоматичното попълване" + }, + "confirmAutofillDesc": { + "message": "Този уеб сайт не съвпада със запазените данни за вписване. Преди да попълните данните си, уверете се, че имате вяра на сайта." + }, "showInlineMenuLabel": { "message": "Показване на предложения за авт. попълване на полетата във формуляри" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Как Битуорден защитава данните Ви от измами?" + }, + "currentWebsite": { + "message": "Текущ уеб сайт" + }, + "autofillAndAddWebsite": { + "message": "Автоматично попълване и добавяне на този уеб сайт" + }, + "autofillWithoutAdding": { + "message": "Автоматично попълване без добавяне" + }, + "doNotAutofill": { + "message": "Да не се попълва автоматично" + }, "showInlineMenuIdentitiesLabel": { "message": "Показване на идентичности като предложения" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Година на изтичане" }, + "monthly": { + "message": "месец" + }, "expiration": { "message": "Изтичане" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Тази страница пречи на работата на Битуорден. Вмъкнатото меню на Битуорден е временно изключено, като мярка за сигурност." + }, "setMasterPassword": { "message": "Задаване на главна парола" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Ще бъдат изнесени само записите от трезора свързан с $ORGANIZATION$.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Само трезорът свързан с $ORGANIZATION$ ще бъде експортиран. Моите записи няма да бъдат включени.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Грешка" }, "decryptionError": { "message": "Грешка при дешифриране" }, + "errorGettingAutoFillData": { + "message": "Грешка при получаването на данните за автоматично попълване" + }, "couldNotDecryptVaultItemsBelow": { "message": "Битоурден не може да дешифрира елементите от трезора посочени по-долу." }, @@ -3970,6 +4071,15 @@ "message": "Автоматичното попълване при зареждане на страницата използва настройката си по подразбиране.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Автоматичното попълване не може да бъде извършено" + }, + "cannotAutofillExactMatch": { + "message": "По подразбиране е зададена настройката „Точно съвпадение“. Текущият уеб сайт не съвпада точно със запазените данни за вход в този запис." + }, + "okay": { + "message": "Добре" + }, "toggleSideNavigation": { "message": "Превключване на страничната навигация" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Премиум" }, + "unlockFeaturesWithPremium": { + "message": "Отключете докладите, аварийния достъп и още функционалности свързани със сигурността, с платения план." + }, "freeOrgsCannotUseAttachments": { "message": "Безплатните организации не могат да използват прикачени файлове" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "По подразбиране ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Показване на откритото съвпадение $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Добре дошли в трезора си!" }, - "phishingPageTitle": { - "message": "Измамен уеб сайт" + "phishingPageTitleV2": { + "message": "Засечен е опит за измама" }, - "phishingPageCloseTab": { - "message": "Затваряне на раздела" + "phishingPageSummary": { + "message": "Уеб сайтът, който се опитвате да посетите, е известен като злонамерен и може да представлява риск за сигурността." }, - "phishingPageContinue": { - "message": "Продължаване" + "phishingPageCloseTabV2": { + "message": "Затваряне на този раздел" }, - "phishingPageLearnWhy": { - "message": "Защо виждате това?" + "phishingPageContinueV2": { + "message": "Продължаване към уеб сайта (не се препоръчва)" + }, + "phishingPageExplanation1": { + "message": "Този уеб сайт е открит в ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", отворен списък с известни измамни уеб сайтове, които могат да отмъкнат Вашите лични или поверителни данни.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Научете повече относно разпознаването на измамни уеб сайтове" + }, + "protectedBy": { + "message": "Защитено от $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Попълвайте автоматично елементи в текущата страница" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Потвърждаване на домейна на конектора за ключове" + }, + "atRiskLoginsSecured": { + "message": "Добра работа с подсигуряването на данните за вписване в риск!" + }, + "upgradeNow": { + "message": "Надграждане сега" + }, + "builtInAuthenticator": { + "message": "Вграден удостоверител" + }, + "secureFileStorage": { + "message": "Сигурно съхранение на файлове" + }, + "emergencyAccess": { + "message": "Авариен достъп" + }, + "breachMonitoring": { + "message": "Наблюдение за пробиви" + }, + "andMoreFeatures": { + "message": "И още!" + }, + "planDescPremium": { + "message": "Пълна сигурност в Интернет" + }, + "upgradeToPremium": { + "message": "Надградете до Платения план" + }, + "unlockAdvancedSecurity": { + "message": "Отключване на разширените функционалности по сигурността" + }, + "unlockAdvancedSecurityDesc": { + "message": "Платеният абонамент предоставя повече инструменти за защита и управление" + }, + "explorePremium": { + "message": "Разгледайте платения план" + }, + "loadingVault": { + "message": "Зареждане на трезора" + }, + "vaultLoaded": { + "message": "Трезорът е зареден" + }, + "settingDisabledByPolicy": { + "message": "Тази настройка е изключена съгласно политиката на организацията Ви.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Пощенски код" + }, + "cardNumberLabel": { + "message": "Номер на картата" + }, + "sessionTimeoutSettingsAction": { + "message": "Действие при изтичането на времето за достъп" } } diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index e7c4c36bce0..4f8e7054305 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -3,7 +3,7 @@ "message": "Bitwarden" }, "appLogoLabel": { - "message": "Bitwarden logo" + "message": "বিটওয়ার্ডেন লোগো" }, "extName": { "message": "Bitwarden Password Manager", @@ -23,7 +23,7 @@ "message": "অ্যাকাউন্ট তৈরি করুন" }, "newToBitwarden": { - "message": "New to Bitwarden?" + "message": "বিটওয়ার্ডেনে নতুন?" }, "logInWithPasskey": { "message": "Log in with passkey" @@ -31,8 +31,11 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { - "message": "Welcome back" + "message": "আবারও স্বাগতম" }, "setAStrongPassword": { "message": "Set a strong password" @@ -276,10 +279,10 @@ "message": "Send a verification code to your email" }, "sendCode": { - "message": "Send code" + "message": "কোড পাঠান" }, "codeSent": { - "message": "Code sent" + "message": "কোড পাঠানো হয়েছে" }, "verificationCode": { "message": "যাচাইকরণ কোড" @@ -300,7 +303,7 @@ "message": "Continue to Help Center?" }, "continueToHelpCenterDesc": { - "message": "Learn more about how to use Bitwarden on the Help Center." + "message": "সহায়তা কেন্দ্রে বিটওয়ার্ডেন কীভাবে ব্যবহার করতে হয় সে সম্পর্কে আরও জানুন।" }, "continueToBrowserExtensionStore": { "message": "Continue to browser extension store?" @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "সম্পাদনা" }, "view": { "message": "দেখুন" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "অবৈধ মূল পাসওয়ার্ড" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "ভল্টের সময়সীমা" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "সিস্টেম লকে" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "ব্রাউজার পুনঃসূচনাই" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "সম্পাদিত বস্তু" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "আপনি কি সত্যিই আবর্জনাতে পাঠাতে চান?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "প্রিমিয়াম আবশ্যক" - }, - "premiumRequiredDesc": { - "message": "এই বৈশিষ্ট্যটি ব্যবহার করতে একটি প্রিমিয়াম সদস্যতার প্রয়োজন।" - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "পছন্দসই পরিবেশ" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "মেয়াদোত্তীর্ণ বছর" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "মেয়াদোত্তীর্ণতা" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "মূল পাসওয়ার্ড ধার্য করুন" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index d9003a749a6..9d5631c47e2 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Edit" }, "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Vault timeout" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "Premium required" - }, - "premiumRequiredDesc": { - "message": "A Premium membership is required to use this feature." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 2002dfc467f..255263a6da7 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Inici de sessió únic" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Benvingut/da de nou" }, @@ -320,7 +323,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." }, "twoStepLogin": { - "message": "Inici de sessió en dues passes" + "message": "Inici de sessió en dos passos" }, "logOut": { "message": "Tanca la sessió" @@ -550,10 +553,15 @@ "resetSearch": { "message": "Restableix la cerca" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Edita" }, "view": { "message": "Visualitza" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Contrasenya mestra no vàlida" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Temps d'espera de la caixa forta" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "En bloquejar el sistema" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "En reiniciar el navegador" }, @@ -956,7 +991,7 @@ "message": "Carpeta afegida" }, "twoStepLoginConfirmation": { - "message": "L'inici de sessió en dues passes fa que el vostre compte siga més segur, ja que obliga a verificar el vostre inici de sessió amb un altre dispositiu, com ara una clau de seguretat, una aplicació autenticadora, un SMS, una trucada telefònica o un correu electrònic. Es pot habilitar l'inici de sessió en dues passes a la caixa forta web de bitwarden.com. Voleu visitar el lloc web ara?" + "message": "L'inici de sessió en dos passos fa que el vostre compte siga més segur, ja que obliga a verificar el vostre inici de sessió amb un altre dispositiu, com ara una clau de seguretat, una aplicació autenticadora, un SMS, una trucada telefònica o un correu electrònic. Es pot habilitar l'inici de sessió en dos passos a la caixa forta web de bitwarden.com. Voleu visitar el lloc web ara?" }, "twoStepLoginConfirmationContent": { "message": "Fes que el vostre compte siga més segur configurant l'inici de sessió en dos passos a l'aplicació web de Bitwarden." @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Element guardat" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Esteu segur que voleu suprimir aquest element?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Demaneu dades biometriques en iniciar" }, - "premiumRequired": { - "message": "Premium requerit" - }, - "premiumRequiredDesc": { - "message": "Cal una subscripció premium per utilitzar aquesta característica." - }, "authenticationTimeout": { "message": "Temps d'espera d'autenticació" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1541,13 +1591,13 @@ "message": "Inici de sessió no disponible" }, "noTwoStepProviders": { - "message": "Aquest compte té habilitat l'inici de sessió en dues passes, però aquest navegador web no admet cap dels dos proveïdors configurats." + "message": "Aquest compte té habilitat l'inici de sessió en dos passos, però aquest navegador web no admet cap dels dos proveïdors configurats." }, "noTwoStepProviders2": { "message": "Utilitzeu un navegador web compatible (com ara Chrome) o afegiu proveïdors addicionals que siguen compatibles amb tots els navegadors web (com una aplicació d'autenticació)." }, "twoStepOptions": { - "message": "Opcions d'inici de sessió en dues passes" + "message": "Opcions d'inici de sessió en dos passos" }, "selectTwoStepLoginMethod": { "message": "Select two-step login method" @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Entorn personalitzat" }, @@ -1636,13 +1689,13 @@ "message": "Suggeriments d'emplenament automàtic" }, "autofillSpotlightTitle": { - "message": "Easily find autofill suggestions" + "message": "Trobeu fàcilment suggeriments d'emplenament automàtic" }, "autofillSpotlightDesc": { - "message": "Turn off your browser's autofill settings, so they don't conflict with Bitwarden." + "message": "Desactiveu la configuració d'emplenament automàtic del vostre navegador perquè no entren en conflicte amb Bitwarden." }, "turnOffBrowserAutofill": { - "message": "Turn off $BROWSER$ autofill", + "message": "Desactiveu l'emplenament automàtic de $BROWSER$", "placeholders": { "browser": { "content": "$1", @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Mostra suggeriments d'emplenament automàtic als camps del formulari" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Mostra identitats com a suggeriments" }, @@ -1782,7 +1856,7 @@ "message": "Si feu clic a l'exterior de la finestra emergent per comprovar el vostre correu electrònic amb el codi de verificació, es tancarà aquesta finestra. Voleu obrir aquesta finestra emergent en una finestra nova perquè no es tanque?" }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Mostra les icones del lloc web i recupera els URL de canvi de contrasenya" }, "cardholderName": { "message": "Nom del titular de la targeta" @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Any de venciment" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Caducitat" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Estableix la contrasenya mestra" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Error de desxifrat" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden no ha pogut desxifrar els elements de la caixa forta que s'indiquen a continuació." }, @@ -3640,7 +3741,7 @@ "message": "Remember this device to make future logins seamless" }, "manageDevices": { - "message": "Manage devices" + "message": "Gestiona els dispositius" }, "currentSession": { "message": "Current session" @@ -3683,7 +3784,7 @@ "message": "Needs approval" }, "devices": { - "message": "Devices" + "message": "Dispositius" }, "accessAttemptBy": { "message": "Access attempt by $EMAIL$", @@ -3970,6 +4071,15 @@ "message": "S'ha configurat l'emplenament automàtic en carregar la pàgina perquè utilitze la configuració predeterminada.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Canvia a la navegació lateral" }, @@ -4772,22 +4882,22 @@ "message": "Download Bitwarden" }, "downloadBitwardenOnAllDevices": { - "message": "Download Bitwarden on all devices" + "message": "Baixa Bitwarden a tots els dispositius" }, "getTheMobileApp": { "message": "Get the mobile app" }, "getTheMobileAppDesc": { - "message": "Access your passwords on the go with the Bitwarden mobile app." + "message": "Accediu a les vostres contrasenyes des de qualsevol lloc amb l'aplicació mòbil Bitwarden." }, "getTheDesktopApp": { - "message": "Get the desktop app" + "message": "Obteniu l'aplicació d'escriptori" }, "getTheDesktopAppDesc": { - "message": "Access your vault without a browser, then set up unlock with biometrics to expedite unlocking in both the desktop app and browser extension." + "message": "Accediu a la vostra caixa forta sense navegador i, a continuació, configureu el desbloqueig amb biometria per accelerar el desbloqueig tant a l'aplicació d'escriptori com a l'extensió del navegador." }, "downloadFromBitwardenNow": { - "message": "Download from bitwarden.com now" + "message": "Baixeu ara des de bitwarden.com" }, "getItOnGooglePlay": { "message": "Get it on Google Play" @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5257,10 +5380,10 @@ "message": "Biometric unlock is currently unavailable for an unknown reason." }, "unlockVault": { - "message": "Unlock your vault in seconds" + "message": "Desbloqueja la caixa forta en segons" }, "unlockVaultDesc": { - "message": "You can customize your unlock and timeout settings to more quickly access your vault." + "message": "Podeu personalitzar la configuració de desbloqueig i temps d'espera per accedir més ràpidament a la vostra caixa forta." }, "unlockPinSet": { "message": "Unlock PIN set" @@ -5497,7 +5620,7 @@ "message": "The vault protects more than just your passwords. Store secure logins, IDs, cards and notes securely here." }, "introCarouselLabel": { - "message": "Welcome to Bitwarden" + "message": "Benvinguts a Bitwarden" }, "securityPrioritized": { "message": "Security, prioritized" @@ -5536,28 +5659,48 @@ "message": "Import now" }, "hasItemsVaultNudgeTitle": { - "message": "Welcome to your vault!" + "message": "Benvigut/da a la vostra caixa forta!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { - "message": "Autofill items for the current page" + "message": "Emplena automàticament els elements de la pàgina actual" }, "hasItemsVaultNudgeBodyTwo": { - "message": "Favorite items for easy access" + "message": "Elements preferits per accedir fàcilment" }, "hasItemsVaultNudgeBodyThree": { - "message": "Search your vault for something else" + "message": "Cerca altres coses a la caixa forta" }, "newLoginNudgeTitle": { "message": "Save time with autofill" @@ -5609,20 +5752,20 @@ "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "generatorNudgeTitle": { - "message": "Quickly create passwords" + "message": "Creeu contrasenyes ràpidament" }, "generatorNudgeBodyOne": { - "message": "Easily create strong and unique passwords by clicking on", + "message": "Creeu fàcilment contrasenyes fortes i úniques fent clic a", "description": "Two part message", "example": "Easily create strong and unique passwords by clicking on {icon} to help you keep your logins secure." }, "generatorNudgeBodyTwo": { - "message": "to help you keep your logins secure.", + "message": "per ajudar-vos a mantenir segurs els vostres inicis de sessió.", "description": "Two part message", "example": "Easily create strong and unique passwords by clicking on {icon} to help you keep your logins secure." }, "generatorNudgeBodyAria": { - "message": "Easily create strong and unique passwords by clicking on the Generate password button to help you keep your logins secure.", + "message": "Creeu fàcilment contrasenyes fortes i úniques fent clic al botó Genera contrasenya per ajudar-vos a mantenir segurs els vostres inicis de sessió.", "description": "Aria label for the body content of the generator nudge" }, "aboutThisSetting": { @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 0638257d687..eff2c6c0ea7 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Použít jednotné přihlášení" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Vaše organizace vyžaduje jednotné přihlášení." + }, "welcomeBack": { "message": "Vítejte zpět" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Resetovat hledání" }, - "archive": { - "message": "Archivovat" + "archiveNoun": { + "message": "Archiv", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archivovat", + "description": "Verb" + }, + "unArchive": { "message": "Odebrat z archivu" }, "itemsInArchive": { @@ -565,10 +573,10 @@ "noItemsInArchiveDesc": { "message": "Zde se zobrazí archivované položky a budou vyloučeny z obecných výsledků vyhledávání a návrhů automatického vyplňování." }, - "itemSentToArchive": { + "itemWasSentToArchive": { "message": "Položka byla přesunuta do archivu" }, - "itemRemovedFromArchive": { + "itemUnarchived": { "message": "Položka byla odebrána z archivu" }, "archiveItem": { @@ -577,12 +585,24 @@ "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?" }, + "upgradeToUseArchive": { + "message": "Pro použití funkce Archiv je potřebné prémiové členství." + }, "edit": { "message": "Upravit" }, "view": { "message": "Zobrazit" }, + "viewAll": { + "message": "Zobrazit vše" + }, + "showAll": { + "message": "Zobrazit vše" + }, + "viewLess": { + "message": "Zobrazit méně" + }, "viewLogin": { "message": "Zobrazit přihlašovací údaje" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Chybné hlavní heslo" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Neplatné hlavní heslo. Potvrďte správnost e-mailu a zda byl Váš účet vytvořen na $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Časový limit trezoru" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Při uzamknutí systému" }, + "onIdle": { + "message": "Při nečinnosti systému" + }, + "onSleep": { + "message": "Při přechodu do režimu spánku" + }, "onRestart": { "message": "Při restartu prohlížeče" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Položka byla uložena" }, + "savedWebsite": { + "message": "Uložená webová stránka" + }, + "savedWebsites": { + "message": "Uložené webové stránky ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Opravdu chcete položku přesunout do koše?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ověřit biometrické údaje při spuštění" }, - "premiumRequired": { - "message": "Je vyžadováno členství Premium" - }, - "premiumRequiredDesc": { - "message": "Pro použití této funkce je potřebné členství Premium." - }, "authenticationTimeout": { "message": "Časový limit ověření" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Přečíst bezpečnostní klíč" }, + "readingPasskeyLoading": { + "message": "Načítání přístupového klíče..." + }, + "passkeyAuthenticationFailed": { + "message": "Ověření přístupového klíče selhalo" + }, + "useADifferentLogInMethod": { + "message": "Použít jinou metodu přihlášení" + }, "awaitingSecurityKeyInteraction": { "message": "Čeká se na interakci s bezpečnostním klíčem..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Musíte přidat buď základní adresu URL serveru nebo alespoň jedno vlastní prostředí." }, + "selfHostedEnvMustUseHttps": { + "message": "URL adresy musí používat HTTPS." + }, "customEnvironment": { "message": "Vlastní prostředí" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Vypnout automatické vyplňování" }, + "confirmAutofill": { + "message": "Potvrdit automatické vyplňování" + }, + "confirmAutofillDesc": { + "message": "Tato stránka neodpovídá Vašim uloženým přihlašovacím údajům. Před vyplněním přihlašovacích údajů se ujistěte, že se jedná o důvěryhodný web." + }, "showInlineMenuLabel": { "message": "Zobrazit návrhy automatického vyplňování v polích formuláře" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Jak Bitwarden chrání Vaše data před phishingem?" + }, + "currentWebsite": { + "message": "Aktuální webová stránka" + }, + "autofillAndAddWebsite": { + "message": "Automatické vyplňování a přidání této stránky" + }, + "autofillWithoutAdding": { + "message": "Automatické vyplňování bez přidání" + }, + "doNotAutofill": { + "message": "Nevyplňovat automaticky" + }, "showInlineMenuIdentitiesLabel": { "message": "Zobrazit identity jako návrhy" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Rok expirace" }, + "monthly": { + "message": "měsíčně" + }, "expiration": { "message": "Expirace" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Tato stránka narušuje zážitek z Bitwardenu. Vložené menu Bitwarden bylo dočasně vypnuto jako bezpečnostní opatření." + }, "setMasterPassword": { "message": "Nastavit hlavní heslo" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Bude exportován jen trezor organizace přidružený k $ORGANIZATION$.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Bude exportován jen trezor organizace přidružený k $ORGANIZATION$. Položky mých sbírek nebudou zahrnuty.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Chyba" }, "decryptionError": { "message": "Chyba dešifrování" }, + "errorGettingAutoFillData": { + "message": "Chyba při načítání dat automatického vyplňování" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden nemohl dešifrovat níže uvedené položky v trezoru." }, @@ -3970,6 +4071,15 @@ "message": "Automatické vyplnění při načítání stránky bylo nastaveno na výchozí nastavení.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Nelze automaticky vyplňovat" + }, + "cannotAutofillExactMatch": { + "message": "Výchozí shoda je nastavena na \"Přesná shoda\". Aktuální web neodpovídá přesně uloženým přihlašovacím údajům pro tuto položku." + }, + "okay": { + "message": "OK" + }, "toggleSideNavigation": { "message": "Přepnout boční navigaci" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Odemkněte hlášení, nouzový přístup a další bezpečnostní funkce s předplatným Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Volné organizace nemohou používat přílohy" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Výchozí ($VALUE$)", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Zobrazit detekci shody $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Vítejte ve Vašem trezoru!" }, - "phishingPageTitle": { - "message": "Phishingové webová stránka" + "phishingPageTitleV2": { + "message": "Zjištěn pokus o phishing" }, - "phishingPageCloseTab": { - "message": "Zavřít kartu" + "phishingPageSummary": { + "message": "Stránka, kterou se pokoušíte navštívit, je známá škodlivá stránka a bezpečnostní riziko." }, - "phishingPageContinue": { - "message": "Pokračovat" + "phishingPageCloseTabV2": { + "message": "Zavřít tuto kartu" }, - "phishingPageLearnWhy": { - "message": "Proč to vidíte?" + "phishingPageContinueV2": { + "message": "Pokračovat na tuto stránku (nedoporučeno)" + }, + "phishingPageExplanation1": { + "message": "Tato stránka byla nalezena v ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", open-source seznamu známých phishingových stránek používaných pro krádež osobních a citlivých informací.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Další informace o detekci phishingu" + }, + "protectedBy": { + "message": "Chráněno produktem $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Položky automatického vyplňování aktuální stránky" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Potvrdit doménu Key Connectoru" + }, + "atRiskLoginsSecured": { + "message": "Skvělá práce při zabezpečení přihlašovacích údajů v ohrožení!" + }, + "upgradeNow": { + "message": "Aktualizovat nyní" + }, + "builtInAuthenticator": { + "message": "Vestavěný autentifikátor" + }, + "secureFileStorage": { + "message": "Zabezpečené úložiště souborů" + }, + "emergencyAccess": { + "message": "Nouzový přístup" + }, + "breachMonitoring": { + "message": "Sledování úniků" + }, + "andMoreFeatures": { + "message": "A ještě více!" + }, + "planDescPremium": { + "message": "Dokončit online zabezpečení" + }, + "upgradeToPremium": { + "message": "Aktualizovat na Premium" + }, + "unlockAdvancedSecurity": { + "message": "Odemknout pokročilé bezpečnostní funkce" + }, + "unlockAdvancedSecurityDesc": { + "message": "Prémiové předplatné Vám dává více nástrojů k bezpečí a kontrole" + }, + "explorePremium": { + "message": "Objevit Premium" + }, + "loadingVault": { + "message": "Načítání trezoru" + }, + "vaultLoaded": { + "message": "Trezor byl načten" + }, + "settingDisabledByPolicy": { + "message": "Toto nastavení je zakázáno zásadami Vaší organizace.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / PSČ" + }, + "cardNumberLabel": { + "message": "Číslo karty" + }, + "sessionTimeoutSettingsAction": { + "message": "Akce vypršení časového limitu" } } diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 8756a138e81..99fcdffcc97 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Croeso nôl" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Golygu" }, "view": { "message": "Gweld" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Prif gyfrinair annilys" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Cloi'r gell" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "wrth ailgychwyn y porwr" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Eitem wedi'i chadw" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Ydych chi wir eisiau anfon i'r sbwriel?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "Mae angen aelodaeth uwch" - }, - "premiumRequiredDesc": { - "message": "Mae angen aelodaeth uwch i ddefnyddio'r nodwedd hon." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Amgylchedd addasedig" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Blwyddyn dod i ben" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Dod i ben" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Gosod prif gyfrinair" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Gwall" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index a78ff26fb0f..865e6ff7dda 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Brug Single Sign-On" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Velkommen tilbage" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Redigér" }, "view": { "message": "Vis" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Ugyldig hovedadgangskode" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Boks timeout" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Når systemet låses" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Ved genstart af browseren" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Element gemt" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Er du sikker på, at du sende til papirkurven?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Bed om biometri ved start" }, - "premiumRequired": { - "message": "Premium påkrævet" - }, - "premiumRequiredDesc": { - "message": "Premium-medlemskab kræves for at anvende denne funktion." - }, "authenticationTimeout": { "message": "Godkendelsestimeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Der skal tilføjes enten basis server-URL'en eller mindst ét tilpasset miljø." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Brugerdefineret miljø" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Vis autoudfyld-menu i formularfelter" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Vis identiteter som forslag" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Udløbsår" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Udløb" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Indstil hovedadgangskode" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Fejl" }, "decryptionError": { "message": "Dekrypteringsfejl" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden kunne ikke dekryptere boks-emne(r) anført nedenfor." }, @@ -3970,6 +4071,15 @@ "message": "Autoudfyldning ved sideindlæsning sat til standardindstillingen.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Slå sidenavigering til/fra" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Gratis organisationer kan ikke bruge vedhæftninger" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Vis matchdetektion $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index f04ca5b11be..86f49bb875e 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -29,7 +29,10 @@ "message": "Mit Passkey anmelden" }, "useSingleSignOn": { - "message": "Single Sign-on verwenden" + "message": "Single Sign-On verwenden" + }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Deine Organisation erfordert Single Sign-On." }, "welcomeBack": { "message": "Willkommen zurück" @@ -41,7 +44,7 @@ "message": "Schließe die Erstellung deines Kontos ab, indem du ein Passwort festlegst" }, "enterpriseSingleSignOn": { - "message": "Enterprise Single-Sign-On" + "message": "Enterprise Single Sign-On" }, "cancel": { "message": "Abbrechen" @@ -550,32 +553,40 @@ "resetSearch": { "message": "Suche zurücksetzen" }, - "archive": { - "message": "Archivieren" + "archiveNoun": { + "message": "Archiv", + "description": "Noun" }, - "unarchive": { - "message": "Archivierung aufheben" + "archiveVerb": { + "message": "Archivieren", + "description": "Verb" + }, + "unArchive": { + "message": "Nicht mehr archivieren" }, "itemsInArchive": { "message": "Einträge im Archiv" }, "noItemsInArchive": { - "message": "Kein Eintrag im Archiv" + "message": "Keine Einträge im Archiv" }, "noItemsInArchiveDesc": { - "message": "Archivierte Einträge erscheinen hier und werden von allgemeinen Suchergebnissen und Autofill Vorschlägen ausgeschlossen." + "message": "Archivierte Einträge werden hier angezeigt und von allgemeinen Suchergebnissen sowie Vorschlägen zum automatischen Ausfüllen ausgeschlossen." }, - "itemSentToArchive": { - "message": "Eintrag an das Archiv gesendet" + "itemWasSentToArchive": { + "message": "Eintrag wurde ins Archiv verschoben" }, - "itemRemovedFromArchive": { - "message": "Eintrag aus dem Archiv entfernt" + "itemUnarchived": { + "message": "Eintrag wird nicht mehr archiviert" }, "archiveItem": { "message": "Eintrag archivieren" }, "archiveItemConfirmDesc": { - "message": "Archivierte Einträge sind von allgemeinen Suchergebnissen und Autofill Vorschlägen ausgeschlossen. Sind Sie sicher, dass Sie diesen Eintrag archivieren möchten?" + "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?" + }, + "upgradeToUseArchive": { + "message": "Für die Nutzung des Archivs ist eine Premium-Mitgliedschaft erforderlich." }, "edit": { "message": "Bearbeiten" @@ -583,8 +594,17 @@ "view": { "message": "Anzeigen" }, + "viewAll": { + "message": "Alles anzeigen" + }, + "showAll": { + "message": "Alles anzeigen" + }, + "viewLess": { + "message": "Weniger anzeigen" + }, "viewLogin": { - "message": "Login ansehen" + "message": "Zugangsdaten anzeigen" }, "noItemsInList": { "message": "Keine Einträge zum Anzeigen vorhanden." @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Ungültiges Master-Passwort" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Ungültiges Master-Passwort. Überprüfe, ob deine E-Mail-Adresse korrekt ist und dein Konto auf $HOST$ erstellt wurde.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Tresor-Timeout" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Wenn System gesperrt" }, + "onIdle": { + "message": "Bei Systeminaktivität" + }, + "onSleep": { + "message": "Im Standby" + }, "onRestart": { "message": "Bei Browser-Neustart" }, @@ -920,7 +955,7 @@ "message": "Folge den Schritten unten, um die Anmeldung abzuschließen." }, "followTheStepsBelowToFinishLoggingInWithSecurityKey": { - "message": "Folge den Schritten unten, um die Anmeldung mit deinem Sicherheitsschlüssel abzuschließen." + "message": "Folge den untenstehenden Schritten, um die Anmeldung mit deinem Sicherheitsschlüssel abzuschließen." }, "restartRegistration": { "message": "Registrierung neu starten" @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Eintrag gespeichert" }, + "savedWebsite": { + "message": "Website gespeichert" + }, + "savedWebsites": { + "message": "Gespeicherte Websites ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Soll dieser Eintrag wirklich in den Papierkorb verschoben werden?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Beim Start nach biometrischen Daten fragen" }, - "premiumRequired": { - "message": "Premium-Mitgliedschaft benötigt" - }, - "premiumRequiredDesc": { - "message": "Eine Premium-Mitgliedschaft ist für diese Funktion notwendig." - }, "authenticationTimeout": { "message": "Authentifizierungs-Timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Sicherheitsschlüssel auslesen" }, + "readingPasskeyLoading": { + "message": "Passkey wird gelesen..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey-Authentifizierung fehlgeschlagen" + }, + "useADifferentLogInMethod": { + "message": "Eine andere Anmeldemethode verwenden" + }, "awaitingSecurityKeyInteraction": { "message": "Warte auf Sicherheitsschlüssel-Interaktion..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Du musst entweder die Basis-Server-URL oder mindestens eine benutzerdefinierte Umgebung hinzufügen." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs müssen HTTPS verwenden." + }, "customEnvironment": { "message": "Benutzerdefinierte Umgebung" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Auto-Ausfüllen deaktivieren" }, + "confirmAutofill": { + "message": "Auto-Ausfüllen bestätigen" + }, + "confirmAutofillDesc": { + "message": "Diese Website stimmt nicht mit deinen gespeicherten Zugangsdaten überein. Bevor du deine Zugangsdaten eingibst, stelle sicher, dass es sich um eine vertrauenswürdige Website handelt." + }, "showInlineMenuLabel": { "message": "Vorschläge zum Auto-Ausfüllen in Formularfeldern anzeigen" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Wie schützt Bitwarden deine Daten vor Phishing?" + }, + "currentWebsite": { + "message": "Aktuelle Website" + }, + "autofillAndAddWebsite": { + "message": "Auto-Ausfüllen und diese Website hinzufügen" + }, + "autofillWithoutAdding": { + "message": "Auto-Ausfüllen ohne Hinzufügen" + }, + "doNotAutofill": { + "message": "Nicht automatisch ausfüllen" + }, "showInlineMenuIdentitiesLabel": { "message": "Identitäten als Vorschläge anzeigen" }, @@ -1782,7 +1856,7 @@ "message": "Dieses Pop-up Fenster wird geschlossen, wenn du außerhalb des Fensters klickst um in deinen E-Mails nach dem Verifizierungscode zu suchen. Möchtest du, dass dieses Pop-up in einem separaten Fenster geöffnet wird, damit es nicht geschlossen wird?" }, "showIconsChangePasswordUrls": { - "message": "Website Symbole anzeigen und URLs zum Ändern von Passwörtern abrufen" + "message": "Website-Symbole anzeigen und URLs zum Ändern von Passwörtern ermitteln" }, "cardholderName": { "message": "Name des Karteninhabers" @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Ablaufjahr" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Gültig bis" }, @@ -1967,11 +2044,11 @@ "description": "Header for new SSH key item type" }, "newItemHeaderTextSend": { - "message": "Neuen Text senden", + "message": "Neues Text-Send", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "Neue Datei senden", + "message": "Neues Datei-Send", "description": "Header for new file send" }, "editItemHeaderLogin": { @@ -1995,11 +2072,11 @@ "description": "Header for edit SSH key item type" }, "editItemHeaderTextSend": { - "message": "Textversand bearbeiten", + "message": "Text-Send bearbeiten", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Dateiversand bearbeiten", + "message": "Datei-Send bearbeiten", "description": "Header for edit file send" }, "viewItemHeaderLogin": { @@ -2227,7 +2304,7 @@ "message": "Gebe deinen PIN-Code für das Entsperren von Bitwarden ein. Deine PIN-Einstellungen werden zurückgesetzt, wenn du dich vollständig von der Anwendung abmeldest." }, "setPinCode": { - "message": "Du kannst diese PIN verwenden, um Bitwarden zu entsperren. Deine PIN wird zurückgesetzt, wenn du dich vollständig aus der Anwendung abmeldest." + "message": "Du kannst diese PIN verwenden, um Bitwarden zu entsperren. Deine PIN wird zurückgesetzt, wenn du dich einmal vollständig aus der Anwendung abmeldest." }, "pinRequired": { "message": "PIN-Code ist erforderlich." @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Diese Seite beeinträchtigt die Nutzung von Bitwarden. Das Bitwarden Inline-Menü wurde aus Sicherheitsgründen vorübergehend deaktiviert." + }, "setMasterPassword": { "message": "Master-Passwort festlegen" }, @@ -2645,7 +2725,7 @@ } }, "atRiskChangePrompt": { - "message": "Dein Passwort für diese Website ist gefährdet. $ORGANIZATION$ hat darum gebeten, dass du es änderst.", + "message": "Dein Passwort für diese Website ist gefährdet. $ORGANIZATION$ hat dich aufgefordert, es zu ändern.", "placeholders": { "organization": { "content": "$1", @@ -2655,7 +2735,7 @@ "description": "Notification body when a login triggers an at-risk password change request and the change password domain is known." }, "atRiskNavigatePrompt": { - "message": "$ORGANIZATION$ möchte, dass du dieses Passwort änderst, da es gefährdet ist. Wechsel zu deinen Kontoeinstellungen, um das Passwort zu ändern.", + "message": "$ORGANIZATION$ möchte, dass du dieses Passwort änderst, da es gefährdet ist. Gehe zu deinen Kontoeinstellungen, um das Passwort zu ändern.", "placeholders": { "organization": { "content": "$1", @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Nur der mit $ORGANIZATION$ verknüpfte Organisations-Tresor wird exportiert.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Nur der mit $ORGANIZATION$ verknüpfte Organisations-Tresor wird exportiert. Meine Eintrags-Sammlungen werden nicht eingeschlossen.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Fehler" }, "decryptionError": { "message": "Entschlüsselungsfehler" }, + "errorGettingAutoFillData": { + "message": "Fehler beim Abrufen der Auto-Ausfüllen-Daten" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden konnte folgende(n) Tresor-Eintrag/Einträge nicht entschlüsseln." }, @@ -3752,7 +3853,7 @@ "message": "Anmeldung kann nicht abgeschlossen werden" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { - "message": "Du musst dich auf einem vertrauenswürdigen Gerät anmelden oder deinem Administrator bitten, dir ein Passwort zuzuweisen." + "message": "Du musst dich auf einem vertrauenswürdigen Gerät anmelden oder deinen Administrator bitten, dir ein Passwort zuzuweisen." }, "ssoIdentifierRequired": { "message": "SSO-Kennung der Organisation erforderlich." @@ -3834,7 +3935,7 @@ "message": "Fahre zur Sicherheit deines Kontos nur fort, wenn du ein Mitglied dieser Organisation bist, die Kontowiederherstellung aktiviert hast und der unten angezeigte Fingerabdruck mit dem Fingerabdruck der Organisation übereinstimmt." }, "orgTrustWarning1": { - "message": "Diese Organisation hat eine Unternehmensrichtlinie, die dich für die Kontowiederherstellung registriert. Die Registrierung wird es den Administratoren der Organisation erlauben, dein Passwort zu ändern. Fahre nur fort, wenn du diese Organisation kennst und die unten angezeigte Fingerabdruck-Phrase mit der der Organisation übereinstimmt." + "message": "Diese Organisation hat eine Enterprise-Richtlinie, die dich für die Kontowiederherstellung registriert. Die Registrierung ermöglicht es den Organisations-Administratoren, dein Passwort zu ändern. Fahre nur fort, wenn du diese Organisation erkennst und die unten angezeigte Fingerabdruck-Phrase mit dem Fingerabdruck der Organisation übereinstimmt." }, "trustUser": { "message": "Benutzer vertrauen" @@ -3970,6 +4071,15 @@ "message": "Auto-Ausfüllen beim Laden einer Seite wurde auf die Standardeinstellung gesetzt.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Kein Auto-Ausfüllen möglich" + }, + "cannotAutofillExactMatch": { + "message": "Die Standard-Übereinstimmungserkennung steht auf \"Exakte Übereinstimmung\". Die aktuelle Website stimmt nicht genau mit den gespeicherten Zugangsdaten für diesen Eintrag überein." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Seitennavigation umschalten" }, @@ -4179,10 +4289,10 @@ "message": "Sammlung auswählen" }, "importTargetHintCollection": { - "message": "Wählen Sie diese Option, wenn der importierte Dateiinhalt in eine Sammlung verschoben werden soll" + "message": "Wähle diese Option, wenn der importierte Dateiinhalt in eine Sammlung verschoben werden soll" }, "importTargetHintFolder": { - "message": "Wählen Sie diese Option, wenn der importierte Dateiinhalt in einen Ordner verschoben werden soll" + "message": "Wähle diese Option, wenn der importierte Dateiinhalt in einen Ordner verschoben werden soll" }, "importUnassignedItemsError": { "message": "Die Datei enthält nicht zugewiesene Einträge." @@ -4419,7 +4529,7 @@ "description": "Label indicating the most common import formats" }, "uriMatchDefaultStrategyHint": { - "message": "Die URI-Übereinstimmungserkennung ist die Methode, mit der Bitwarden Auto-Ausfüllen-Vorschläge erkennt.", + "message": "Die URI-Übereinstimmungserkennung ist die Methode, mit der Bitwarden Vorschläge zum automatischen Ausfüllen identifiziert.", "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": { @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Schalte mit Premium Berichte, Notfallzugriff und weitere Sicherheitsfunktionen frei." + }, "freeOrgsCannotUseAttachments": { "message": "Kostenlose Organisationen können Anhänge nicht verwenden" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Standard ($VALUE$)", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Übereinstimmungs-Erkennung anzeigen $WEBSITE$", "placeholders": { @@ -5485,10 +5608,10 @@ "message": "Gefährdetes Passwort ändern" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "Dieser Login ist gefährdet und es fehlt eine Website. Fügen Sie eine Website hinzu und ändern Sie das Passwort, um die Sicherheit zu erhöhen." + "message": "Diese Zugangsdaten sind gefährdet und es fehlt eine Website. Füge eine Website hinzu und ändere das Passwort für mehr Sicherheit." }, "missingWebsite": { - "message": "Fehlende Webseite" + "message": "Fehlende Website" }, "settingsVaultOptions": { "message": "Tresoroptionen" @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Willkommen in deinem Tresor!" }, - "phishingPageTitle": { - "message": "Phishing Webseite" + "phishingPageTitleV2": { + "message": "Phishing-Versuch erkannt" }, - "phishingPageCloseTab": { - "message": "Tab schließen" + "phishingPageSummary": { + "message": "Die Website, die du versuchst zu öffnen, ist eine bekannte böswillige Website und ein Sicherheitsrisiko." }, - "phishingPageContinue": { - "message": "Weiter" + "phishingPageCloseTabV2": { + "message": "Diesen Tab schließen" }, - "phishingPageLearnWhy": { - "message": "Warum sehen Sie das?" + "phishingPageContinueV2": { + "message": "Weiter zu dieser Seite (nicht empfohlen)" + }, + "phishingPageExplanation1": { + "message": "Diese Seite wurde in ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": " gefunden, einer Open-Source-Liste bekannter Phishing-Seiten, die zum Diebstahl persönlicher und vertraulicher Informationen verwendet werden.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Erfahre mehr über die Phishing-Erkennung" + }, + "protectedBy": { + "message": "Geschützt durch $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Einträge für die aktuelle Seite automatisch ausfüllen" @@ -5557,7 +5700,7 @@ "message": "Favoriten-Einträge für einfachen Zugriff" }, "hasItemsVaultNudgeBodyThree": { - "message": "Deinen Tresor nach etwas anderem durchsuchen" + "message": "Durchsuche deinen Tresor nach etwas anderem" }, "newLoginNudgeTitle": { "message": "Spare Zeit mit Auto-Ausfüllen" @@ -5590,16 +5733,16 @@ "message": "Mit Identitäten kannst du lange Registrierungs- oder Kontaktformulare schnell automatisch ausfüllen." }, "newNoteNudgeTitle": { - "message": "Bewahre deine sensiblen Daten sicher auf" + "message": "Halte deine sensiblen Daten sicher" }, "newNoteNudgeBody": { - "message": "Mit Notizen speicherst du sensible Daten wie Bank- oder Versicherungs-Informationen." + "message": "Mit Notizen kannst du sensible Daten wie Bank- oder Versicherungsinformationen sicher speichern." }, "newSshNudgeTitle": { "message": "Entwickler-freundlicher SSH-Zugriff" }, "newSshNudgeBodyOne": { - "message": "Speicher deine Schlüssel und verbinden dich mit dem SSH-Agenten für eine schnelle und verschlüsselte Authentifizierung.", + "message": "Speichere deine Schlüssel und verbinde dich mit dem SSH-Agenten für eine schnelle, verschlüsselte Authentifizierung.", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, @@ -5629,10 +5772,10 @@ "message": "Über diese Einstellung" }, "permitCipherDetailsDescription": { - "message": "Bitwarden wird gespeicherte Login-URIs verwenden, um zu ermitteln, welches Symbol oder welche URL zum Ändern des Passworts verwendet werden soll, um Ihr Erlebnis zu verbessern. Bei der Nutzung dieses Dienstes werden keine Informationen erfasst oder gespeichert." + "message": "Bitwarden verwendet gespeicherte Zugangsdaten-URIs, um zu erkennen, welches Symbol oder welche Passwort-Ändern-URL verwendet werden soll, um dein Erlebnis zu verbessern. Es werden keine Informationen erfasst oder gespeichert, wenn du diesen Dienst nutzt." }, "noPermissionsViewPage": { - "message": "Du hast keine Berechtigung, diese Seite anzuzeigen. Versuche dich mit einem anderen Konto anzumelden." + "message": "Du hast keine Berechtigung, diese Seite anzuzeigen. Versuche, dich mit einem anderen Konto anzumelden." }, "wasmNotSupported": { "message": "WebAssembly wird von deinem Browser nicht unterstützt oder ist nicht aktiviert. WebAssembly wird benötigt, um die Bitwarden-App nutzen zu können.", @@ -5648,10 +5791,65 @@ "message": "Weiter" }, "moreBreadcrumbs": { - "message": "Mehr Breadcrumbs", + "message": "Dateipfad erweitern", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." }, "confirmKeyConnectorDomain": { - "message": "Bestätige Key Connector Domäne" + "message": "Key Connector-Domain bestätigen" + }, + "atRiskLoginsSecured": { + "message": "Gute Arbeit! Du hast deine gefährdeten Zugangsdaten geschützt!" + }, + "upgradeNow": { + "message": "Jetzt upgraden" + }, + "builtInAuthenticator": { + "message": "Integrierter Authenticator" + }, + "secureFileStorage": { + "message": "Sicherer Dateispeicher" + }, + "emergencyAccess": { + "message": "Notfallzugriff" + }, + "breachMonitoring": { + "message": "Datendiebstahl-Überwachung" + }, + "andMoreFeatures": { + "message": "Und mehr!" + }, + "planDescPremium": { + "message": "Umfassende Online-Sicherheit" + }, + "upgradeToPremium": { + "message": "Upgrade auf Premium" + }, + "unlockAdvancedSecurity": { + "message": "Erweiterte Sicherheitsfunktionen freischalten" + }, + "unlockAdvancedSecurityDesc": { + "message": "Mit einem Premium-Abonnement erhältst du mehr Werkzeuge für mehr Sicherheit und Kontrolle" + }, + "explorePremium": { + "message": "Premium entdecken" + }, + "loadingVault": { + "message": "Tresor wird geladen" + }, + "vaultLoaded": { + "message": "Tresor geladen" + }, + "settingDisabledByPolicy": { + "message": "Diese Einstellung ist durch die Richtlinien deiner Organisation deaktiviert.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "PLZ / Postleitzahl" + }, + "cardNumberLabel": { + "message": "Kartennummer" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout-Aktion" } } diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index cce3e0ea39f..476a6165a8e 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Χρήση ενιαίας σύνδεσης" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Καλώς ήρθατε" }, @@ -550,14 +553,19 @@ "resetSearch": { "message": "Επαναφορά αναζήτησης" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Αρχειοθήκη", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Αρχειοθέτηση", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Στοιχεία στην αρχειοθήκη" }, "noItemsInArchive": { "message": "No items in archive" @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,14 +585,26 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Επεξεργασία" }, "view": { "message": "Προβολή" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { - "message": "View login" + "message": "Προβολή σύνδεσης" }, "noItemsInList": { "message": "Δεν υπάρχουν στοιχεία στη λίστα." @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Μη έγκυρος κύριος κωδικός πρόσβασης" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Χρόνος Λήξης Vault" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Κατά το Κλείδωμα Συστήματος" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Κατά την Επανεκκίνηση του Browser" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Το αντικείμενο αποθηκεύτηκε" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το στοιχείο;" }, @@ -1199,7 +1246,7 @@ "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": { @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ζητήστε βιομετρικά κατά την εκκίνηση" }, - "premiumRequired": { - "message": "Απαιτείται το Premium" - }, - "premiumRequiredDesc": { - "message": "Για να χρησιμοποιήσετε αυτή τη λειτουργία, απαιτείται συνδρομή Premium." - }, "authenticationTimeout": { "message": "Χρονικό όριο επαλήθευσης" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Ανάγνωση κλειδιού πρόσβασης..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Πρέπει να προσθέσετε είτε το βασικό URL του διακομιστή ή τουλάχιστον ένα προσαρμοσμένο περιβάλλον." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Προσαρμοσμένο περιβάλλον" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Απενεργοποίηση αυτόματης συμπλήρωσης" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Εμφάνιση μενού αυτόματης συμπλήρωσης στα πεδία της φόρμας" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Εμφάνιση ταυτοτήτων ως προτάσεις" }, @@ -1756,7 +1830,7 @@ "message": "Σύρετε για ταξινόμηση" }, "dragToReorder": { - "message": "Drag to reorder" + "message": "Σύρετε για αναδιάταξη" }, "cfTypeText": { "message": "Κείμενο" @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Έτος λήξης" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Λήξη" }, @@ -1842,7 +1919,7 @@ "message": "Κωδικός ασφαλείας" }, "cardNumber": { - "message": "card number" + "message": "αριθμός κάρτας" }, "ex": { "message": "πχ." @@ -1944,82 +2021,82 @@ "message": "Κλειδί SSH" }, "typeNote": { - "message": "Note" + "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": "Νέο Send κειμένου", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Νέο Send αρχείου", "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": "Επεξεργασία Send κειμένου", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Επεξεργασία Send αρχείου", "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" }, "passwordHistory": { @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Ορισμός κύριου κωδικού πρόσβασης" }, @@ -2571,7 +2651,7 @@ "message": "Αποκλεισμένοι τομείς" }, "learnMoreAboutBlockedDomains": { - "message": "Learn more about blocked domains" + "message": "Μάθετε περισσότερα για τους αποκλεισμένους τομείς" }, "excludedDomains": { "message": "Εξαιρούμενοι Τομείς" @@ -2595,7 +2675,7 @@ "message": "Αλλαγή" }, "changePassword": { - "message": "Change password", + "message": "Αλλαγή κωδικού πρόσβασής", "description": "Change password button for browser at risk notification on login." }, "changeButtonTitle": { @@ -2608,7 +2688,7 @@ } }, "atRiskPassword": { - "message": "At-risk password" + "message": "Κωδικός πρόσβασης σε κίνδυνο" }, "atRiskPasswords": { "message": "Κωδικοί πρόσβασης σε κίνδυνο" @@ -2703,7 +2783,7 @@ "message": "Illustration of the Bitwarden autofill menu displaying a generated password." }, "updateInBitwarden": { - "message": "Update in Bitwarden" + "message": "Ενημέρωση στο Bitwarden" }, "updateInBitwardenSlideDesc": { "message": "Bitwarden will then prompt you to update the password in the password manager.", @@ -3134,7 +3214,7 @@ "message": "A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator." }, "organizationName": { - "message": "Organization name" + "message": "Όνομα οργανισμού" }, "keyConnectorDomain": { "message": "Key Connector domain" @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Σφάλμα" }, "decryptionError": { "message": "Σφάλμα αποκρυπτογράφησης" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Το Bitwarden δεν μπόρεσε να αποκρυπτογραφήσει τα αντικείμενα θησαυ/κίου που αναφέρονται παρακάτω." }, @@ -3541,10 +3642,10 @@ "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." }, "device": { - "message": "Device" + "message": "Συσκευή" }, "loginStatus": { - "message": "Login status" + "message": "Κατάσταση σύνδεσης" }, "masterPasswordChanged": { "message": "Master password saved" @@ -3640,17 +3741,17 @@ "message": "Απομνημόνευση αυτής της συσκευής για την αυτόματες συνδέσεις στο μέλλον" }, "manageDevices": { - "message": "Manage devices" + "message": "Διαχείριση συσκευών" }, "currentSession": { - "message": "Current session" + "message": "Τρέχουσα συνεδρία" }, "mobile": { "message": "Mobile", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "Επέκταση", "description": "Browser extension/addon" }, "desktop": { @@ -3674,7 +3775,7 @@ "message": "Request pending" }, "firstLogin": { - "message": "First login" + "message": "Πρώτη σύνδεση" }, "trusted": { "message": "Trusted" @@ -3683,10 +3784,10 @@ "message": "Needs approval" }, "devices": { - "message": "Devices" + "message": "Συσκευές" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "Απόπειρα πρόσβασης από το $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -3695,31 +3796,31 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "Επιβεβαίωση πρόσβασης" }, "denyAccess": { - "message": "Deny access" + "message": "Άρνηση πρόσβασης" }, "time": { - "message": "Time" + "message": "Ώρα" }, "deviceType": { - "message": "Device Type" + "message": "Τύπος συσκευής" }, "loginRequest": { - "message": "Login request" + "message": "Αίτημα σύνδεσης" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Αυτό το αίτημα δεν είναι πλέον έγκυρο." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "Το αίτημα σύνδεσης έχει ήδη λήξει." }, "justNow": { - "message": "Just now" + "message": "Μόλις τώρα" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "Ζητήθηκε πριν από $MINUTES$ λεπτά", "placeholders": { "minutes": { "content": "$1", @@ -3970,6 +4071,15 @@ "message": "Η αυτόματη συμπλήρωση κατά τη φόρτωση της σελίδας ορίστηκε να χρησιμοποιεί τις προεπιλεγμένες ρυθμίσεις.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Εναλλαγή πλευρικής πλοήγησης" }, @@ -4435,7 +4545,7 @@ "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "Σύνθετες επιλογές", "description": "Advanced option placeholder for uri option component" }, "confirmContinueToBrowserSettingsTitle": { @@ -4790,10 +4900,10 @@ "message": "Download from bitwarden.com now" }, "getItOnGooglePlay": { - "message": "Get it on Google Play" + "message": "Αποκτήστε το στο Google Play" }, "downloadOnTheAppStore": { - "message": "Download on the App Store" + "message": "Λήψη στο AppStore" }, "permanentlyDeleteAttachmentConfirmation": { "message": "Είστε σίγουροι ότι θέλετε να διαγράψετε οριστικά αυτό το συνημμένο;" @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Οι δωρεάν οργανισμοί δεν μπορούν να χρησιμοποιήσουν συνημμένα" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Εμφάνιση ανιχνεύσεων αντιστοίχισης $WEBSITE$", "placeholders": { @@ -5497,7 +5620,7 @@ "message": "The vault protects more than just your passwords. Store secure logins, IDs, cards and notes securely here." }, "introCarouselLabel": { - "message": "Welcome to Bitwarden" + "message": "Καλώς ορίσατε στο Bitwarden" }, "securityPrioritized": { "message": "Security, prioritized" @@ -5506,13 +5629,13 @@ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you." }, "quickLogin": { - "message": "Quick and easy login" + "message": "Εύκολη και γρήγορη σύνδεση" }, "quickLoginBody": { "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter." }, "secureUser": { - "message": "Level up your logins" + "message": "Αναβαθμίστε τις συνδέσεις σας" }, "secureUserBody": { "message": "Use the generator to create and save strong, unique passwords for all your accounts." @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Μάθετε περισσότερα για την ανίχνευση ηλεκτρονικού «ψαρέματος»" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5560,7 +5703,7 @@ "message": "Search your vault for something else" }, "newLoginNudgeTitle": { - "message": "Save time with autofill" + "message": "Εξοικονομήστε χρόνο με την αυτόματη συμπλήρωση" }, "newLoginNudgeBodyOne": { "message": "Include a", @@ -5639,13 +5782,13 @@ "description": "'WebAssembly' is a technical term and should not be translated." }, "showMore": { - "message": "Show more" + "message": "Εμφάνιση περισσότερων" }, "showLess": { - "message": "Show less" + "message": "Εμφάνιση λιγότερων" }, "next": { - "message": "Next" + "message": "Επόμενο" }, "moreBreadcrumbs": { "message": "More breadcrumbs", @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 72c3892af62..21149499485 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Edit" }, "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Vault timeout" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "Premium required" - }, - "premiumRequiredDesc": { - "message": "A Premium membership is required to use this feature." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4418,7 +4528,7 @@ "message": "Common formats", "description": "Label indicating the most common import formats" }, - "uriMatchDefaultStrategyHint": { + "uriMatchDefaultStrategyHint": { "message": "URI match detection is how Bitwarden identifies autofill suggestions.", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle":{ - "message": "Phishing website" + "phishingPageTitleV2":{ + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 43bb17c297f..103d45f0685 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organisation requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Edit" }, "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Vault timeout" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the bin?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "Premium required" - }, - "premiumRequiredDesc": { - "message": "A Premium membership is required to use this feature." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organisation vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organisation vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Auto-fill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organisations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organisation's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index 59c4966a48c..2713381986c 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organisation requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Edit" }, "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Vault timeout" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Edited item" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Are you sure you want to delete this item?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "Premium required" - }, - "premiumRequiredDesc": { - "message": "A premium membership is required to use this feature." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organisation vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organisation vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Auto-fill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organisations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organisation's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "PIN" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index d3c6e3556a0..e19bd11ba28 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Usar inicio de sesión único" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Bienvenido de nuevo" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Restablecer búsqueda" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Editar" }, "view": { "message": "Ver" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Contraseña maestra no válida" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Tiempo de espera de la caja fuerte" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Al bloquear el sistema" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Al reiniciar el navegador" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Elemento editado" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "¿Seguro que quieres enviarlo a la papelera?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Pedir datos biométricos al ejecutar" }, - "premiumRequired": { - "message": "Premium requerido" - }, - "premiumRequiredDesc": { - "message": "Una membrasía Premium es requerida para utilizar esta característica." - }, "authenticationTimeout": { "message": "Tiempo de autenticación agotado" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Leer clave de seguridad" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Esperando interacción de la clave de seguridad..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Debes añadir la dirección URL del servidor base o al menos un entorno personalizado." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Entorno personalizado" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Desactivar autocompletado" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Mostrar sugerencias de autocompletado en campos de formulario" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Mostrar identidades como sugerencias" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Año de expiración" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiración" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Establecer contraseña maestra" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Error de descifrado" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden no pudo descifrar el/los elemento(s) de la bóveda listados a continuación." }, @@ -3970,6 +4071,15 @@ "message": "El autorrellenado de la página está usando la configuración predeterminada.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Activar/desactivar navegación lateral" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Las organizaciones gratis no pueden usar archivos adjuntos" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "¡Bienvenido a tu caja fuerte!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 5508a1cee72..7a9737f71ff 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Tere tulemast tagasi" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Muuda" }, "view": { "message": "Vaata" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Vale ülemparool" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Hoidla ajalõpp" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Arvutist väljalogimisel" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Brauseri taaskäivitamisel" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Kirje on muudetud" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Soovid tõesti selle kirje kustutada?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Küsi avamisel biomeetriat" }, - "premiumRequired": { - "message": "Vajalik on Premium versioon" - }, - "premiumRequiredDesc": { - "message": "Selle funktsiooni kasutamiseks on vajalik tasulist kontot omada." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Kohandatud keskkond" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Aegumise aasta" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Aegumine" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Määra ülemparool" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Viga" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Tere tulemast sinu hoidlasse!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index 93242263dc0..b61213da989 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Ongi etorri berriro ere" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Editatu" }, "view": { "message": "Erakutsi" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Pasahitz nagusi baliogabea" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Kutxa gotorraren itxaronaldia" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Sistema blokeatzean" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Nabigatzailea berrabiaraztean" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Elementua editatuta" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Ziur zaude elementu hau zakarrontzira bidali nahi duzula?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Biometria eskatu saioa hastean" }, - "premiumRequired": { - "message": "Premium izatea beharrezkoa da" - }, - "premiumRequiredDesc": { - "message": "Premium bazkidetza beharrezkoa da ezaugarri hau erabiltzeko." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Ingurune pertsonalizatua" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Iraungitze urtea" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Iraungitze data" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Ezarri pasahitz nagusia" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Akatsa" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 129f2ee383a..550de006cf6 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "استفاده از ورود تک مرحله‌ای" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "خوش آمدید" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "ویرایش" }, "view": { "message": "مشاهده" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "کلمه عبور اصلی نامعتبر است" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "متوقف شدن گاو‌صندوق" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "هنگام قفل سیستم" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "هنگام راه‌اندازی مجدد" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "مورد ذخیره شد" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "واقعاً می‌خواهید این مورد را به سطل زباله ارسال کنید؟" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "درخواست بیومتریک هنگام راه‌اندازی" }, - "premiumRequired": { - "message": "در نسخه پرمیوم کار می‌کند" - }, - "premiumRequiredDesc": { - "message": "برای استفاده از این ویژگی عضویت پرمیوم لازم است." - }, "authenticationTimeout": { "message": "پایان زمان احراز هویت" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "خواندن کلید امنیتی" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "در انتظار تعامل با کلید امنیتی..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "شما باید یا نشانی اینترنتی پایه سرور را اضافه کنید، یا حداقل یک محیط سفارشی تعریف کنید." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "محیط سفارشی" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "پر کردن خودکار را خاموش کنید" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "نمایش پیشنهادهای پر کردن خودکار روی فیلدهای فرم" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "نمایش هویت‌ها به‌عنوان پیشنهاد" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "سال انقضاء" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "انقضاء" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "تنظیم کلمه عبور اصلی" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "خطا" }, "decryptionError": { "message": "خطای رمزگشایی" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden نتوانست مورد(های) گاوصندوق فهرست شده زیر را رمزگشایی کند." }, @@ -3970,6 +4071,15 @@ "message": "پر کردن خودکار در بارگیری صفحه برای استفاده از تنظیمات پیش‌فرض تنظیم شده است.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "تغییر وضعیت ناوبری کناری" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "پرمیوم" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "سازمان‌های رایگان نمی‌توانند از پرونده‌های پیوست استفاده کنند" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "نمایش شناسایی تطابق برای $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "به گاوصندوق خود خوش آمدید!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "پر کردن خودکار موارد برای صفحه فعلی" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 5de1d9fe7e4..bdf2ebd641c 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Käytä kertakirjautumista" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Tervetuloa takaisin" }, @@ -550,39 +553,56 @@ "resetSearch": { "message": "Nollaa haku" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Arkistoi", + "description": "Noun" }, - "unarchive": { - "message": "Unarchive" + "archiveVerb": { + "message": "Arkistoi", + "description": "Verb" + }, + "unArchive": { + "message": "Poista arkistosta" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Arkistossa olevat kohteet" }, "noItemsInArchive": { - "message": "No items in archive" + "message": "Arkistossa ei ole kohteita" }, "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { - "message": "Archive item" + "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?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Muokkaa" }, "view": { "message": "Näytä" }, + "viewAll": { + "message": "Näytä kaikki" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Virheellinen pääsalasana" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Holvin aikakatkaisu" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Kun järjestelmä lukitaan" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Kun selain käynnistetään uudelleen" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Kohde tallennettiin" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Haluatko varmasti siirtää roskakoriin?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Pyydä Biometristä todennusta käynnistettäessä" }, - "premiumRequired": { - "message": "Premium vaaditaan" - }, - "premiumRequiredDesc": { - "message": "Tämä ominaisuus edellyttää Premium-jäsenyyttä." - }, "authenticationTimeout": { "message": "Todennuksen aikakatkaisu" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Lue suojausavain" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Odotetaan suojausavaimen aktivointia..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Sinun on lisättävä joko palvelimen perusosoite tai ainakin yksi mukautettu palvelinympäristö." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Mukautettu palvelinympäristö" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Poista automaattitäyttö käytöstä" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Näytä automaattitäytön ehdotukset lomakekentissä" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Näytä identiteetit ehdotuksina" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Erääntymisvuosi" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Voimassaolo päättyy" }, @@ -1947,11 +2024,11 @@ "message": "Muistiinpano" }, "newItemHeaderLogin": { - "message": "New Login", + "message": "Uusi kirjautumistieto", "description": "Header for new login item type" }, "newItemHeaderCard": { - "message": "New Card", + "message": "Uusi kortti", "description": "Header for new card item type" }, "newItemHeaderIdentity": { @@ -1963,23 +2040,23 @@ "description": "Header for new note item type" }, "newItemHeaderSshKey": { - "message": "New SSH key", + "message": "Uusi SSH-avain", "description": "Header for new SSH key item type" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Uusi teksti-Send", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Uusi tiedosto-Send", "description": "Header for new file send" }, "editItemHeaderLogin": { - "message": "Edit Login", + "message": "Muokkaa kirjautumistietoa", "description": "Header for edit login item type" }, "editItemHeaderCard": { - "message": "Edit Card", + "message": "Muokkaa korttia", "description": "Header for edit card item type" }, "editItemHeaderIdentity": { @@ -1991,23 +2068,23 @@ "description": "Header for edit note item type" }, "editItemHeaderSshKey": { - "message": "Edit SSH key", + "message": "Muokkaa SSH avainta", "description": "Header for edit SSH key item type" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Muokkaa teksti-Sendiä", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Muokkaa tiedosto-Sendiä", "description": "Header for edit file send" }, "viewItemHeaderLogin": { - "message": "View Login", + "message": "Näytä kirjautumistieto", "description": "Header for view login item type" }, "viewItemHeaderCard": { - "message": "View Card", + "message": "Näytä kortti", "description": "Header for view card item type" }, "viewItemHeaderIdentity": { @@ -2019,7 +2096,7 @@ "description": "Header for view note item type" }, "viewItemHeaderSshKey": { - "message": "View SSH key", + "message": "Näytä SSH-avain", "description": "Header for view SSH key item type" }, "passwordHistory": { @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Aseta pääsalasana" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Virhe" }, "decryptionError": { "message": "Salauksen purkuvirhe" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden ei pystynyt purkamaan alla lueteltuja holvin kohteita." }, @@ -3970,6 +4071,15 @@ "message": "Automaattitäyttö sivun avautuessa käyttää oletusasetusta.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Näytä/piilota sivuvalikko" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Ilmaiset organisaatiot eivät voi käyttää liitteitä" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Oletus ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Näytä vastaavuuden tunnistus $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Tervetuloa holviisi!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Jatka tälle sivustolle (ei suositeltavaa)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Täytä nykyisen sivun kohteet automaattisesti" @@ -5645,7 +5788,7 @@ "message": "Show less" }, "next": { - "message": "Next" + "message": "Seuraava" }, "moreBreadcrumbs": { "message": "More breadcrumbs", @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Ladataan holvia" + }, + "vaultLoaded": { + "message": "Holvi ladattu" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Postinumero" + }, + "cardNumberLabel": { + "message": "Kortin numero" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 600abfb2d4e..31284265b2e 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "I-edit" }, "view": { "message": "Tanaw" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Hindi wasto ang master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Vault timeout" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Sa pag-lock ng sistema" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Sa pag-restart ng browser" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Ang item ay nai-save" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Gusto mo bang talagang ipadala sa basura?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Mangyaring humingi ng mga biometrika sa paglunsad" }, - "premiumRequired": { - "message": "Premium na kinakailangan" - }, - "premiumRequiredDesc": { - "message": "Ang Premium na membership ay kinakailangan upang gamitin ang tampok na ito." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Kapaligirang Custom" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Taon ng Pag-expire" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Pag-expire" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Itakda ang master password" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Mali" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 765ebff53c5..dadcd0c041e 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -3,7 +3,7 @@ "message": "Bitwarden" }, "appLogoLabel": { - "message": "Logo de Bitwarden" + "message": "Logo Bitwarden" }, "extName": { "message": "Gestionnaire de mots de passe Bitwarden", @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Utiliser l'authentification unique" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Votre organisation exige l’authentification unique." + }, "welcomeBack": { "message": "Content de vous revoir" }, @@ -246,7 +249,7 @@ "message": "Connectez-vous à votre coffre" }, "autoFillInfo": { - "message": "Il n'y a aucun identifiant disponible pour la saisie automatique concernant l'onglet actuel du navigateur." + "message": "Il n'y a pas d'identifiants disponibles pour la saisie automatique pour l'onglet actuel du navigateur." }, "addLogin": { "message": "Ajouter un identifiant" @@ -550,32 +553,40 @@ "resetSearch": { "message": "Réinitialiser la recherche" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { - "message": "Unarchive" + "archiveVerb": { + "message": "Archiver", + "description": "Verb" + }, + "unArchive": { + "message": "Désarchiver" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Éléments dans l'archive" }, "noItemsInArchive": { - "message": "No items in archive" + "message": "Aucun élément dans l'archive" }, "noItemsInArchiveDesc": { - "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + "message": "Les éléments archivés apparaîtront ici et seront exclus des résultats de recherche généraux et des suggestions de remplissage automatique." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "L'élément a été envoyé à l'archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "L'élément a été désarchivé" }, "archiveItem": { - "message": "Archive item" + "message": "Archiver l'élément" }, "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "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 ?" + }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." }, "edit": { "message": "Modifier" @@ -583,8 +594,17 @@ "view": { "message": "Afficher" }, + "viewAll": { + "message": "Tout afficher" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "Afficher moins" + }, "viewLogin": { - "message": "View login" + "message": "Afficher l'Identifiant" }, "noItemsInList": { "message": "Aucun identifiant à afficher." @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Mot de passe principal invalide" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Mot de passe principal invalide. Confirmez que votre adresse courriel est correcte et que votre compte a été créé sur $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Délai d'expiration du coffre" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Au verrouillage" }, + "onIdle": { + "message": "À l'inactivité du système" + }, + "onSleep": { + "message": "À la mise en veille du système" + }, "onRestart": { "message": "Au redémarrage du navigateur" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Élément enregistré" }, + "savedWebsite": { + "message": "Site Web enregistré" + }, + "savedWebsites": { + "message": "Sites Web enregistrés ( $COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Êtes-vous sûr de vouloir supprimer cet identifiant ?" }, @@ -1486,17 +1533,11 @@ "message": "Copier le TOTP automatiquement" }, "disableAutoTotpCopyDesc": { - "message": "Si un identifiant possède une clé d'authentification, copie le code de vérification TOTP dans votre presse-papiers lorsque vous saisissez automatiquement l'identifiant." + "message": "Copie le code de vérification TOTP dans votre presse-papiers lorsque vous saisissez automatiquement l'identifiant, si un identifiant possède une clé d'authentification.\n\nSi un identifiant dispose d'une clé d'authentification, copie le code de vérification TOTP dans votre presse-papiers lorsque vous saisissez automatiquement l'identifiant." }, "enableAutoBiometricsPrompt": { "message": "Demander la biométrie au lancement" }, - "premiumRequired": { - "message": "Premium requis" - }, - "premiumRequiredDesc": { - "message": "Une adhésion Premium est requise pour utiliser cette fonctionnalité." - }, "authenticationTimeout": { "message": "Délai d'authentification dépassé" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Lire la clé de sécurité" }, + "readingPasskeyLoading": { + "message": "Lecture de la clé d'accès..." + }, + "passkeyAuthenticationFailed": { + "message": "Échec de l'authentification avec clé d'accès" + }, + "useADifferentLogInMethod": { + "message": "Utiliser une méthode de connexion différente" + }, "awaitingSecurityKeyInteraction": { "message": "En attente d'interaction de la clé de sécurité..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Vous devez ajouter soit l'URL du serveur de base, soit au moins un environnement personnalisé." }, + "selfHostedEnvMustUseHttps": { + "message": "Les URL doivent utiliser HTTPS." + }, "customEnvironment": { "message": "Environnement personnalisé" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Désactiver la saisie automatique" }, + "confirmAutofill": { + "message": "Confirmer la saisie automatique" + }, + "confirmAutofillDesc": { + "message": "Ce site ne correspond pas à vos identifiants de connexion enregistrés. Avant de remplir vos identifiants de connexion, assurez-vous que c'est un site de confiance." + }, "showInlineMenuLabel": { "message": "Afficher les suggestions de saisie automatique dans les champs d'un formulaire" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Comment Bitwarden protège-t-il vos données contre l'hameçonnage ?" + }, + "currentWebsite": { + "message": "Site internet actuel" + }, + "autofillAndAddWebsite": { + "message": "Saisir automatiquement et ajouter ce site" + }, + "autofillWithoutAdding": { + "message": "Saisir automatiquement sans ajouter" + }, + "doNotAutofill": { + "message": "Ne pas saisir automatiquement" + }, "showInlineMenuIdentitiesLabel": { "message": "Afficher les identités sous forme de suggestions" }, @@ -1782,7 +1856,7 @@ "message": "Cliquer en dehors de la fenêtre popup pour vérifier votre courriel avec le code de vérification va causer la fermeture de cette fenêtre popup. Voulez-vous ouvrir cette fenêtre popup dans une nouvelle fenêtre afin qu'elle ne se ferme pas ?" }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Afficher les icônes du site web et récupérer les URL de changement de mot de passe" }, "cardholderName": { "message": "Nom du titulaire de la carte" @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Année d'expiration" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -1947,79 +2024,79 @@ "message": "Note" }, "newItemHeaderLogin": { - "message": "New Login", + "message": "Nouvel Identifiant", "description": "Header for new login item type" }, "newItemHeaderCard": { - "message": "New Card", + "message": "Nouvelle Carte de paiement", "description": "Header for new card item type" }, "newItemHeaderIdentity": { - "message": "New Identity", + "message": "Nouvelle Identité", "description": "Header for new identity item type" }, "newItemHeaderNote": { - "message": "New Note", + "message": "Nouvelle Note", "description": "Header for new note item type" }, "newItemHeaderSshKey": { - "message": "New SSH key", + "message": "Nouvelle clé SSH", "description": "Header for new SSH key item type" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Nouveau Send de texte", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Nouveau Send de fichier", "description": "Header for new file send" }, "editItemHeaderLogin": { - "message": "Edit Login", + "message": "Modifier l'Identifiant", "description": "Header for edit login item type" }, "editItemHeaderCard": { - "message": "Edit Card", + "message": "Modifier la Carte de paiement", "description": "Header for edit card item type" }, "editItemHeaderIdentity": { - "message": "Edit Identity", + "message": "Modifier l'Identité", "description": "Header for edit identity item type" }, "editItemHeaderNote": { - "message": "Edit Note", + "message": "Modifier la Note", "description": "Header for edit note item type" }, "editItemHeaderSshKey": { - "message": "Edit SSH key", + "message": "Modifier la clé SSH", "description": "Header for edit SSH key item type" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Modifier le Send de texte", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Modifier le Send de fichier", "description": "Header for edit file send" }, "viewItemHeaderLogin": { - "message": "View Login", + "message": "Afficher l'Identifiant", "description": "Header for view login item type" }, "viewItemHeaderCard": { - "message": "View Card", + "message": "Afficher la Carte de paiement", "description": "Header for view card item type" }, "viewItemHeaderIdentity": { - "message": "View Identity", + "message": "Afficher l'Identité", "description": "Header for view identity item type" }, "viewItemHeaderNote": { - "message": "View Note", + "message": "Afficher la Note", "description": "Header for view note item type" }, "viewItemHeaderSshKey": { - "message": "View SSH key", + "message": "Afficher la clé SSH", "description": "Header for view SSH key item type" }, "passwordHistory": { @@ -2218,7 +2295,7 @@ "message": "Déverrouiller avec un code PIN" }, "setYourPinTitle": { - "message": "Définir PIN" + "message": "Définir NIP" }, "setYourPinButton": { "message": "Définir PIN" @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Cette page interfère avec l'expérience Bitwarden. Le menu en ligne de Bitwarden a été temporairement désactivé en tant que mesure de sécurité." + }, "setMasterPassword": { "message": "Définir le mot de passe principal" }, @@ -3193,17 +3273,38 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Seul le coffre de l'organisation associé à $ORGANIZATION$ sera exporté.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Seul le coffre de l'organisation associé à $ORGANIZATION$ sera exporté. Mes éléments de mes collections ne seront pas inclus.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Erreur" }, "decryptionError": { "message": "Erreur de déchiffrement" }, + "errorGettingAutoFillData": { + "message": "Erreur lors de l'obtention des données de saisie automatique" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden n’a pas pu déchiffrer le(s) élément(s) du coffre listé(s) ci-dessous." }, "contactCSToAvoidDataLossPart1": { - "message": "Contacter le service clientèle", + "message": "Contacter succès client", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "contactCSToAvoidDataLossPart2": { @@ -3970,6 +4071,15 @@ "message": "La saisie automatique au chargement de la page est configuré selon les paramètres par défaut.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Impossible de saisir automatiquement" + }, + "cannotAutofillExactMatch": { + "message": "La correspondance par défaut est définie à 'Correspondance exacte'. Le site internet actuel ne correspond pas exactement aux informations de l'identifiant de connexion enregistrées pour cet élément." + }, + "okay": { + "message": "Ok" + }, "toggleSideNavigation": { "message": "Basculer la navigation latérale" }, @@ -4101,7 +4211,7 @@ "message": "Vérification requise pour cette action. Définissez un code PIN pour continuer." }, "setPin": { - "message": "Définir le code PIN" + "message": "Définir le code NIP" }, "verifyWithBiometrics": { "message": "Vérifier par biométrie" @@ -4225,7 +4335,7 @@ "message": "Données du coffre exportées" }, "typePasskey": { - "message": "Clé d'identification (passkey)" + "message": "Clé d'accès" }, "accessing": { "message": "Accès en cours" @@ -4267,7 +4377,7 @@ "message": "Enregistrer la clé d'identification (passkey) comme nouvel identifiant" }, "chooseCipherForPasskeySave": { - "message": "Choisissez un identifiant ou enregistrer cette clé d'accès" + "message": "Choisissez un identifiant où enregistrer cette clé d'accès" }, "chooseCipherForPasskeyAuth": { "message": "Choisissez une clé d'accès pour vous connecter" @@ -4285,7 +4395,7 @@ "message": "Fonctionnalité non supportée" }, "yourPasskeyIsLocked": { - "message": "Authentification requise pour utiliser une clé d'identification (passkey). Vérifiez votre identité pour continuer." + "message": "Authentification requise pour utiliser une clé d'accès. Vérifiez votre identité pour continuer." }, "multifactorAuthenticationCancelled": { "message": "Authentification multi-facteurs annulée" @@ -4303,7 +4413,7 @@ "message": "Code incorrect" }, "incorrectPin": { - "message": "Code PIN incorrect" + "message": "Code NIP incorrect" }, "multifactorAuthenticationFailed": { "message": "Authentification multifacteur échouée" @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Déverrouillez la journalisation, l'accès d'urgence et plus de fonctionnalités de sécurité avec Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Les organisations gratuites ne peuvent pas utiliser de pièces jointes" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Par défaut ($VALUE$)", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Afficher la détection de correspondance $WEBSITE$", "placeholders": { @@ -5149,10 +5272,10 @@ "message": "Afficher le nombre de suggestions de saisie automatique d'identifiant sur l'icône d'extension" }, "accountAccessRequested": { - "message": "Account access requested" + "message": "Accès au compte demandé" }, "confirmAccessAttempt": { - "message": "Confirm access attempt for $EMAIL$", + "message": "Confirmer la tentative d’accès pour $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -5263,7 +5386,7 @@ "message": "Vous pouvez personnaliser vos paramètres de déverrouillage et de délai d'attente pour accéder plus rapidement à votre coffre-fort." }, "unlockPinSet": { - "message": "Déverrouiller l'ensemble de codes PIN" + "message": "Déverrouiller l'ensemble de codes NIP" }, "unlockWithBiometricSet": { "message": "Déverrouiller avec l'ensemble biométrique" @@ -5485,10 +5608,10 @@ "message": "Changer le mot de passe à risque" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "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é." }, "missingWebsite": { - "message": "Missing website" + "message": "Site Web manquant" }, "settingsVaultOptions": { "message": "Options du coffre" @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Bienvenue dans votre coffre !" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Tentative d'hameçonnage détectée" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "Le site que vous essayez de visiter est un site malveillant connu et un risque de sécurité." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Fermer cet onglet" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continuer vers ce site (non recommandé)" + }, + "phishingPageExplanation1": { + "message": "Ce site a été trouvé dans ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", une liste open-source de sites d'hameçonnage connus et utilisés pour voler des informations personnelles et sensibles.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "En savoir plus sur la détection d'hameçonnage" + }, + "protectedBy": { + "message": "Protégé par $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Remplissage automatique des éléments de la page actuelle" @@ -5626,10 +5769,10 @@ "description": "Aria label for the body content of the generator nudge" }, "aboutThisSetting": { - "message": "About this setting" + "message": "À propos de ce paramètre" }, "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." + "message": "Bitwarden utilisera les URI de connexion enregistrées pour identifier quelle icône ou URL de changement de mot de passe doit être utilisée pour améliorer votre expérience. Aucune information n'est recueillie ou enregistrée lorsque vous utilisez ce service." }, "noPermissionsViewPage": { "message": "Vous n'avez pas les autorisations pour consulter cette page. Essayez de vous connecter avec un autre compte." @@ -5639,13 +5782,13 @@ "description": "'WebAssembly' is a technical term and should not be translated." }, "showMore": { - "message": "Show more" + "message": "Afficher plus" }, "showLess": { - "message": "Show less" + "message": "Afficher moins" }, "next": { - "message": "Next" + "message": "Suivant" }, "moreBreadcrumbs": { "message": "Plus de fil d'Ariane", @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirmez le domaine de Key Connector" + }, + "atRiskLoginsSecured": { + "message": "Excellent travail pour sécuriser vos identifiants à risque !" + }, + "upgradeNow": { + "message": "Mettre à niveau maintenant" + }, + "builtInAuthenticator": { + "message": "Authentificateur intégré" + }, + "secureFileStorage": { + "message": "Stockage sécurisé de fichier" + }, + "emergencyAccess": { + "message": "Accès d'urgence" + }, + "breachMonitoring": { + "message": "Surveillance des fuites" + }, + "andMoreFeatures": { + "message": "Et encore plus !" + }, + "planDescPremium": { + "message": "Sécurité en ligne complète" + }, + "upgradeToPremium": { + "message": "Mettre à niveau vers Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explorer Premium" + }, + "loadingVault": { + "message": "Chargement du coffre" + }, + "vaultLoaded": { + "message": "Coffre chargé" + }, + "settingDisabledByPolicy": { + "message": "Ce paramètre est désactivé par la politique de sécurité de votre organisation.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Code postal" + }, + "cardNumberLabel": { + "message": "Numéro de carte" + }, + "sessionTimeoutSettingsAction": { + "message": "Action à l’expiration" } } diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index c2573ea6bfa..bd657b9d4b7 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Usar inicio de sesión único" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Benvido de novo" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Editar" }, "view": { "message": "Ver" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Contrasinal mestre non válido" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Tempo de espera da caixa forte" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Ó bloquear o sistema" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Ó reiniciar o navegador" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Entrada gardada" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Seguro que queres envialo ó lixo?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Requirir biometría no inicio" }, - "premiumRequired": { - "message": "Plan Prémium requirido" - }, - "premiumRequiredDesc": { - "message": "Requírese un plan Prémium para poder empregar esta función." - }, "authenticationTimeout": { "message": "Tempo límite de autenticación superado" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Debes engadir ou a URL base do servidor ou, polo menos, un entorno personalizado." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Entorno personalizado" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Amosar suxestións de autoenchido en formularios" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Amosar identidades como suxestións" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Ano de vencemento" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Vencemento" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Definir contrasinal mestre" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Erro" }, "decryptionError": { "message": "Erro de descifrado" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden non puido descifrar os seguintes elementos." }, @@ -3970,6 +4071,15 @@ "message": "Axuste de autoenchido ó cargar a páxina por defecto.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Activar/desactivar navegación lateral" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Prémium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "As organizacións gratuitas non poden empregar anexos" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Mostrar detección de coincidencia $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index 38fe3618610..2e9243206b9 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "השתמש בכניסה יחידה" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "הארגון שלך דורש כניסה יחידה." + }, "welcomeBack": { "message": "ברוך שובך" }, @@ -550,32 +553,40 @@ "resetSearch": { "message": "אפס חיפוש" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "ארכיון", + "description": "Noun" }, - "unarchive": { - "message": "Unarchive" + "archiveVerb": { + "message": "העבר לארכיון", + "description": "Verb" + }, + "unArchive": { + "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": "פריטים בארכיון יופיעו כאן ויוחרגו מתוצאות חיפוש כללי והצעות למילוי אוטומטי." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "הפריט נשלח לארכיון" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "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?" + "message": "פריטים בארכיון מוחרגים מתוצאות חיפוש כללי והצעות למילוי אוטומטי. האם אתה בטוח שברצונך להעביר פריט זה לארכיון?" + }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." }, "edit": { "message": "ערוך" @@ -583,6 +594,15 @@ "view": { "message": "הצג" }, + "viewAll": { + "message": "הצג הכל" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "הצג פחות" + }, "viewLogin": { "message": "הצג כניסה" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "סיסמה ראשית שגויה" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "סיסמה ראשית אינה תקינה. יש לאשר שהדוא\"ל שלך נכון ושהחשבון שלך נוצר ב־$HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "פסק זמן לכספת" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "בנעילת המערכת" }, + "onIdle": { + "message": "כשהמערכת מזהה חוסר פעילות" + }, + "onSleep": { + "message": "כשהמערכת נכנסת למצב שינה" + }, "onRestart": { "message": "בהפעלת הדפדפן מחדש" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "הפריט נשמר" }, + "savedWebsite": { + "message": "אתר אינטרנט שנשמר" + }, + "savedWebsites": { + "message": "אתרי אינטרנט שנשמרו ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "האם אתה בטוח שברצונך למחוק פריט זה?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "בקש זיהוי ביומטרי בפתיחה" }, - "premiumRequired": { - "message": "נדרש פרימיום" - }, - "premiumRequiredDesc": { - "message": "נדרשת חברות פרימיום כדי להשתמש בתכונה זו." - }, "authenticationTimeout": { "message": "פסק זמן לאימות" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "קרא מפתח אבטחה" }, + "readingPasskeyLoading": { + "message": "קורא מפתח גישה..." + }, + "passkeyAuthenticationFailed": { + "message": "אימות מפתח גישה נכשל" + }, + "useADifferentLogInMethod": { + "message": "השתמש בשיטת כניסה אחרת" + }, "awaitingSecurityKeyInteraction": { "message": "ממתין לאינטראקציה עם מפתח אבטחה..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "אתה מוכרח להוסיף או את בסיס ה־URL של השרת או לפחות סביבה מותאמת אישית אחת." }, + "selfHostedEnvMustUseHttps": { + "message": "כתובות URL מוכרחות להשתמש ב־HTTPS." + }, "customEnvironment": { "message": "סביבה מותאמת אישית" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "השבת מילוי אוטומטי" }, + "confirmAutofill": { + "message": "אשר מילוי אוטומטי" + }, + "confirmAutofillDesc": { + "message": "אתר זה אינו תואם את פרטי הכניסה השמורה שלך. לפני שאתה ממלא את אישורי הכניסה שלך, וודא שזהו אתר מהימן." + }, "showInlineMenuLabel": { "message": "הצג הצעות למילוי אוטומטי על שדות טופס" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "כיצד Bitwarden מגנה על הנתונים שלך מדיוג?" + }, + "currentWebsite": { + "message": "אתר נוכחי" + }, + "autofillAndAddWebsite": { + "message": "מלא אוטומטית והוסף אתר אינטרנט זה" + }, + "autofillWithoutAdding": { + "message": "מלא אוטומטית מבלי להוסיף" + }, + "doNotAutofill": { + "message": "אל תמלא אוטומטית" + }, "showInlineMenuIdentitiesLabel": { "message": "הצג זהויות כהצעות" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "שנת תפוגה" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "תוקף" }, @@ -1947,79 +2024,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" }, "passwordHistory": { @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "הגדר סיסמה ראשית" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "רק הכספת הארגונית המשויכת עם $ORGANIZATION$ תיוצא.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "רק הכספת הארגונית המשויכת עם $ORGANIZATION$ תיוצא. פריטי האוספים שלי לא יכללו.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "שגיאה" }, "decryptionError": { "message": "שגיאת פענוח" }, + "errorGettingAutoFillData": { + "message": "שגיאה בקבלת נתוני מילוי אוטומטי" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden לא יכל לפענח את פריט(י) הכספת המפורט(ים) להלן." }, @@ -3970,6 +4071,15 @@ "message": "מילוי אוטומטי בעת טעינת הוגדר להשתמש בהגדרת ברירת מחדל.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "לא ניתן למלא אוטומטית" + }, + "cannotAutofillExactMatch": { + "message": "ברירת המחדל להתאמה מוגדרת כ'התאמה מדויקת'. האתר הנוכחי אינו תואם באופן מדויק את פרטי הכניסה השמורים עבור פריט זה." + }, + "okay": { + "message": "בסדר" + }, "toggleSideNavigation": { "message": "החלף מצב ניווט צדדי" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "פרימיום" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "ארגונים חינמיים לא יכולים להשתמש בצרופות" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "ברירת מחדל ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "הצג זיהוי התאמה $WEBSITE$", "placeholders": { @@ -5494,7 +5617,7 @@ "message": "אפשרויות כספת" }, "emptyVaultDescription": { - "message": "הכספת מגינה על יותר מרק הסיסמאות שלך. אחסן כניסות מאובטחות, זהויות, כרטיסים והערות באופן מאובטח כאן." + "message": "הכספת מגנה על יותר מרק הסיסמאות שלך. אחסן כניסות מאובטחות, זהויות, כרטיסים והערות באופן מאובטח כאן." }, "introCarouselLabel": { "message": "ברוך בואך אל Bitwarden" @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "ברוך בואך אל הכספת שלך!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "זוהה ניסיון דיוג" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "האתר שאתה מנסה לבקר הוא אתר זדוני ידוע וסכנת אבטחה." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "סגור כרטיסיה זו" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "המשך לאתר זה (לא מומלץ)" + }, + "phishingPageExplanation1": { + "message": "אתר זה נמצא ב־", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", רשימת קוד פתוח של אתרי דיוג ידועים המשמשים לגניבת מידע אישי ורגיש.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "למד עוד על זיהוי דיוג" + }, + "protectedBy": { + "message": "מוגן על ידי $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "פריטים למילוי אוטומטי עבור הדף הנוכחי" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "אשר דומיין של Key Connector" + }, + "atRiskLoginsSecured": { + "message": "עבודה נהדרת באבטחת הכניסות בסיכון שלך!" + }, + "upgradeNow": { + "message": "שדרג עכשיו" + }, + "builtInAuthenticator": { + "message": "מאמת מובנה" + }, + "secureFileStorage": { + "message": "אחסון קבצים מאובטח" + }, + "emergencyAccess": { + "message": "גישת חירום" + }, + "breachMonitoring": { + "message": "ניטור פרצות" + }, + "andMoreFeatures": { + "message": "ועוד!" + }, + "planDescPremium": { + "message": "השלם אבטחה מקוונת" + }, + "upgradeToPremium": { + "message": "שדרג לפרימיום" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "טוען כספת" + }, + "vaultLoaded": { + "message": "הכספת נטענה" + }, + "settingDisabledByPolicy": { + "message": "הגדרה זו מושבתת על ידי מדיניות של הארגון שלך.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "מיקוד" + }, + "cardNumberLabel": { + "message": "מספר כרטיס" + }, + "sessionTimeoutSettingsAction": { + "message": "פעולת פסק זמן" } } diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 1575543aef3..58db7ac8ad6 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "सिंगल साइन-ऑन प्रयोग करें" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "आपका पुन: स्वागत है!" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "खोज रीसेट करें" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "संपादन करें" }, "view": { "message": "देखें" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "अमान्य मास्टर पासवर्ड" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "वॉल्ट मध्यांतर" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "On Locked" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On Restart" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "संपादित आइटम " }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "क्या आप वास्तव में थ्रैश में भेजना चाहते हैं?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "लॉन्च पर बायोमेट्रिक्स के लिए पूछें" }, - "premiumRequired": { - "message": "Premium Required" - }, - "premiumRequiredDesc": { - "message": "इस सुविधा का उपयोग करने के लिए प्रीमियम सदस्यता की आवश्यकता होती है।" - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom Environment" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Expiration Year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "समय सीमा समाप्ति" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "मास्टर पासवर्ड सेट करें" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "एरर" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index 4f67de34071..8a5a09aec9c 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Jedinstvena prijava (SSO)" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Tvoja organizacija zahtijeva jedinstvenu prijavu." + }, "welcomeBack": { "message": "Dobro došli natrag" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Ponovno postavljanje pretraživanja" }, - "archive": { - "message": "Arhiviraj" + "archiveNoun": { + "message": "Arhiva", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Arhiviraj", + "description": "Verb" + }, + "unArchive": { "message": "Poništi arhiviranje" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Arhivirane stavke biti će prikazane ovdje i biti će izuzete iz rezultata općih pretraga i preporuka auto-ispune." }, - "itemSentToArchive": { + "itemWasSentToArchive": { "message": "Stavka poslana u arhivu" }, - "itemRemovedFromArchive": { - "message": "Stavka maknute iz arhive" + "itemUnarchived": { + "message": "Stavka vraćena iz arhive" }, "archiveItem": { "message": "Arhiviraj stavku" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Arhivirane stavke biti će izuzete iz rezultata općih pretraga i preporuka auto-ispune. Sigurno želiš arhivirati?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Uredi" }, "view": { "message": "Prikaz" }, + "viewAll": { + "message": "Vidi sve" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "Vidi manje" + }, "viewLogin": { "message": "Prikaži prijavu" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Neispravna glavna lozinka" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Nevažeća glavna lozinka. Provjeri je li tvoja adresa e-pošta ispravna i je li račun kreiran na $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Istek trezora" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Pri zaključavanju sustava" }, + "onIdle": { + "message": "U stanju besposlenosti" + }, + "onSleep": { + "message": "U stanju mirovanja sustava" + }, "onRestart": { "message": "Pri pokretanju preglednika" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Stavka izmijenjena" }, + "savedWebsite": { + "message": "Spremljeno mrežno mjesto" + }, + "savedWebsites": { + "message": "Spremljena mrežna mjesta ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Želiš li zaista poslati u smeće?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Traži biometrijsku autentifikaciju pri pokretanju" }, - "premiumRequired": { - "message": "Potrebno premium članstvo" - }, - "premiumRequiredDesc": { - "message": "Za korištenje ove značajke potrebno je Premium članstvo." - }, "authenticationTimeout": { "message": "Istek vremena za autentifikaciju" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Pročitaj sigurnosni ključ" }, + "readingPasskeyLoading": { + "message": "Čitanje pristupnog ključa..." + }, + "passkeyAuthenticationFailed": { + "message": "Autentifikacija pristupnog ključa nije uspjela" + }, + "useADifferentLogInMethod": { + "message": "Koristi drugi način prijave" + }, "awaitingSecurityKeyInteraction": { "message": "Čekanje na interakciju sa sigurnosnim ključem..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Moraš dodati ili osnovni URL poslužitelja ili barem jedno prilagođeno okruženje." }, + "selfHostedEnvMustUseHttps": { + "message": "URL mora koristiti HTTPS." + }, "customEnvironment": { "message": "Prilagođeno okruženje" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Isključi auto-ispunu" }, + "confirmAutofill": { + "message": "Potvrdi auto-ispunu" + }, + "confirmAutofillDesc": { + "message": "Ova stranica ne odgovara tvojim spremljenim podacima za prijavu. Prije nego što uneseš svoje podatke za prijavu, provjeri je li riječ o pouzdanoj stranici." + }, "showInlineMenuLabel": { "message": "Prikaži prijedloge auto-ispune na poljima obrazaca" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Kako Bitwarden štiti tvoje podatke od phishinga?" + }, + "currentWebsite": { + "message": "Trenutna web stranica" + }, + "autofillAndAddWebsite": { + "message": "Auto-ispuni i dodaj ovu stranicu" + }, + "autofillWithoutAdding": { + "message": "Auto-ispuni bez dodavanja" + }, + "doNotAutofill": { + "message": "Nemoj auto-ispuniti" + }, "showInlineMenuIdentitiesLabel": { "message": "Prikaži identitete kao prijedloge" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Godina isteka" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Istek" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Ova stranica ometa Bitwarden iskustvo. Kao sigurnosna mjera, Bitwarden inline izbornik je privremeno onemogućen." + }, "setMasterPassword": { "message": "Postavi glavnu lozinku" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Izvezt će se samo trezor organizacije povezan s $ORGANIZATION$.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Izvezt će se samo trezor organizacije povezan s $ORGANIZATION$. Zbirka mojih stavki neće biti uključena.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Pogreška" }, "decryptionError": { "message": "Pogreška pri dešifriranju" }, + "errorGettingAutoFillData": { + "message": "Greška kod dohvata podataka za auto-ispunu" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden nije mogao dešifrirati sljedeće stavke trezora." }, @@ -3970,6 +4071,15 @@ "message": "Auto-ispuna kod učitavanja stranice koristi zadane postavke.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Nije moguća auto-ispuna" + }, + "cannotAutofillExactMatch": { + "message": "Zadano podudaranje postavljeno je na „Točno podudaranje”. Trenutna web-stranica ne podudara se točno sa spremljenim podacima ove stavke za prijavu." + }, + "okay": { + "message": "U redu" + }, "toggleSideNavigation": { "message": "U/Isključi bočnu navigaciju" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Besplatne organizacije ne mogu koristiti privitke" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Zadano ($VALUE$)", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Prikaži otkrivanje podudaranja $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Dobrodošli u svoj trezor!" }, - "phishingPageTitle": { - "message": "Phishing web stranica" + "phishingPageTitleV2": { + "message": "Otkriven pokušaj phishinga" }, - "phishingPageCloseTab": { - "message": "Zatvori karticu" + "phishingPageSummary": { + "message": "Web-mjesto koje pokušavaš posjetiti poznato je kao zlonamjerno i predstavlja sigurnosni rizik." }, - "phishingPageContinue": { - "message": "Nastavi" + "phishingPageCloseTabV2": { + "message": "Zatvori ovu karticu" }, - "phishingPageLearnWhy": { - "message": "Zašto ovo vidiš?" + "phishingPageContinueV2": { + "message": "Nastavi na web mjesto (nije preporučljivo)" + }, + "phishingPageExplanation1": { + "message": "Ovo mjesto je nađeno na ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", popisu otvorenog koda poznatih phishing stranica koje se koriste za krađu osobnih i osjetljivih podataka.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Saznaj više o otkrivanju phishinga" + }, + "protectedBy": { + "message": "Zaštićeno s $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Auto-ispuni stavke za trenutnu stranicu" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Potvrdi domenu kontektora ključa" + }, + "atRiskLoginsSecured": { + "message": "Rizične prijave su osigurane!" + }, + "upgradeNow": { + "message": "Nadogradi sada" + }, + "builtInAuthenticator": { + "message": "Ugrađeni autentifikator" + }, + "secureFileStorage": { + "message": "Sigurna pohrana datoteka" + }, + "emergencyAccess": { + "message": "Pristup u nuždi" + }, + "breachMonitoring": { + "message": "Nadzor proboja" + }, + "andMoreFeatures": { + "message": "I više!" + }, + "planDescPremium": { + "message": "Dovrši online sigurnost" + }, + "upgradeToPremium": { + "message": " Nadogradi na Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Provjeri Premium" + }, + "loadingVault": { + "message": "Učitavanje trezora" + }, + "vaultLoaded": { + "message": "Trezor učitan" + }, + "settingDisabledByPolicy": { + "message": "Ova je postavka onemogućena pravilima tvoje organizacije.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Poštanski broj" + }, + "cardNumberLabel": { + "message": "Broj kartice" + }, + "sessionTimeoutSettingsAction": { + "message": "Radnja kod isteka" } } diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index 864580a64b0..bacda584ba1 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Egyszeri bejelentkezés használata" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "A szervezet egyszeri bejelentkezést igényel." + }, "welcomeBack": { "message": "Üdvözlet újra" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Keresés visszaállítása" }, - "archive": { - "message": "Archívum" + "archiveNoun": { + "message": "Archívum", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archívum", + "description": "Verb" + }, + "unArchive": { "message": "Visszavétel archívumból" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Az archivált elemek itt jelennek meg és kizárásra kerülnek az általános keresési eredményekből és az automatikus kitöltési javaslatokból." }, - "itemSentToArchive": { - "message": "Archívumba küldött elemek száma" + "itemWasSentToArchive": { + "message": "Az elem az archivumba került." }, - "itemRemovedFromArchive": { - "message": "Az elem kikerült a kedvencekből." + "itemUnarchived": { + "message": "Az elemek visszavéelre kerültek az archivumból." }, "archiveItem": { "message": "Elem archiválása" @@ -577,12 +585,24 @@ "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?" }, + "upgradeToUseArchive": { + "message": "Az Archívum használatához prémium tagság szükséges." + }, "edit": { "message": "Szerkesztés" }, "view": { "message": "Nézet" }, + "viewAll": { + "message": "Összes megtekintése" + }, + "showAll": { + "message": "Összes megjelenítése" + }, + "viewLess": { + "message": "kevesebb megjelenítése" + }, "viewLogin": { "message": "Bejelentkezés megtekintése" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Hibás mesterjelszó" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "A mesterjelszó érvénytelen. Erősítsük meg, hogy email cím helyes és a fiók létrehozásának helye: $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Széf időkifutás" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Rendszerzároláskor" }, + "onIdle": { + "message": "Rendszer üresjárat esetén" + }, + "onSleep": { + "message": "Rendszer alvó mód esetén" + }, "onRestart": { "message": "Böngésző újraindításkor" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Az elem szerkesztésre került." }, + "savedWebsite": { + "message": "Mentett webhely" + }, + "savedWebsites": { + "message": "Mentett webhelyek ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Biztosan törlésre kerüljön ezt az elem?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Biometria kérése indításkor" }, - "premiumRequired": { - "message": "Prémium funkció szükséges" - }, - "premiumRequiredDesc": { - "message": "Prémium tagság szükséges ennek a funkciónak eléréséhez a jövőben." - }, "authenticationTimeout": { "message": "Hitelesítési időkifutás" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Biztonsági kulcs olvasása" }, + "readingPasskeyLoading": { + "message": "Hozzáférési kulcs beolvasása..." + }, + "passkeyAuthenticationFailed": { + "message": "A hozzáférési kulcs hitelesítés sikertelen volt." + }, + "useADifferentLogInMethod": { + "message": "Más bejelentkezési mód használata" + }, "awaitingSecurityKeyInteraction": { "message": "Várakozás a biztonsági kulcs interakciójára..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Hozzá kell adni az alapszerver webcímét vagy legalább egy egyedi környezetet." }, + "selfHostedEnvMustUseHttps": { + "message": "A webcímeknek HTTPS-t kell használniuk." + }, "customEnvironment": { "message": "Egyedi környezet" }, @@ -1653,8 +1706,29 @@ "turnOffAutofill": { "message": "Automat kitöltés bekapcsolása" }, + "confirmAutofill": { + "message": "Automatikus kitöltés megerősítése" + }, + "confirmAutofillDesc": { + "message": "Ez a webhely nem egyezik a mentett bejelentkezési adatokkal. Mielőtt kitöltenénk a bejelentkezés hitelesítő adatokat, győződjünk meg arról, hogy megbízható webhelyről van-e szó." + }, "showInlineMenuLabel": { - "message": "Show autofill suggestions on form fields" + "message": "Automatikus kitöltési javaslatok megjelenítése űrlapmezőknél" + }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Hogyan védi meg a Bitwarden az adathalászattól az adatokat?" + }, + "currentWebsite": { + "message": "Jelenlegi webhely" + }, + "autofillAndAddWebsite": { + "message": "Automatikus kitöltés és ezen webhely hozzáadása" + }, + "autofillWithoutAdding": { + "message": "Automatikus kitöltés hozzáadás nélkül" + }, + "doNotAutofill": { + "message": "Ne legyen automatikus kitöltés" }, "showInlineMenuIdentitiesLabel": { "message": "Az identitások megjelenítése javaslatként" @@ -1663,10 +1737,10 @@ "message": "A kártyák megjelenítése javaslatként" }, "showInlineMenuOnIconSelectionLabel": { - "message": "Display suggestions when icon is selected" + "message": "Javaslatok megjelenítése a az ikon kiválasztásakor" }, "showInlineMenuOnFormFieldsDescAlt": { - "message": "Applies to all logged in accounts." + "message": "Minden bejelentkezett fiókra vonatkozik." }, "turnOffBrowserBuiltInPasswordManagerSettings": { "message": "Az ütközések elkerülése érdekében kapcsoljuk ki a böngésző beépített jelszókezelő beállításait." @@ -1679,7 +1753,7 @@ "description": "Overlay setting select option for disabling autofill overlay" }, "autofillOverlayVisibilityOnFieldFocus": { - "message": "When field is selected (on focus)", + "message": "Amikor a mező kiválasztásra kerül (fókuszoláskor)", "description": "Overlay appearance select option for showing the field on focus of the input element" }, "autofillOverlayVisibilityOnButtonClick": { @@ -1687,7 +1761,7 @@ "description": "Overlay appearance select option for showing the field on click of the overlay icon" }, "enableAutoFillOnPageLoadSectionTitle": { - "message": "Autofill on page load" + "message": "Automatikus kitöltés az oldal betöltésénél" }, "enableAutoFillOnPageLoad": { "message": "Automatikus kitöltés engedélyezése oldal betöltéskor" @@ -1699,7 +1773,7 @@ "message": "Az oldalbetöltésnél automatikus kitöltést a feltört vagy nem megbízhatató weboldalak kihasználhatják." }, "learnMoreAboutAutofillOnPageLoadLinkText": { - "message": "Learn more about risks" + "message": "Bővebben a kockázatokról" }, "learnMoreAboutAutofill": { "message": "További információk az automatikus kitöltésről" @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Lejárati év" }, + "monthly": { + "message": "hónap" + }, "expiration": { "message": "Lejárat" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Ez az oldal zavarja a Bitwarden élményt. Biztonsági intézkedésként ideiglenesen letiltásra került a Bitwarden belső menü." + }, "setMasterPassword": { "message": "Mesterjelszó beállítása" }, @@ -2423,7 +2503,7 @@ "message": "Az új mesterjelszó nem felel meg a szabály követelményeknek." }, "receiveMarketingEmailsV2": { - "message": "Get advice, announcements, and research opportunities from Bitwarden in your inbox." + "message": "Tanácsokat, bejelentéseket, kutatási lehetőségeket kaphat a Bitwarden-től a postaládájába." }, "unsubscribe": { "message": "Leiratkozás" @@ -2459,10 +2539,10 @@ "message": "Ok" }, "errorRefreshingAccessToken": { - "message": "Access Token Refresh Error" + "message": "Hozzáférési Token Frissítés Hiba" }, "errorRefreshingAccessTokenDesc": { - "message": "No refresh token or API keys found. Please try logging out and logging back in." + "message": "Nem található token vagy API kulcs. Próbáljon meg ki-, majd újra bejelentkezni." }, "desktopSyncVerificationTitle": { "message": "Asztali szinkronizálás ellenőrzés" @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Csak a $ORGANIZATION$ szervezetehez kapcsolódó szervezeti széf kerül exportálásra.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Csak a $ORGANIZATION$ szervezethez kapcsolódó szervezeti széf kerül exportálásra. A saját elem gyűjtemények nem lesznek benne.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Hiba" }, "decryptionError": { "message": "Visszafejtési hiba" }, + "errorGettingAutoFillData": { + "message": "Hiba történt az automatikus kitöltési adatok beolvasásakor." + }, "couldNotDecryptVaultItemsBelow": { "message": "A Bitwarden nem tudta visszafejteni az alább felsorolt ​​széf elemeket." }, @@ -3583,7 +3684,7 @@ "message": "A szervezeti szabályzat bekapcsolta az automatikus kitöltést az oldalbetöltéskor." }, "autofillSelectInfoWithCommand": { - "message": "Select an item from this screen, use the shortcut $COMMAND$, or explore other options in settings.", + "message": "Válasszon ki egy elemet a képernyőről, használja a $COMMAND$ kombinációt, vagy tekintse meg a többi lehetőséget a beállításokban.", "placeholders": { "command": { "content": "$1", @@ -3592,7 +3693,7 @@ } }, "autofillSelectInfoWithoutCommand": { - "message": "Select an item from this screen, or explore other options in settings." + "message": "Válasszon ki egy elemet a képernyőről, vagy tekintse meg a többi lehetőséget a beállításokban." }, "gotIt": { "message": "Rendben" @@ -3601,10 +3702,10 @@ "message": "Automatikus kitöltés beállítások" }, "autofillKeyboardShortcutSectionTitle": { - "message": "Autofill shortcut" + "message": "Automatikus kitöltés gyorselérés" }, "autofillKeyboardShortcutUpdateLabel": { - "message": "Change shortcut" + "message": "Billentyűparancs változtatás" }, "autofillKeyboardManagerShortcutsLabel": { "message": "Bullenytűparancsok kezelése" @@ -3970,18 +4071,27 @@ "message": "Az automatikus kitöltés az oldal betöltésekor az alapértelmezett beállítás használatára lett beállítva.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Nem lehetséges az automatikus kitöltés." + }, + "cannotAutofillExactMatch": { + "message": "Az alapértelmezett egyezés beállítása 'Pontos egyezés'. Az aktuális webhely nem egyezik pontosan az ezzel az elemmel mentett bejelentkezési adatokkal." + }, + "okay": { + "message": "OK" + }, "toggleSideNavigation": { - "message": "Toggle side navigation" + "message": "Oldalnavigáció váltás" }, "skipToContent": { "message": "Ugrás a tartalomra" }, "bitwardenOverlayButton": { - "message": "Bitwarden autofill menu button", + "message": "Bitwarden automatikus kitöltés menü gomb", "description": "Page title for the iframe containing the overlay button" }, "toggleBitwardenVaultOverlay": { - "message": "Toggle Bitwarden autofill menu", + "message": "Bitwarden automatikus kitöltés menü váltás", "description": "Screen reader and tool tip label for the overlay button" }, "bitwardenVault": { @@ -3989,7 +4099,7 @@ "description": "Page title in overlay" }, "unlockYourAccountToViewMatchingLogins": { - "message": "Unlock your account to view matching logins", + "message": "Az összeillő belépések megtekintéséhez oldja fel fiókját", "description": "Text to display in overlay when the account is locked." }, "unlockYourAccountToViewAutofillSuggestions": { @@ -4057,7 +4167,7 @@ "description": "Screen reader text (aria-label) for new identity button within inline menu" }, "bitwardenOverlayMenuAvailable": { - "message": "Bitwarden autofill menu available. Press the down arrow key to select.", + "message": "Bitwarden automatikus kitöltés menü elérhető. Nyomja meg a lefele nyilat a kiválasztáshoz.", "description": "Screen reader text for announcing when the overlay opens on the page" }, "turnOn": { @@ -4143,7 +4253,7 @@ } }, "duoHealthCheckResultsInNullAuthUrlError": { - "message": "Error connecting with the Duo service. Use a different two-step login method or contact Duo for assistance." + "message": "Hiba a Duo szolgáltatáshoz való kapcsolódáskor. Használjon másféle kétlépcsős bejelentkezést, vagy keresse fel a Duo ügyfélszolgálatot." }, "duoRequiredForAccount": { "message": "A fiókhoz kétlépcsős DUO bejelentkezés szükséges." @@ -4439,27 +4549,27 @@ "description": "Advanced option placeholder for uri option component" }, "confirmContinueToBrowserSettingsTitle": { - "message": "Continue to browser settings?", + "message": "Továbblépés a böngésző beállításokhoz?", "description": "Title for dialog which asks if the user wants to proceed to a relevant browser settings page" }, "confirmContinueToHelpCenter": { - "message": "Continue to Help Center?", + "message": "Továbblépés a Segítség Központba?", "description": "Title for dialog which asks if the user wants to proceed to a relevant Help Center page" }, "confirmContinueToHelpCenterPasswordManagementContent": { - "message": "Change your browser's autofill and password management settings.", + "message": "A böngésző automatikus kitöltés és jelszókezelési beállításainak módosítása.", "description": "Body content for dialog which asks if the user wants to proceed to the Help Center's page about browser password management settings" }, "confirmContinueToHelpCenterKeyboardShortcutsContent": { - "message": "You can view and set extension shortcuts in your browser's settings.", + "message": "Megtekintheti vagy beállíthatja a bővítmény billentyűparancsokat a böngésző beállításoknál.", "description": "Body content for dialog which asks if the user wants to proceed to the Help Center's page about browser keyboard shortcut settings" }, "confirmContinueToBrowserPasswordManagementSettingsContent": { - "message": "Change your browser's autofill and password management settings.", + "message": "A böngésző automatikus kitöltési és jelszókezelési beállításainak módosítása.", "description": "Body content for dialog which asks if the user wants to proceed to the browser's password management settings page" }, "confirmContinueToBrowserKeyboardShortcutSettingsContent": { - "message": "You can view and set extension shortcuts in your browser's settings.", + "message": "Megtekintheti vagy beállíthatja a bővítmény billentyűparancsokat a böngésző beállításoknál.", "description": "Body content for dialog which asks if the user wants to proceed to the browser's keyboard shortcut settings page" }, "overrideDefaultBrowserAutofillTitle": { @@ -4639,7 +4749,7 @@ "message": "Nincsenek másolandó értékek." }, "assignToCollections": { - "message": "Assign to collections" + "message": "Hozzárendelés gyűjteményhez" }, "copyEmail": { "message": "Email cím másolása" @@ -4702,7 +4812,7 @@ } }, "itemsWithNoFolder": { - "message": "Items with no folder" + "message": "Be nem mappázott elemek" }, "itemDetails": { "message": "Elem részletek" @@ -4711,7 +4821,7 @@ "message": "Elem neve" }, "organizationIsDeactivated": { - "message": "Organization is deactivated" + "message": "A szervezet deaktiválásra került" }, "owner": { "message": "Tulajdonos" @@ -4721,7 +4831,7 @@ "description": "Used as a label to indicate that the user is the owner of an item." }, "contactYourOrgAdmin": { - "message": "Items in deactivated organizations cannot be accessed. Contact your organization owner for assistance." + "message": "A deaktivált szervezetek elemeit nem lehet elérni. Keresse fel további segítségért a szervezet tulajdonosát." }, "additionalInformation": { "message": "További információ" @@ -4801,6 +4911,9 @@ "premium": { "message": "Prémium" }, + "unlockFeaturesWithPremium": { + "message": "A Prémium segítségével feloldhatjuk a jelentés készítést, a vészhelyzeti hozzáférést és a további biztonsági funkciókat." + }, "freeOrgsCannotUseAttachments": { "message": "Az ingyenes szervezetek nem használhatnak mellékleteket." }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Alapértelmezett ($VALUE$)", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "$WEBSITE$ egyező érzékelés megjelenítése", "placeholders": { @@ -4949,7 +5072,7 @@ "description": "ARIA label for the inline menu button that logs in with a passkey." }, "assign": { - "message": "Assign" + "message": "Hozzárendelés" }, "bulkCollectionAssignmentDialogDescriptionSingular": { "message": "Csak az ezekhez a gyűjteményekhez hozzáféréssel rendelkező szervezeti tagok láthatják az elemet." @@ -4958,7 +5081,7 @@ "message": "Csak az ezekhez a gyűjteményekhez hozzáféréssel rendelkező szervezeti tagok láthatják az elemeket." }, "bulkCollectionAssignmentWarning": { - "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "message": "$TOTAL_COUNT$ elemeket jelölt ki. Nem frissítheti a $READONLY_COUNT$ részét, mert nem rendelkezik szerkesztési jogosultsággal.", "placeholders": { "total_count": { "content": "$1", @@ -5056,13 +5179,13 @@ } }, "selectCollectionsToAssign": { - "message": "Select collections to assign" + "message": "A hozzárendeléshez jelöljön ki gyűjteményeket" }, "personalItemTransferWarningSingular": { - "message": "1 item will be permanently transferred to the selected organization. You will no longer own this item." + "message": "1 elem véglegesen áthelyezésre kerül a szervezethez. Többé nem Önhöz fog tartozni az elem." }, "personalItemsTransferWarningPlural": { - "message": "$PERSONAL_ITEMS_COUNT$ items will be permanently transferred to the selected organization. You will no longer own these items.", + "message": "$PERSONAL_ITEMS_COUNT$ elemek véglegesen áthelyezésre kerülnek a kiválasztott szervezethez. Többé nem Önhöz fognak tartozni az elemek.", "placeholders": { "personal_items_count": { "content": "$1", @@ -5071,7 +5194,7 @@ } }, "personalItemWithOrgTransferWarningSingular": { - "message": "1 item will be permanently transferred to $ORG$. You will no longer own this item.", + "message": "Ez az 1 elem véglegesen áthelyezésre kerül a $ORG$ szervezethez. Többé nem az Öné lesz az elem.", "placeholders": { "org": { "content": "$1", @@ -5080,7 +5203,7 @@ } }, "personalItemsWithOrgTransferWarningPlural": { - "message": "$PERSONAL_ITEMS_COUNT$ items will be permanently transferred to $ORG$. You will no longer own these items.", + "message": "$PERSONAL_ITEMS_COUNT$ elem véglegesen áthelyezésre kerül a $ORG$ szervezethez. Többé nem az Öné lesz az elem.", "placeholders": { "personal_items_count": { "content": "$1", @@ -5093,13 +5216,13 @@ } }, "successfullyAssignedCollections": { - "message": "Successfully assigned collections" + "message": "Sikerült a gyűjteményhez való hozzárendelés" }, "nothingSelected": { - "message": "You have not selected anything." + "message": "Nem választott ki semmit." }, "itemsMovedToOrg": { - "message": "Items moved to $ORGNAME$", + "message": "Elemek áthelyezve a $ORGNAME$ szervezethez", "placeholders": { "orgname": { "content": "$1", @@ -5108,7 +5231,7 @@ } }, "itemMovedToOrg": { - "message": "Item moved to $ORGNAME$", + "message": "Elem áthelyezve a $ORGNAME$ szervezethez", "placeholders": { "orgname": { "content": "$1", @@ -5269,7 +5392,7 @@ "message": "Feloldás biometrikusan" }, "authenticating": { - "message": "Authenticating" + "message": "Hitelesítés" }, "fillGeneratedPassword": { "message": "Generált jelszó kitöltés", @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Üdvözlet a széfben!" }, - "phishingPageTitle": { - "message": "Adathalász webhely" + "phishingPageTitleV2": { + "message": "Adathalászati kísérlet lett észlelve." }, - "phishingPageCloseTab": { + "phishingPageSummary": { + "message": "A meglátogatni kívánt webhely ismert rosszindulatú webhely és biztonsági kockázatot jelent." + }, + "phishingPageCloseTabV2": { "message": "Fül bezárása" }, - "phishingPageContinue": { - "message": "Folytatás" + "phishingPageContinueV2": { + "message": "Tovább erre a webhelyre (nem ajánlott)" }, - "phishingPageLearnWhy": { - "message": "Miért látható ez?" + "phishingPageExplanation1": { + "message": "Ez a webhely megtalálható volt ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": "listán, egy személyes és érzékeny információk ellopására használt ismert adathalász webhelyek nyílt forráskódú listáján.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "További információ az adathalászat észleléséről" + }, + "protectedBy": { + "message": "$PRODUCT$ által védett", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Az aktuális oldal elemeinek automatikus kitöltése" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "A Key Connector tartomány megerősítése" + }, + "atRiskLoginsSecured": { + "message": "Remek munka a kockázatos bejelentkezések biztosítása!" + }, + "upgradeNow": { + "message": "Áttérés most" + }, + "builtInAuthenticator": { + "message": "Beépített hitelesítő" + }, + "secureFileStorage": { + "message": "Biztonságos fájl tárolás" + }, + "emergencyAccess": { + "message": "Sürgősségi hozzáférés" + }, + "breachMonitoring": { + "message": "Adatszivárgás figyelés" + }, + "andMoreFeatures": { + "message": "És még több!" + }, + "planDescPremium": { + "message": "Teljes körű online biztonság" + }, + "upgradeToPremium": { + "message": "Áttérés Prémium csomagra" + }, + "unlockAdvancedSecurity": { + "message": "Fejlett biztonsági funkciók feloldása" + }, + "unlockAdvancedSecurityDesc": { + "message": "A prémium előfizetés több eszközt biztosít a biztonság és az irányítás megőrzéséhez." + }, + "explorePremium": { + "message": "Premium felfedezése" + }, + "loadingVault": { + "message": "Széf betöltése" + }, + "vaultLoaded": { + "message": "A széf betöltésre került." + }, + "settingDisabledByPolicy": { + "message": "Ezt a beállítást a szervezet házirendje letiltotta.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Irányítószám" + }, + "cardNumberLabel": { + "message": "Kártya szám" + }, + "sessionTimeoutSettingsAction": { + "message": "Időkifutási művelet" } } diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index b38b6f05628..88147f804d1 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Gunakan masuk tunggal" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Selamat datang kembali" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Atur ulang pencarian" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Edit" }, "view": { "message": "Tampilan" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Sandi utama tidak valid" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Batas Waktu Brankas" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Saat Komputer Terkunci" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Saat Peramban Dimulai Ulang" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Item yang Diedit" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Apakah Anda yakin ingin menghapus item ini?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Tanyakan untuk biometrik pada saat diluncurkan" }, - "premiumRequired": { - "message": "Membutuhkan Keanggotaan Premium" - }, - "premiumRequiredDesc": { - "message": "Keanggotaan premium diperlukan untuk menggunakan fitur ini." - }, "authenticationTimeout": { "message": "Batas waktu otentikasi" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Baca kunci keamanan" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Menunggu interaksi kunci keamanan..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Anda harus menambahkan antara URL dasar server atau paling tidak satu lingkungan ubahsuai." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Lingkungan Khusus" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Matikan isi otomatis" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Tampilkan saran isi otomatis pada kolom formulir" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Tampilkan identitas sebagai saran" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Tahun Kedaluwarsa" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Masa Berlaku" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Atur Kata Sandi Utama" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Galat" }, "decryptionError": { "message": "Kesalahan dekripsi" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden tidak bisa mendekripsi butir brankas yang tercantum di bawah." }, @@ -3970,6 +4071,15 @@ "message": "Isi otomatis ketika halaman dimuat telah diatur untuk menggunakan pengaturan bawaan.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Saklar bilah isi navigasi" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Organisasi gratis tidak dapat menggunakan lampiran" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Tampilkan deteksi kecocokan $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Selamat datang di brankas Anda!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Benda-benda isi otomatis untuk halaman saat ini" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index df4411ee42b..d4d032737b8 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Usa il Single Sign-On" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "La tua organizzazione richiede un accesso Single Sign-On (SSO)." + }, "welcomeBack": { "message": "Bentornato/a" }, @@ -550,32 +553,40 @@ "resetSearch": { "message": "Svuota ricerca" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archivio", + "description": "Noun" }, - "unarchive": { - "message": "Unarchive" + "archiveVerb": { + "message": "Archivia", + "description": "Verb" + }, + "unArchive": { + "message": "Togli dall'archivio" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Elementi in archivio" }, "noItemsInArchive": { - "message": "No items in archive" + "message": "Archivio vuoto" }, "noItemsInArchiveDesc": { - "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + "message": "Gli elementi archiviati compariranno qui e saranno esclusi dai risultati di ricerca e suggerimenti di autoriempimento." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Elemento archiviato" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Elemento rimosso dall'archivio" }, "archiveItem": { - "message": "Archive item" + "message": "Archivia elemento" }, "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "message": "Gli elementi archiviati sono esclusi dai risultati di ricerca e suggerimenti di autoriempimento. Vuoi davvero archiviare questo elemento?" + }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." }, "edit": { "message": "Modifica" @@ -583,8 +594,17 @@ "view": { "message": "Visualizza" }, + "viewAll": { + "message": "Mostra tutto" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "Vedi meno" + }, "viewLogin": { - "message": "View login" + "message": "Visualizza login" }, "noItemsInList": { "message": "Non ci sono elementi da mostrare." @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Password principale errata" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Password principale errata. Conferma che l'email sia corretta e che l'account sia stato creato su $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Timeout cassaforte" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Al blocco del computer" }, + "onIdle": { + "message": "Quando il sistema è inattivo" + }, + "onSleep": { + "message": "Quando il sistema è sospeso" + }, "onRestart": { "message": "Al riavvio del browser" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Elemento salvato" }, + "savedWebsite": { + "message": "Sito Web salvato" + }, + "savedWebsites": { + "message": "Siti Web salvati ( $COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Sei sicuro di voler eliminare questo elemento?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Richiedi dati biometrici all'avvio" }, - "premiumRequired": { - "message": "Premium necessario" - }, - "premiumRequiredDesc": { - "message": "Passa a Premium per utilizzare questa funzionalità." - }, "authenticationTimeout": { "message": "Timeout autenticazione" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Leggi chiave di sicurezza" }, + "readingPasskeyLoading": { + "message": "Lettura passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Autenticazione passkey fallita" + }, + "useADifferentLogInMethod": { + "message": "Usa un altro metodo di accesso" + }, "awaitingSecurityKeyInteraction": { "message": "In attesa di interazione con la chiave di sicurezza..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Devi aggiungere lo URL del server di base o almeno un ambiente personalizzato." }, + "selfHostedEnvMustUseHttps": { + "message": "Gli indirizzi devono usare il protocollo HTTPS." + }, "customEnvironment": { "message": "Ambiente personalizzato" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Disattiva il riempimento automatico" }, + "confirmAutofill": { + "message": "Conferma il riempimento automatico" + }, + "confirmAutofillDesc": { + "message": "Questo sito non corrisponde ai tuoi dati di accesso salvati. Prima di compilare le credenziali di accesso, assicurati che si tratti di un sito affidabile." + }, "showInlineMenuLabel": { "message": "Mostra suggerimenti di riempimento automatico nei campi del modulo" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "In che modo Bitwarden ti protegge dai pericoli del phising?" + }, + "currentWebsite": { + "message": "Sito web corrente" + }, + "autofillAndAddWebsite": { + "message": "Compila e aggiungi questo sito" + }, + "autofillWithoutAdding": { + "message": "Compila senza salvare" + }, + "doNotAutofill": { + "message": "Non compilare con il riempimento automatico" + }, "showInlineMenuIdentitiesLabel": { "message": "Mostra identità come consigli" }, @@ -1782,7 +1856,7 @@ "message": "Cliccare fuori del pop-up per controllare il codice di verifica nella tua email chiuderà questo pop-up. Vuoi aprire questo pop-up in una nuova finestra in modo che non si chiuda?" }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Mostra le icone dei siti e recupera gli URL di cambio password" }, "cardholderName": { "message": "Titolare della carta" @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Anno di scadenza" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Scadenza" }, @@ -1947,79 +2024,79 @@ "message": "Nota" }, "newItemHeaderLogin": { - "message": "New Login", + "message": "Nuovo Login", "description": "Header for new login item type" }, "newItemHeaderCard": { - "message": "New Card", + "message": "Nuova Carta", "description": "Header for new card item type" }, "newItemHeaderIdentity": { - "message": "New Identity", + "message": "Nuova Identità", "description": "Header for new identity item type" }, "newItemHeaderNote": { - "message": "New Note", + "message": "Nuova Nota", "description": "Header for new note item type" }, "newItemHeaderSshKey": { - "message": "New SSH key", + "message": "Nuova chiave SSH", "description": "Header for new SSH key item type" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Nuovo Send di testo", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Nuovo Send di File", "description": "Header for new file send" }, "editItemHeaderLogin": { - "message": "Edit Login", + "message": "Modifica Login", "description": "Header for edit login item type" }, "editItemHeaderCard": { - "message": "Edit Card", + "message": "Modifica Carta", "description": "Header for edit card item type" }, "editItemHeaderIdentity": { - "message": "Edit Identity", + "message": "Modifica Identità", "description": "Header for edit identity item type" }, "editItemHeaderNote": { - "message": "Edit Note", + "message": "Modifica Nota", "description": "Header for edit note item type" }, "editItemHeaderSshKey": { - "message": "Edit SSH key", + "message": "Modifica chiave SSH", "description": "Header for edit SSH key item type" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Modifica Send di Testo", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Modifica Send di File", "description": "Header for edit file send" }, "viewItemHeaderLogin": { - "message": "View Login", + "message": "Vedi Login", "description": "Header for view login item type" }, "viewItemHeaderCard": { - "message": "View Card", + "message": "Vedi Carta", "description": "Header for view card item type" }, "viewItemHeaderIdentity": { - "message": "View Identity", + "message": "Vedi Identità", "description": "Header for view identity item type" }, "viewItemHeaderNote": { - "message": "View Note", + "message": "Vedi Nota", "description": "Header for view note item type" }, "viewItemHeaderSshKey": { - "message": "View SSH key", + "message": "Vedi chiave SSH", "description": "Header for view SSH key item type" }, "passwordHistory": { @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Questa pagina sta interferendo con Bitwarden. Il menu in linea di Bitwarden è stato temporaneamente disabilitato come misura di sicurezza." + }, "setMasterPassword": { "message": "Imposta password principale" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Solo la vault dell'organizzazione associata con $ORGANIZATION$ sarà esportata.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Solo la vault dell'organizzazione associata con $ORGANIZATION$ sarà esportata. Le tue raccolte non saranno incluse.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Errore" }, "decryptionError": { "message": "Errore di decifrazione" }, + "errorGettingAutoFillData": { + "message": "Errore: impossibile accedere ai dati per il riempimento automatico" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden non può decifrare gli elementi elencati di seguito." }, @@ -3970,6 +4071,15 @@ "message": "Riempimento automatico al caricamento della pagina impostato con l'impostazione predefinita.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Impossibile usare il riempimento automatico" + }, + "cannotAutofillExactMatch": { + "message": "La corrispondenza predefinita è impostata su 'Corrispondenza esatta'. Il sito Web corrente non corrisponde esattamente ai dettagli di accesso salvati per questo elemento." + }, + "okay": { + "message": "OK" + }, "toggleSideNavigation": { "message": "Attiva/Disattiva navigazione laterale" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Sblocca reportistica, accesso d'emergenza e altre funzionalità di sicurezza con Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Le organizzazioni gratis non possono utilizzare gli allegati" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Predefinito ($VALUE$)", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Mostra corrispondenza $WEBSITE$", "placeholders": { @@ -5485,10 +5608,10 @@ "message": "Cambia parola d'accesso a rischio" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "message": "Questo login è a rischio e non contiene un sito web. Aggiungi un sito web e cambia la password per maggiore sicurezza." }, "missingWebsite": { - "message": "Missing website" + "message": "Sito web mancante" }, "settingsVaultOptions": { "message": "Opzioni cassaforte" @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Benvenuto nella tua cassaforte!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Tentativo di phishing rilevato" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "Stai cercando di visitare un sito dannoso noto che può mettere a rischio la tua sicurezza." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Chiudi tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Vai al sito (SCONSIGLIATO!)" + }, + "phishingPageExplanation1": { + "message": "Questo sito è stato trovato in ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", un elenco open-source di siti di phishing noti per il furto di informazioni personali e sensibili.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Scopri di più sul rilevamento di phishing" + }, + "protectedBy": { + "message": "Protetto da $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Riempimento automatico per questa pagina" @@ -5626,10 +5769,10 @@ "description": "Aria label for the body content of the generator nudge" }, "aboutThisSetting": { - "message": "About this setting" + "message": "Riguardo questa opzione" }, "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." + "message": "Bitwarden userà gli URL memorizzati in ogni login per mostrare, se possibile, l'icona del sito Web e l'URL di modifica password per facilitare la modifica delle credenziali. Nessuna informazione è raccolta o archiviata per il funzionamento di questo servizio." }, "noPermissionsViewPage": { "message": "Non hai i permessi per visualizzare questa pagina. Prova ad accedere con un altro account." @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Conferma dominio Key Connector" + }, + "atRiskLoginsSecured": { + "message": "Ottimo lavoro, i tuoi dati di accesso sono al sicuro!" + }, + "upgradeNow": { + "message": "Aggiorna ora" + }, + "builtInAuthenticator": { + "message": "App di autenticazione integrata" + }, + "secureFileStorage": { + "message": "Archiviazione sicura di file" + }, + "emergencyAccess": { + "message": "Accesso di emergenza" + }, + "breachMonitoring": { + "message": "Monitoraggio delle violazioni" + }, + "andMoreFeatures": { + "message": "E molto altro!" + }, + "planDescPremium": { + "message": "Sicurezza online completa" + }, + "upgradeToPremium": { + "message": "Aggiorna a Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Scopri Premium" + }, + "loadingVault": { + "message": "Caricamento cassaforte" + }, + "vaultLoaded": { + "message": "Cassaforte caricata" + }, + "settingDisabledByPolicy": { + "message": "Questa impostazione è disabilitata dalle restrizioni della tua organizzazione.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "CAP" + }, + "cardNumberLabel": { + "message": "Numero di carta" + }, + "sessionTimeoutSettingsAction": { + "message": "Azione al timeout" } } diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 5305a265781..c13b139e13a 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -3,7 +3,7 @@ "message": "Bitwarden" }, "appLogoLabel": { - "message": "Bitwarden logo" + "message": "Bitwarden ロゴ" }, "extName": { "message": "Bitwarden パスワードマネージャー", @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "シングルサインオンを使用する" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "あなたの組織ではシングルサインオン (SSO) を使用する必要があります。" + }, "welcomeBack": { "message": "ようこそ" }, @@ -550,11 +553,16 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "アーカイブ", + "description": "Noun" }, - "unarchive": { - "message": "Unarchive" + "archiveVerb": { + "message": "アーカイブ", + "description": "Verb" + }, + "unArchive": { + "message": "アーカイブ解除" }, "itemsInArchive": { "message": "Items in archive" @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,14 +585,26 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "編集" }, "view": { "message": "表示" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { - "message": "View login" + "message": "ログイン情報を表示" }, "noItemsInList": { "message": "表示するアイテムがありません" @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "マスターパスワードが間違っています" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "保管庫のタイムアウト" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "ロック時" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "ブラウザ再起動時" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "編集されたアイテム" }, + "savedWebsite": { + "message": "保存されたウェブサイト" + }, + "savedWebsites": { + "message": "保存されたウェブサイト ($COUNT$ 件)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "このアイテムを削除しますか?" }, @@ -1113,10 +1160,10 @@ "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": { @@ -1158,11 +1205,11 @@ "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": { @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "起動時に生体認証を要求する" }, - "premiumRequired": { - "message": "プレミアム会員専用" - }, - "premiumRequiredDesc": { - "message": "この機能を使うにはプレミアム会員になってください。" - }, "authenticationTimeout": { "message": "認証のタイムアウト" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "セキュリティキーの読み取り" }, + "readingPasskeyLoading": { + "message": "パスキーを読み込み中…" + }, + "passkeyAuthenticationFailed": { + "message": "パスキー認証に失敗しました" + }, + "useADifferentLogInMethod": { + "message": "別のログイン方法を使用する" + }, "awaitingSecurityKeyInteraction": { "message": "セキュリティキーとの通信を待ち受け中…" }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "ベース サーバー URL または少なくとも 1 つのカスタム環境を追加する必要があります。" }, + "selfHostedEnvMustUseHttps": { + "message": "URL は HTTPS を使用する必要があります。" + }, "customEnvironment": { "message": "カスタム環境" }, @@ -1651,11 +1704,32 @@ } }, "turnOffAutofill": { - "message": "Turn off autofill" + "message": "自動入力をオフにする" + }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." }, "showInlineMenuLabel": { "message": "フォームフィールドに自動入力の候補を表示する" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "自動入力しない" + }, "showInlineMenuIdentitiesLabel": { "message": "ID を候補として表示する" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "有効期限年" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "有効期限" }, @@ -1842,7 +1919,7 @@ "message": "セキュリティコード" }, "cardNumber": { - "message": "card number" + "message": "カード番号" }, "ex": { "message": "例:" @@ -1944,30 +2021,30 @@ "message": "SSH 鍵" }, "typeNote": { - "message": "Note" + "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": "新しい Send テキスト", "description": "Header for new text send" }, "newItemHeaderFileSend": { @@ -1975,11 +2052,11 @@ "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": { @@ -1987,15 +2064,15 @@ "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": "Send テキストを編集", "description": "Header for edit text send" }, "editItemHeaderFileSend": { @@ -2003,11 +2080,11 @@ "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": { @@ -2015,11 +2092,11 @@ "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" }, "passwordHistory": { @@ -2278,7 +2355,7 @@ "message": "このパスワードを使用する" }, "useThisPassphrase": { - "message": "Use this passphrase" + "message": "このパスフレーズを使用" }, "useThisUsername": { "message": "このユーザー名を使用する" @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "マスターパスワードを設定" }, @@ -2595,7 +2675,7 @@ "message": "変更" }, "changePassword": { - "message": "Change password", + "message": "パスワードを変更", "description": "Change password button for browser at risk notification on login." }, "changeButtonTitle": { @@ -2608,7 +2688,7 @@ } }, "atRiskPassword": { - "message": "At-risk password" + "message": "リスクがあるパスワード" }, "atRiskPasswords": { "message": "リスクがあるパスワード" @@ -2784,7 +2864,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "maxAccessCountReached": { - "message": "Max access count reached", + "message": "最大アクセス数に達しました", "description": "This text will be displayed after a Send has been accessed the maximum amount of times." }, "hideTextByDefault": { @@ -3134,7 +3214,7 @@ "message": "A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator." }, "organizationName": { - "message": "Organization name" + "message": "組織名" }, "keyConnectorDomain": { "message": "Key Connector domain" @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "エラー" }, "decryptionError": { "message": "復号エラー" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden は以下の保管庫のアイテムを復号できませんでした。" }, @@ -3276,7 +3377,7 @@ "message": "サービス" }, "forwardedEmail": { - "message": "転送されたメールエイリアス" + "message": "転送されるメールエイリアス" }, "forwardedEmailDesc": { "message": "外部転送サービスを使用してメールエイリアスを生成します。" @@ -3525,7 +3626,7 @@ "message": "リクエストが送信されました" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "$EMAIL$ に $DEVICE$ でのログインを承認しました", "placeholders": { "email": { "content": "$1", @@ -3538,16 +3639,16 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "別のデバイスからのログイン試行を拒否しました。自分自身である場合は、もう一度デバイスでログインしてください。" }, "device": { - "message": "Device" + "message": "デバイス" }, "loginStatus": { - "message": "Login status" + "message": "ログイン状態" }, "masterPasswordChanged": { - "message": "Master password saved" + "message": "マスターパスワードが保存されました" }, "exposedMasterPassword": { "message": "流出したマスターパスワード" @@ -3640,28 +3741,28 @@ "message": "このデバイスを記憶して今後のログインをシームレスにする" }, "manageDevices": { - "message": "Manage devices" + "message": "デバイスを管理" }, "currentSession": { - "message": "Current session" + "message": "現在のセッション" }, "mobile": { - "message": "Mobile", + "message": "モバイル", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "拡張機能", "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "デスクトップ", "description": "Desktop app" }, "webVault": { - "message": "Web vault" + "message": "ウェブ保管庫" }, "webApp": { - "message": "Web app" + "message": "Web アプリ" }, "cli": { "message": "CLI" @@ -3671,22 +3772,22 @@ "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "保留中のリクエスト" }, "firstLogin": { - "message": "First login" + "message": "初回ログイン" }, "trusted": { - "message": "Trusted" + "message": "信頼済み" }, "needsApproval": { - "message": "Needs approval" + "message": "承認が必要" }, "devices": { - "message": "Devices" + "message": "デバイス" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "$EMAIL$ によるログインの試行", "placeholders": { "email": { "content": "$1", @@ -3695,31 +3796,31 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "アクセスの確認" }, "denyAccess": { - "message": "Deny access" + "message": "アクセスを拒否" }, "time": { - "message": "Time" + "message": "時間" }, "deviceType": { - "message": "Device Type" + "message": "デバイス種別" }, "loginRequest": { - "message": "Login request" + "message": "ログインリクエスト" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "このリクエストは無効になりました。" }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "ログインリクエストの有効期限が切れています。" }, "justNow": { - "message": "Just now" + "message": "たった今" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "$MINUTES$ 分前に要求されました", "placeholders": { "minutes": { "content": "$1", @@ -3749,7 +3850,7 @@ "message": "管理者の承認を要求する" }, "unableToCompleteLogin": { - "message": "Unable to complete login" + "message": "ログインを完了できません" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { "message": "You need to log in on a trusted device or ask your administrator to assign you a password." @@ -3819,13 +3920,13 @@ "message": "Trust organization" }, "trust": { - "message": "Trust" + "message": "信頼する" }, "doNotTrust": { - "message": "Do not trust" + "message": "信頼しない" }, "organizationNotTrusted": { - "message": "Organization is not trusted" + "message": "組織は信頼されていません" }, "emergencyAccessTrustWarning": { "message": "For the security of your account, only confirm if you have granted emergency access to this user and their fingerprint matches what is displayed in their account" @@ -3840,11 +3941,11 @@ "message": "Trust user" }, "sendsTitleNoItems": { - "message": "Send sensitive information safely", + "message": "機密情報を安全に送信", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsBodyNoItems": { - "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", + "message": "どのプラットフォームでも、誰とでも安全にファイルとデータを共有できます。流出を防止しながら、あなたの情報はエンドツーエンドで暗号化されます。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "inputRequired": { @@ -3970,6 +4071,15 @@ "message": "ページ読み込み時の自動入力はデフォルトの設定を使うよう設定しました。", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "自動入力できません" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "OK" + }, "toggleSideNavigation": { "message": "サイドナビゲーションの切り替え" }, @@ -4179,10 +4289,10 @@ "message": "コレクションを選択" }, "importTargetHintCollection": { - "message": "Select this option if you want the imported file contents moved to a collection" + "message": "インポートしたファイルコンテンツをコレクションに移動したい場合は、このオプションを選択してください" }, "importTargetHintFolder": { - "message": "Select this option if you want the imported file contents moved to a folder" + "message": "インポートしたファイルコンテンツをフォルダーに移動したい場合は、このオプションを選択してください" }, "importUnassignedItemsError": { "message": "割り当てられていないアイテムがファイルに含まれています。" @@ -4419,7 +4529,7 @@ "description": "Label indicating the most common import formats" }, "uriMatchDefaultStrategyHint": { - "message": "URI match detection is how Bitwarden identifies autofill suggestions.", + "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": { @@ -4435,7 +4545,7 @@ "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "高度な設定", "description": "Advanced option placeholder for uri option component" }, "confirmContinueToBrowserSettingsTitle": { @@ -4622,7 +4732,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "$FIELD$ ($CIPHERNAME$) をコピー", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -4769,31 +4879,31 @@ } }, "downloadBitwarden": { - "message": "Download Bitwarden" + "message": "Bitwarden をダウンロード" }, "downloadBitwardenOnAllDevices": { - "message": "Download Bitwarden on all devices" + "message": "すべてのデバイスに Bitwarden をダウンロード" }, "getTheMobileApp": { - "message": "Get the mobile app" + "message": "モバイルアプリを入手" }, "getTheMobileAppDesc": { "message": "Access your passwords on the go with the Bitwarden mobile app." }, "getTheDesktopApp": { - "message": "Get the desktop app" + "message": "デスクトップアプリを入手" }, "getTheDesktopAppDesc": { "message": "Access your vault without a browser, then set up unlock with biometrics to expedite unlocking in both the desktop app and browser extension." }, "downloadFromBitwardenNow": { - "message": "Download from bitwarden.com now" + "message": "bitwarden.com から今すぐダウンロード" }, "getItOnGooglePlay": { - "message": "Get it on Google Play" + "message": "Google Play で入手" }, "downloadOnTheAppStore": { - "message": "Download on the App Store" + "message": "App Store からダウンロード" }, "permanentlyDeleteAttachmentConfirmation": { "message": "この添付ファイルを完全に削除してもよろしいですか?" @@ -4801,6 +4911,9 @@ "premium": { "message": "プレミアム" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "無料の組織は添付ファイルを使用できません" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "既定 ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "一致検出 $WEBSITE$を表示", "placeholders": { @@ -5149,7 +5272,7 @@ "message": "拡張機能アイコンにログイン自動入力の候補の数を表示する" }, "accountAccessRequested": { - "message": "Account access requested" + "message": "アカウントへのアクセスが要求されました" }, "confirmAccessAttempt": { "message": "Confirm access attempt for $EMAIL$", @@ -5266,7 +5389,7 @@ "message": "Unlock PIN set" }, "unlockWithBiometricSet": { - "message": "Unlock with biometrics set" + "message": "生体認証でロック解除を設定しました" }, "authenticating": { "message": "認証中" @@ -5280,7 +5403,7 @@ "description": "Notification message for when a password has been regenerated" }, "saveToBitwarden": { - "message": "Save to Bitwarden", + "message": "Bitwarden へ保存", "description": "Confirmation message for saving a login to Bitwarden" }, "spaceCharacterDescriptor": { @@ -5488,13 +5611,13 @@ "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, "missingWebsite": { - "message": "Missing website" + "message": "ウェブサイトがありません" }, "settingsVaultOptions": { "message": "保管庫オプション" }, "emptyVaultDescription": { - "message": "The vault protects more than just your passwords. Store secure logins, IDs, cards and notes securely here." + "message": "保管庫はパスワードだけではなく、ログイン情報、ID、カード、メモを安全に保管できます。" }, "introCarouselLabel": { "message": "Bitwarden へようこそ" @@ -5524,31 +5647,51 @@ "message": "Bitwarden のモバイル、ブラウザ、デスクトップアプリでは、保存できるパスワード数やデバイス数に制限はありません。" }, "nudgeBadgeAria": { - "message": "1 notification" + "message": "1件の通知" }, "emptyVaultNudgeTitle": { - "message": "Import existing passwords" + "message": "既存のパスワードをインポート" }, "emptyVaultNudgeBody": { "message": "Use the importer to quickly transfer logins to Bitwarden without manually adding them." }, "emptyVaultNudgeButton": { - "message": "Import now" + "message": "今すぐインポート" }, "hasItemsVaultNudgeTitle": { - "message": "Welcome to your vault!" + "message": "保管庫へようこそ!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "このタブを閉じる" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "$PRODUCT$ によって保護されています", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5568,7 +5711,7 @@ "example": "Include a Website so this login appears as an autofill suggestion." }, "newLoginNudgeBodyBold": { - "message": "Website", + "message": "ウェブサイト", "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." }, @@ -5596,20 +5739,20 @@ "message": "With notes, securely store sensitive data like banking or insurance details." }, "newSshNudgeTitle": { - "message": "Developer-friendly SSH access" + "message": "開発者フレンドリーの SSH アクセス" }, "newSshNudgeBodyOne": { - "message": "Store your keys and connect with the SSH agent for fast, encrypted authentication.", + "message": "SSHエージェントにキーを登録することで、高速かつ暗号化された認証が可能になります。", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "newSshNudgeBodyTwo": { - "message": "Learn more about SSH agent", + "message": "SSH エージェントに関する詳細", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "generatorNudgeTitle": { - "message": "Quickly create passwords" + "message": "パスワードをすばやく作成" }, "generatorNudgeBodyOne": { "message": "Easily create strong and unique passwords by clicking on", @@ -5626,7 +5769,7 @@ "description": "Aria label for the body content of the generator nudge" }, "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." @@ -5639,13 +5782,13 @@ "description": "'WebAssembly' is a technical term and should not be translated." }, "showMore": { - "message": "Show more" + "message": "もっと見る" }, "showLess": { - "message": "Show less" + "message": "隠す" }, "next": { - "message": "Next" + "message": "次へ" }, "moreBreadcrumbs": { "message": "More breadcrumbs", @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "今すぐアップグレード" + }, + "builtInAuthenticator": { + "message": "認証機を内蔵" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "緊急アクセス" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "プレミアムにアップグレード" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "郵便番号" + }, + "cardNumberLabel": { + "message": "カード番号" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index b759d674cca..a54a0f2c657 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -162,7 +165,7 @@ "message": "Copy passport number" }, "copyLicenseNumber": { - "message": "Copy license number" + "message": "დააკოპირეთ ლიცენზიის ნომერი" }, "copyPrivateKey": { "message": "Copy private key" @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "ჩასწორება" }, "view": { "message": "ხედი" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Vault timeout" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "Premium required" - }, - "premiumRequiredDesc": { - "message": "A Premium membership is required to use this feature." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "ვადა" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "შეცდომა" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "პრემიუმი" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index 78a49021a0c..f829937ac51 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Edit" }, "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Vault timeout" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "Premium required" - }, - "premiumRequiredDesc": { - "message": "A Premium membership is required to use this feature." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 1311a97df68..bd2be23828c 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "ಎಡಿಟ್" }, "view": { "message": "ವೀಕ್ಷಣೆ" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "ಅಮಾನ್ಯ ಮಾಸ್ಟರ್ ಪಾಸ್‌ವರ್ಡ್" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "ವಾಲ್ಟ್ ಕಾಲಾವಧಿ" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "ಸಿಸ್ಟಮ್ ಲಾಕ್‌ನಲ್ಲಿ" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "ಬ್ರೌಸರ್ ಮರುಪ್ರಾರಂಭದಲ್ಲಿ" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "ಐಟಂ ಸಂಪಾದಿಸಲಾಗಿದೆ" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "ನೀವು ನಿಜವಾಗಿಯೂ ಅನುಪಯುಕ್ತಕ್ಕೆ ಕಳುಹಿಸಲು ಬಯಸುವಿರಾ?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "ಪ್ರೀಮಿಯಂ ಅಗತ್ಯವಿದೆ" - }, - "premiumRequiredDesc": { - "message": "ಈ ವೈಶಿಷ್ಟ್ಯವನ್ನು ಬಳಸಲು ಪ್ರೀಮಿಯಂ ಸದಸ್ಯತ್ವ ಅಗತ್ಯವಿದೆ." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "ಕಸ್ಟಮ್ ಪರಿಸರ" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "ಮುಕ್ತಾಯ ವರ್ಷ" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "ಮುಕ್ತಾಯ" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "ಮಾಸ್ಟರ್ ಪಾಸ್ವರ್ಡ್ ಹೊಂದಿಸಿ" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index 06611be0282..1ad08accfc9 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "통합인증(SSO) 사용하기" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "돌아온 것을 환영합니다." }, @@ -189,7 +192,7 @@ "message": "노트 복사" }, "copy": { - "message": "Copy", + "message": "복사", "description": "Copy to clipboard" }, "fill": { @@ -383,7 +386,7 @@ "message": "폴더 편집" }, "editFolderWithName": { - "message": "Edit folder: $FOLDERNAME$", + "message": "폴더 편집: $FOLDERNAME$", "placeholders": { "foldername": { "content": "$1", @@ -471,10 +474,10 @@ "message": "패스프레이즈 생성됨" }, "usernameGenerated": { - "message": "Username generated" + "message": "사용자 이름 생성" }, "emailGenerated": { - "message": "Email generated" + "message": "이메일 생성" }, "regeneratePassword": { "message": "비밀번호 재생성" @@ -548,34 +551,42 @@ "message": "보관함 검색" }, "resetSearch": { - "message": "Reset search" + "message": "검색 초기화" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "보관", + "description": "Noun" }, - "unarchive": { - "message": "Unarchive" + "archiveVerb": { + "message": "보관", + "description": "Verb" + }, + "unArchive": { + "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": "보관된 항목은 여기에 표시되며 일반 검색 결과 및 자동 완성 제안에서 제외됩니다." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "항목이 보관함으로 이동되었습니다" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "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?" + "message": "보관된 항목은 일반 검색 결과와 자동 완성 제안에서 제외됩니다. 이 항목을 보관하시겠습니까?" + }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." }, "edit": { "message": "편집" @@ -583,8 +594,17 @@ "view": { "message": "보기" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { - "message": "View login" + "message": "로그인 보기" }, "noItemsInList": { "message": "항목이 없습니다." @@ -689,7 +709,7 @@ "message": "사용하고 있는 웹 브라우저가 쉬운 클립보드 복사를 지원하지 않습니다. 직접 복사하세요." }, "verifyYourIdentity": { - "message": "Verify your identity" + "message": "신원을 인증하세요" }, "weDontRecognizeThisDevice": { "message": "We don't recognize this device. Enter the code sent to your email to verify your identity." @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "잘못된 마스터 비밀번호" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "보관함 시간 제한" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "시스템 잠금 시" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "브라우저 재시작 시" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "항목 편집함" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "정말로 휴지통으로 이동시킬까요?" }, @@ -1222,7 +1269,7 @@ "message": "웹사이트에서 변경 사항이 감지되면 로그인 비밀번호를 업데이트하라는 메시지를 표시합니다. 모든 로그인된 계정에 적용됩니다." }, "enableUsePasskeys": { - "message": "패스키를 저장 및 사용할지 묻기" + "message": "패스키 저장 및 사용 확인" }, "usePasskeysDesc": { "message": "보관함에 새 패스키를 저장하거나 로그인할지 물어봅니다. 모든 로그인된 계정에 적용됩니다." @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "실행 시 생체 인증 요구하기" }, - "premiumRequired": { - "message": "프리미엄 멤버십 필요" - }, - "premiumRequiredDesc": { - "message": "이 기능을 사용하려면 프리미엄 멤버십이 필요합니다." - }, "authenticationTimeout": { "message": "인증 시간 초과" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "기본 서버 URL이나 최소한 하나의 사용자 지정 환경을 추가해야 합니다." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "사용자 지정 환경" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "양식 필드에 자동 완성 제안 표시" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "신원을 제안으로 표시" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "만료 연도" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "만료" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "마스터 비밀번호 설정" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "오류" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3840,7 +3941,7 @@ "message": "Trust user" }, "sendsTitleNoItems": { - "message": "Send sensitive information safely", + "message": "민감한 정보 안전하게 보내세요", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsBodyNoItems": { @@ -3970,6 +4071,15 @@ "message": "페이지 로드 시 자동 완성이 기본 설정을 사용하도록 설정되었습니다.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "사이드 내비게이션 전환" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "프리미엄" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "무료 조직에서는 첨부 파일을 사용할 수 없습니다." }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "$WEBSITE$ 일치 인식 보이기", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index 464fa5aae92..46ffb24e3df 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Naudoti vieningo prisijungimo sistemą" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Sveiki sugrįžę" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Keisti" }, "view": { "message": "Peržiūrėti" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Neteisingas pagrindinis slaptažodis" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Atsijungta nuo saugyklos" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Užrakinant sistemą" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Paleidus iš naujo naršyklę" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Redaguotas elementas" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Ar tikrai norite perkelti į šiukšlinę?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Paleidžiant patvirtinti biometrinius duomenis" }, - "premiumRequired": { - "message": "Premium reikalinga" - }, - "premiumRequiredDesc": { - "message": "Premium narystė reikalinga šiai funkcijai naudoti." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Individualizuota aplinka" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Galiojimo pabaigos metai" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Galiojimo pabaiga" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Pagrindinio slaptažodžio nustatymas" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Klaida" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Automatinis pildymas įkeliant puslapį nustatytas naudoti numatytąjį nustatymą.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Perjungti šoninę naršymą" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "„Premium“" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Nemokamos organizacijos negali naudoti priedų" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index 99edb486d9d..754781dc4f7 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Izmantot vienoto pieteikšanos" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Tava apvienība pieprasa vienoto pieteikšanos." + }, "welcomeBack": { "message": "Laipni lūdzam atpakaļ" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Atiestatīt meklēšanu" }, - "archive": { - "message": "Arhivēt" + "archiveNoun": { + "message": "Arhīvs", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Arhivēt", + "description": "Verb" + }, + "unArchive": { "message": "Atcelt arhivēšanu" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Šeit parādīsies arhivētie vienumi, un tie netiks iekļauti vispārējās meklēšanas iznākumos un automātiskās aizpildes ieteikumos." }, - "itemSentToArchive": { - "message": "Vienums ievietots arhīvā" + "itemWasSentToArchive": { + "message": "Vienums tika ievietots arhīvā" }, - "itemRemovedFromArchive": { - "message": "Vienums izņemts no arhīva" + "itemUnarchived": { + "message": "Vienums tika izņemts no arhīva" }, "archiveItem": { "message": "Arhivēt vienumu" @@ -577,12 +585,24 @@ "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?" }, + "upgradeToUseArchive": { + "message": "Ir nepieciešama Premium dalība, lai izmantotu arhīvu." + }, "edit": { "message": "Labot" }, "view": { "message": "Skatīt" }, + "viewAll": { + "message": "Apskatīt visu" + }, + "showAll": { + "message": "Rādīt visu" + }, + "viewLess": { + "message": "Skatīt mazāk" + }, "viewLogin": { "message": "Apskatīt pieteikšanās vienumu" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Nederīga galvenā parole" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Nederīga galvenā parole. Jāpārliecinās, ka e-pasta adrese ir pareiza un konts tika izveidots $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Glabātavas noildze" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Pēc sistēmas aizslēgšanas" }, + "onIdle": { + "message": "Sistēmas dīkstāvē" + }, + "onSleep": { + "message": "Pēc sistēmas iemigšanas" + }, "onRestart": { "message": "Pēc pārlūka pārsāknēšanas" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Vienums labots" }, + "savedWebsite": { + "message": "Saglabāta tīmekļvietne" + }, + "savedWebsites": { + "message": "Saglabātas tīmekļvietnes ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Vai tiešām pārvietot uz atkritni?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Palaižot vaicāt biometriju" }, - "premiumRequired": { - "message": "Nepieciešams Premium" - }, - "premiumRequiredDesc": { - "message": "Ir nepieciešama Premium dalība, lai izmantotu šo iespēju." - }, "authenticationTimeout": { "message": "Autentificēšanās noildze" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Nolasīt drošības atslēgu" }, + "readingPasskeyLoading": { + "message": "Nolasa piekļuves atslēgu..." + }, + "passkeyAuthenticationFailed": { + "message": "Autentificēšanās ar piekļuves atslēgu neizdevās" + }, + "useADifferentLogInMethod": { + "message": "Jāizmanto cits pieteikšanās veids" + }, "awaitingSecurityKeyInteraction": { "message": "Gaida mijiedarbību ar drošības atslēgu..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Jāpievieno vai no servera pamata URL vai vismaz viena pielāgota vide." }, + "selfHostedEnvMustUseHttps": { + "message": "URL ir jābūt HTTPS." + }, "customEnvironment": { "message": "Pielāgota vide" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Izslēgt automātisko aizpildi" }, + "confirmAutofill": { + "message": "Apstiprināt automātisko aizpildi" + }, + "confirmAutofillDesc": { + "message": "Šī vietne neatbilst saglabātā pieteikšanās vienumam. Pirms pieteikšanās datu aizpildīšanas jāpārliecinās, ka tā ir uzticama." + }, "showInlineMenuLabel": { "message": "Rādīt automātiskās aizpildes ieteikumuis veidlapu laukos" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Kā Bitwarden aizsargā datus no pikšķerēšanas?" + }, + "currentWebsite": { + "message": "Pašreizējā tīmekļvietne" + }, + "autofillAndAddWebsite": { + "message": "Automātiski aizpildīt un pievienot šo tīmekļvietni" + }, + "autofillWithoutAdding": { + "message": "Automātiski aizpildīt bez pievienošanas" + }, + "doNotAutofill": { + "message": "Neaizpildīt automātiski" + }, "showInlineMenuIdentitiesLabel": { "message": "Attēlot identitātes kā ieteikumus" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Derīguma gads" }, + "monthly": { + "message": "mēnesī" + }, "expiration": { "message": "Derīgums" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Šī lapa traucē Bitwarden darbību. Bitwarden iekļautā izvēlne ir īslaicīgi atspējot kā drošības mērs." + }, "setMasterPassword": { "message": "Uzstādīt galveno paroli" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Tiks izgūta tikai apvienības glabātava, kas ir saistīta ar $ORGANIZATION$.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Tiks izgūta tikai apvienības glabātava, kas ir saistīta ar $ORGANIZATION$. Mani vienumu krājumi netiks iekļauti.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Kļūda" }, "decryptionError": { "message": "Atšifrēšanas kļūda" }, + "errorGettingAutoFillData": { + "message": "Kļūda automātiskās aizpildes datu iegūšanā" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden nevarēja atšifrēt zemāk uzskaitītos glabātavas vienumus." }, @@ -3970,6 +4071,15 @@ "message": "Automātiskā aizpilde lapas ielādes brīdī iestatīta izmantot noklusējuma iestatījumu.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Nevar automātiski aizpildīt" + }, + "cannotAutofillExactMatch": { + "message": "Noklusējuma atbilstības noteikšana ir iestatīta uz “Pilnīga atbilstība”. Pašreizējā tīmekļvietne pilnībā neabilst saglabātajai pieteikšanās informācijai šajā vienumā." + }, + "okay": { + "message": "Labi" + }, "toggleSideNavigation": { "message": "Pārslēgt sānu pārvietošanās joslu" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Iegūsti piekļuvi atskaitēm, ārkārtas piekļuvei un citām drošības iespējām ar Premium!" + }, "freeOrgsCannotUseAttachments": { "message": "Bezmaksas apvienības nevar izmantot pielikumus" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Noklusējums ($VALUE$)", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Rādīt atbilstības noteikšanu $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Laipni lūdzam Tavā glabātavā!" }, - "phishingPageTitle": { - "message": "Pikšķerēšanas tīmekļvietne" + "phishingPageTitleV2": { + "message": "Noteikts pikšķerēšanas mēģinājums" }, - "phishingPageCloseTab": { - "message": "Aizvērt cilni" + "phishingPageSummary": { + "message": "Vietne, kuru mēģini apmeklēt, ir zināma ļaunprātīga vietne un drošības risks." }, - "phishingPageContinue": { - "message": "Turpināt" + "phishingPageCloseTabV2": { + "message": "Aizvērt šo cilni" }, - "phishingPageLearnWhy": { - "message": "Kāpēc šis ir redzams?" + "phishingPageContinueV2": { + "message": "Turpināt šīs vietnes apmeklēšanu (nav ieteicams)" + }, + "phishingPageExplanation1": { + "message": "Šī vietne tika atrasta ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", atvērta pirmkoda saraksts ar zināmām pikšķerēšanas vietnēm, kuras tiek izmantotas personīgas un jūtīgas informācijas zagšanai.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Uzzināt vairāk par pikšķerēšanas noteikšanu" + }, + "protectedBy": { + "message": "Aizsargā $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Automātiska pašreizējās lapas vienumu aizpildīšana" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Apstiprināt Key Connector domēnu" + }, + "atRiskLoginsSecured": { + "message": "Labs darbs riskam pakļauto pieteikšanās vienumu drošības uzlabošanā!" + }, + "upgradeNow": { + "message": "Uzlabot tagad" + }, + "builtInAuthenticator": { + "message": "Iebūvēts autentificētājs" + }, + "secureFileStorage": { + "message": "Droša datņu krātuve" + }, + "emergencyAccess": { + "message": "Ārkārtas piekļuve" + }, + "breachMonitoring": { + "message": "Noplūžu pārraudzīšana" + }, + "andMoreFeatures": { + "message": "Un vēl!" + }, + "planDescPremium": { + "message": "Pilnīga drošība tiešsaistē" + }, + "upgradeToPremium": { + "message": "Uzlabot uz Premium" + }, + "unlockAdvancedSecurity": { + "message": "Atslēdz papildu drošības iespējas" + }, + "unlockAdvancedSecurityDesc": { + "message": "Premium abonements sniedz vairāk rīku drošības uzturēšanai un pārraudzībai" + }, + "explorePremium": { + "message": "Izpētīt Premium" + }, + "loadingVault": { + "message": "Ielādē glabātavu" + }, + "vaultLoaded": { + "message": "Glabātava ielādēta" + }, + "settingDisabledByPolicy": { + "message": "Šis iestatījums ir atspējots apvienības pamatnostādnēs.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Pasta indekss" + }, + "cardNumberLabel": { + "message": "Kartes numurs" + }, + "sessionTimeoutSettingsAction": { + "message": "Noildzes darbība" } } diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index efe18c96a59..2f7f8d30e74 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "തിരുത്തുക" }, "view": { "message": "കാണുക" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "അസാധുവായ പ്രാഥമിക പാസ്‌വേഡ്" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "വാൾട് ടൈംഔട്ട്" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "സിസ്റ്റം ലോക്കിൽ" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "ബ്രൌസർ പുനരാരംഭിക്കുമ്പോൾ" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "തിരുത്തപ്പെട്ട ഇനം" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "ഈ ഇനം ഇല്ലാതാക്കാൻ നിങ്ങൾ ആഗ്രഹിക്കുന്നുണ്ടോ?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "പ്രീമിയം അംഗത്വം ആവശ്യമാണ്" - }, - "premiumRequiredDesc": { - "message": "ഈ സവിശേഷത ഉപയോഗിക്കുന്നതിന് പ്രീമിയം അംഗത്വം ആവശ്യമാണ്." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "ഇഷ്‌ടാനുസൃത എൻവിയോണ്മെന്റ്" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "കാലാവതി കഴിയുന്ന വർഷം" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "കാലഹരണപ്പെടൽ" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "പ്രാഥമിക പാസ്‌വേഡ് സജ്ജമാക്കുക" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index 16ac31ff599..70f8fe5393c 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Edit" }, "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "अवैध मुख्य पासवर्ड" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Vault timeout" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "Premium required" - }, - "premiumRequiredDesc": { - "message": "A Premium membership is required to use this feature." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index 78a49021a0c..f829937ac51 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Edit" }, "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Vault timeout" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "Premium required" - }, - "premiumRequiredDesc": { - "message": "A Premium membership is required to use this feature." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index a23fd7fe4c1..0eba9c953a3 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Bruk singulær pålogging" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Velkommen tilbake" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Rediger" }, "view": { "message": "Vis" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Ugyldig hovedpassord" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Tidsavbrudd i hvelvet" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Ved maskinlåsing" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Ved nettleseromstart" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Redigerte elementet" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Er du sikker på at du vil slette dette elementet?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Spør om biometri ved oppstart" }, - "premiumRequired": { - "message": "Premium er påkrevd" - }, - "premiumRequiredDesc": { - "message": "Et Premium-medlemskap er påkrevd for å bruke denne funksjonen." - }, "authenticationTimeout": { "message": "Tidsavbrudd for autentisering" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Les sikkerhetsnøkkel" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Tilpasset miljø" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Skru av autoutfylling" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Vis autoutfyll-forslag i tekstbokser" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Vis identiteter som forslag" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Utløpsår" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Utløp" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Angi hovedpassord" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Feil" }, "decryptionError": { "message": "Dekrypteringsfeil" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Skru av/på sidenavigering" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Velkommen til hvelvet ditt!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index 78a49021a0c..f829937ac51 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Edit" }, "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Vault timeout" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "Premium required" - }, - "premiumRequiredDesc": { - "message": "A Premium membership is required to use this feature." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index 2562b7a1d4c..601aac16f94 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Single sign-on gebruiken" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welkom terug" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Zoekopdracht resetten" }, - "archive": { - "message": "Archiveren" + "archiveNoun": { + "message": "Archief", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archiveren", + "description": "Verb" + }, + "unArchive": { "message": "Dearchiveren" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Gearchiveerde items verschijnen hier en worden uitgesloten van algemene zoekresultaten en automatisch invulsuggesties." }, - "itemSentToArchive": { + "itemWasSentToArchive": { "message": "Item naar archief verzonden" }, - "itemRemovedFromArchive": { - "message": "Item verwijderd uit archief" + "itemUnarchived": { + "message": "Item uit het archief gehaald" }, "archiveItem": { "message": "Item archiveren" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Gearchiveerde items worden uitgesloten van algemene zoekresultaten en automatische invulsuggesties. Weet je zeker dat je dit item wilt archiveren?" }, + "upgradeToUseArchive": { + "message": "Je hebt een Premium-abonnement nodig om te kunnen archiveren." + }, "edit": { "message": "Bewerken" }, "view": { "message": "Weergeven" }, + "viewAll": { + "message": "Alles weergeven" + }, + "showAll": { + "message": "Alles weergeven" + }, + "viewLess": { + "message": "Minder weergeven" + }, "viewLogin": { "message": "Login bekijken" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Ongeldig hoofdwachtwoord" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Ongeldig hoofdwachtwoord. Check of je e-mailadres klopt en of je account is aangemaakt op $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Time-out van de kluis" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Bij systeemvergrendeling" }, + "onIdle": { + "message": "Bij systeeminactiviteit" + }, + "onSleep": { + "message": "Bij slaapmodus" + }, "onRestart": { "message": "Bij herstart van de browser" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Item is bewerkt" }, + "savedWebsite": { + "message": "Opgeslagen website" + }, + "savedWebsites": { + "message": "Opgeslagen websites ( $COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Weet je zeker dat je dit naar de prullenbak wilt verplaatsen?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Vraag om biometrie bij opstarten" }, - "premiumRequired": { - "message": "Premium is vereist" - }, - "premiumRequiredDesc": { - "message": "Je hebt een Premium-abonnement nodig om deze functie te gebruiken." - }, "authenticationTimeout": { "message": "Authenticatie-timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Beveiligingssleutel lezen" }, + "readingPasskeyLoading": { + "message": "Passkey uitlezen..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey-authenticatie mislukt" + }, + "useADifferentLogInMethod": { + "message": "Gebruik een andere loginmethode" + }, "awaitingSecurityKeyInteraction": { "message": "Wacht op interactie met beveiligingssleutel…" }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Je moet de basis URL van de server of ten minste één aangepaste omgeving toevoegen." }, + "selfHostedEnvMustUseHttps": { + "message": "URL's moeten HTTPS gebruiken." + }, "customEnvironment": { "message": "Aangepaste omgeving" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Automatisch invullen uitschakelen" }, + "confirmAutofill": { + "message": "Automatisch aanvullen bevestigen" + }, + "confirmAutofillDesc": { + "message": "Deze website komt past niet bij je opgeslagen inloggegevens. Verzeker jezelf ervan dat het een vertrouwde website is, voordat je je inloggegevens invult." + }, "showInlineMenuLabel": { "message": "Suggesties voor automatisch invullen op formuliervelden weergeven" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Hoe beschermt Bitwarden je gegevens tegen phishing?" + }, + "currentWebsite": { + "message": "Huidige website" + }, + "autofillAndAddWebsite": { + "message": "Automatisch invullen en deze website toevoegen" + }, + "autofillWithoutAdding": { + "message": "Automatisch invullen zonder toevoegen" + }, + "doNotAutofill": { + "message": "Niet automatisch invullen" + }, "showInlineMenuIdentitiesLabel": { "message": "Identiteiten als suggesties weergeven" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Vervaljaar" }, + "monthly": { + "message": "maand" + }, "expiration": { "message": "Vervaldatum" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Deze pagina verstoort de Bitwarden-ervaring. Het inline-menu van Bitwarden is tijdelijk uitgeschakeld als veiligheidsmaatregel." + }, "setMasterPassword": { "message": "Hoofdwachtwoord instellen" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Alleen de organisatiekluis die gekoppeld is aan $ORGANIZATION$ wordt geëxporteerd.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Exporteert alleen de organisatiekluis van $ORGANIZATION$. Geen persoonlijke kluis-items.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Fout" }, "decryptionError": { "message": "Ontsleutelingsfout" }, + "errorGettingAutoFillData": { + "message": "Fout bij ophalen van gegevens voor automatisch vullen" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden kon de onderstaande kluisitem(s) niet ontsleutelen." }, @@ -3432,10 +3533,10 @@ "message": "Premium-abonnement vereist" }, "organizationIsDisabled": { - "message": "Organisatie is uitgeschakeld." + "message": "Organisatie opgeschort." }, "disabledOrganizationFilterError": { - "message": "Je kunt uitgeschakelde items in een organisatie niet benaderen. Neem contact op met de eigenaar van je organisatie voor hulp." + "message": "Je kunt items in opgeschorte organisaties niet benaderen. Neem contact op met de eigenaar van je organisatie voor hulp." }, "loggingInTo": { "message": "Inloggen op $DOMAIN$", @@ -3970,6 +4071,15 @@ "message": "Automatisch invullen bij het laden van een pagina ingesteld op de standaardinstelling.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Kan niet automatisch invullen" + }, + "cannotAutofillExactMatch": { + "message": "Standaard overeenkomst is ingesteld op 'Exacte Match'. De huidige website komt niet precies overeen met de opgeslagen inloggegevens voor dit item." + }, + "okay": { + "message": "OK" + }, "toggleSideNavigation": { "message": "Zijnavigatie schakelen" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Ontgrendel tapporteren, noodtoegang en meer beveiligingsfuncties met Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Gratis organisaties kunnen geen bijlagen gebruiken" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Standaard ($VALUE$)", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Overeenkomstdetectie weergeven $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welkom in je kluis!" }, - "phishingPageTitle": { - "message": "Kwaadaardige website" + "phishingPageTitleV2": { + "message": "Phishing-poging gedetecteerd" }, - "phishingPageCloseTab": { - "message": "Tabblad sluiten" + "phishingPageSummary": { + "message": "De site die je probeert te bezoeken is een bekende kwaadaardige site en een veiligheidsrisico." }, - "phishingPageContinue": { - "message": "Doorgaan" + "phishingPageCloseTabV2": { + "message": "Dit tabblad sluiten" }, - "phishingPageLearnWhy": { - "message": "Waarom zie je dit?" + "phishingPageContinueV2": { + "message": "Doorgaan naar deze site (niet aanbevolen)" + }, + "phishingPageExplanation1": { + "message": "Deze site is gevonden in ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", een open-source lijst van bekende phishing-sites die worden gebruikt om persoonlijke en gevoelige informatie te stelen.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Leer meer over phishing-detectie" + }, + "protectedBy": { + "message": "Beschermd door $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Automatisch invullen van items voor de huidige pagina" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Key Connector-domein bevestigen" + }, + "atRiskLoginsSecured": { + "message": "Goed gedaan, je hebt je risicovolle inloggegevens verbeterd!" + }, + "upgradeNow": { + "message": "Nu upgraden" + }, + "builtInAuthenticator": { + "message": "Ingebouwde authenticator" + }, + "secureFileStorage": { + "message": "Beveiligde bestandsopslag" + }, + "emergencyAccess": { + "message": "Noodtoegang" + }, + "breachMonitoring": { + "message": "Lek-monitoring" + }, + "andMoreFeatures": { + "message": "En meer!" + }, + "planDescPremium": { + "message": "Online beveiliging voltooien" + }, + "upgradeToPremium": { + "message": "Opwaarderen naar Premium" + }, + "unlockAdvancedSecurity": { + "message": "Geavanceerde beveiligingsfuncties ontgrendelen" + }, + "unlockAdvancedSecurityDesc": { + "message": "Een Premium-abonnement geeft je meer tools om veilig en in controle te blijven" + }, + "explorePremium": { + "message": "Premium verkennen" + }, + "loadingVault": { + "message": "Kluis laden" + }, + "vaultLoaded": { + "message": "Kluis geladen" + }, + "settingDisabledByPolicy": { + "message": "Deze instelling is uitgeschakeld door het beleid van uw organisatie.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Postcode" + }, + "cardNumberLabel": { + "message": "Kaartnummer" + }, + "sessionTimeoutSettingsAction": { + "message": "Time-out actie" } } diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index 78a49021a0c..f829937ac51 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Edit" }, "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Vault timeout" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "Premium required" - }, - "premiumRequiredDesc": { - "message": "A Premium membership is required to use this feature." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index 78a49021a0c..f829937ac51 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Edit" }, "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Vault timeout" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "Premium required" - }, - "premiumRequiredDesc": { - "message": "A Premium membership is required to use this feature." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index f24e790c9ad..9741f94da36 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Użyj logowania jednokrotnego" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Twoja organizacja wymaga logowania jednokrotnego." + }, "welcomeBack": { "message": "Witaj ponownie" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Zresetuj wyszukiwanie" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archiwum", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archiwizuj", + "description": "Verb" + }, + "unArchive": { "message": "Usuń z archiwum" }, "itemsInArchive": { @@ -565,10 +573,10 @@ "noItemsInArchiveDesc": { "message": "Zarchiwizowane elementy pojawią się tutaj i zostaną wykluczone z wyników wyszukiwania i sugestii autouzupełniania." }, - "itemSentToArchive": { + "itemWasSentToArchive": { "message": "Element został przeniesiony do archiwum" }, - "itemRemovedFromArchive": { + "itemUnarchived": { "message": "Element został usunięty z archiwum" }, "archiveItem": { @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Zarchiwizowane elementy są wykluczone z wyników wyszukiwania i sugestii autouzupełniania. Czy na pewno chcesz archiwizować element?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Edytuj" }, "view": { "message": "Pokaż" }, + "viewAll": { + "message": "Pokaż wszystko" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "Pokaż mniej" + }, "viewLogin": { "message": "Pokaż dane logowania" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Hasło główne jest nieprawidłowe" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Nieprawidłowe hasło główne. Sprawdź, czy Twój adres e-mail jest poprawny i czy Twoje konto zostało utworzone na $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Blokowanie sejfu" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Po zablokowaniu urządzenia" }, + "onIdle": { + "message": "Podczas bezczynności systemu" + }, + "onSleep": { + "message": "Podczas uśpienia systemu" + }, "onRestart": { "message": "Po uruchomieniu przeglądarki" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Element został zapisany" }, + "savedWebsite": { + "message": "Zapisana witryna" + }, + "savedWebsites": { + "message": "Zapisane witryny ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Czy na pewno chcesz usunąć?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Wymagaj odblokowania biometrią po uruchomieniu przeglądarki" }, - "premiumRequired": { - "message": "Konto premium jest wymagane" - }, - "premiumRequiredDesc": { - "message": "Konto premium jest wymagane, aby skorzystać z tej funkcji." - }, "authenticationTimeout": { "message": "Przekroczono limit czasu uwierzytelniania" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Odczytaj klucz bezpieczeństwa" }, + "readingPasskeyLoading": { + "message": "Odczytywanie klucza dostępu..." + }, + "passkeyAuthenticationFailed": { + "message": "Uwierzytelnienie za pomocą klucza nie powiodło się" + }, + "useADifferentLogInMethod": { + "message": "Użyj innej metody logowania" + }, "awaitingSecurityKeyInteraction": { "message": "Oczekiwanie na klucz bezpieczeństwa..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Musisz dodać podstawowy adres URL serwera lub co najmniej jedno niestandardowe środowisko." }, + "selfHostedEnvMustUseHttps": { + "message": "Adresy URL muszą używać protokołu HTTPS." + }, "customEnvironment": { "message": "Niestandardowe środowisko" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Wyłącz autouzupełnianie" }, + "confirmAutofill": { + "message": "Potwierdź autouzupełnianie" + }, + "confirmAutofillDesc": { + "message": "Ta witryna nie pasuje do Twoich zapisanych danych logowania. Zanim wpiszesz dane logowania, upewnij się, że jest to zaufana witryna." + }, "showInlineMenuLabel": { "message": "Pokaż sugestie autouzupełniania na polach formularza" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "W jaki sposób Bitwarden chroni Twoje dane przed phishingiem?" + }, + "currentWebsite": { + "message": "Aktualna witryna" + }, + "autofillAndAddWebsite": { + "message": "Wypełnij automatycznie i dodaj tę witrynę" + }, + "autofillWithoutAdding": { + "message": "Automatyczne uzupełnianie bez dodawania" + }, + "doNotAutofill": { + "message": "Nie wypełniaj automatycznie" + }, "showInlineMenuIdentitiesLabel": { "message": "Pokaż tożsamości w sugestiach" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Rok wygaśnięcia" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Data wygaśnięcia" }, @@ -2174,7 +2251,7 @@ "description": "ex. Date this item was created" }, "datePasswordUpdated": { - "message": "Hasło zostało zaktualizowane", + "message": "Ostatnia aktualizacja hasła", "description": "ex. Date this password was updated" }, "neverLockWarning": { @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Ta strona zakłóca działanie Bitwarden. Menu Bitwarden zostało tymczasowo wyłączone ze względów bezpieczeństwa." + }, "setMasterPassword": { "message": "Ustaw hasło główne" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Tylko sejf organizacji $ORGANIZATION$ zostanie wyeksportowany.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Tylko sejf organizacji $ORGANIZATION$ zostanie wyeksportowany. Twoje kolekcje nie zostaną uwzględnione.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Błąd" }, "decryptionError": { "message": "Błąd odszyfrowywania" }, + "errorGettingAutoFillData": { + "message": "Błąd podczas pobierania danych autouzupełniania" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden nie mógł odszyfrować poniższych elementów sejfu." }, @@ -3970,6 +4071,15 @@ "message": "Autouzupełnianie po załadowaniu strony zostało ustawione do domyślnych ustawień.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Nie można automatycznie wypełnić" + }, + "cannotAutofillExactMatch": { + "message": "Domyślnie dopasowanie jest ustawione na „Dokładne dopasowanie”. Aktualna strona internetowa nie jest dokładnie taka sama jak zapisane dane logowania dla tego elementu." + }, + "okay": { + "message": "Ok" + }, "toggleSideNavigation": { "message": "Przełącz nawigację boczną" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Darmowe organizacje nie mogą używać załączników" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Domyślne ($VALUE$)", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Pokaż wykrywanie dopasowania $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Witaj w sejfie!" }, - "phishingPageTitle": { - "message": "Witryna phishingowa" + "phishingPageTitleV2": { + "message": "Wykryto próbę phishingu" }, - "phishingPageCloseTab": { + "phishingPageSummary": { + "message": "Witryna, którą próbujesz odwiedzić, jest znaną złośliwą witryną i zagrożeniem bezpieczeństwa." + }, + "phishingPageCloseTabV2": { "message": "Zamknij kartę" }, - "phishingPageContinue": { - "message": "Kontynuuj" + "phishingPageContinueV2": { + "message": "Przejdź do tej witryny (niezalecane)" }, - "phishingPageLearnWhy": { - "message": "Dlaczego to widzę?" + "phishingPageExplanation1": { + "message": "Ta witryna została znaleziona w ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", lista znanych witryn phishingowych, które służą do kradzieży danych osobowych i poufnych.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Dowiedz się więcej o wykrywaniu phishingu" + }, + "protectedBy": { + "message": "Chronione przez $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Uzupełniaj elementy na stronie internetowej" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Potwierdź domenę Key Connector" + }, + "atRiskLoginsSecured": { + "message": "Świetna robota z zabezpieczeniem Twoich zagrożonych danych logowania!" + }, + "upgradeNow": { + "message": "Zaktualizuj teraz" + }, + "builtInAuthenticator": { + "message": "Wbudowany uwierzytelniacz" + }, + "secureFileStorage": { + "message": "Bezpieczne przechowywanie plików" + }, + "emergencyAccess": { + "message": "Dostęp awaryjny" + }, + "breachMonitoring": { + "message": "Monitorowanie naruszeń" + }, + "andMoreFeatures": { + "message": "I jeszcze więcej!" + }, + "planDescPremium": { + "message": "Pełne bezpieczeństwo w Internecie" + }, + "upgradeToPremium": { + "message": "Ulepsz do Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Poznaj Premium" + }, + "loadingVault": { + "message": "Ładowanie sejfu" + }, + "vaultLoaded": { + "message": "Sejf załadowany" + }, + "settingDisabledByPolicy": { + "message": "To ustawienie jest wyłączone zgodnie z zasadami polityki Twojej organizacji.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Kod pocztowy" + }, + "cardNumberLabel": { + "message": "Numer karty" + }, + "sessionTimeoutSettingsAction": { + "message": "Akcja przekroczenia limitu czasu" } } diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 2d7dd1e42a4..a7675274eae 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -3,45 +3,48 @@ "message": "Bitwarden" }, "appLogoLabel": { - "message": "Bitwarden logo" + "message": "Logo do Bitwarden" }, "extName": { - "message": "Gerenciador de senhas Bitwarden", + "message": "Bitwarden Gerenciador de Senhas", "description": "Extension name, MUST be less than 40 characters (Safari restriction)" }, "extDesc": { - "message": "Em qual lugar for, o Bitwarden protege suas senhas, chaves de acesso, e informações confidenciais", + "message": "Onde quer que você esteja, o Bitwarden protege suas senhas, chaves de acesso e informações sensíveis", "description": "Extension description, MUST be less than 112 characters (Safari restriction)" }, "loginOrCreateNewAccount": { - "message": "Inicie a sessão ou crie uma nova conta para acessar seu cofre seguro." + "message": "Conecte-se ou crie uma conta para acessar o seu cofre seguro." }, "inviteAccepted": { "message": "Convite aceito" }, "createAccount": { - "message": "Criar Conta" + "message": "Criar conta" }, "newToBitwarden": { "message": "Novo no Bitwarden?" }, "logInWithPasskey": { - "message": "Iniciar sessão com a chave de acesso" + "message": "Conectar-se com chave de acesso" }, "useSingleSignOn": { - "message": "Usar login único" + "message": "Usar autenticação única" + }, + "yourOrganizationRequiresSingleSignOn": { + "message": "A sua organização requer o uso da autenticação única." }, "welcomeBack": { - "message": "Bem vindo de volta" + "message": "Boas-vindas de volta" }, "setAStrongPassword": { - "message": "Defina uma senha forte" + "message": "Configure uma senha forte" }, "finishCreatingYourAccountBySettingAPassword": { - "message": "Termine de criar a sua conta definindo uma senha" + "message": "Termine de criar a sua conta configurando uma senha" }, "enterpriseSingleSignOn": { - "message": "Iniciar Sessão Empresarial Única" + "message": "Autenticação única empresarial" }, "cancel": { "message": "Cancelar" @@ -53,16 +56,16 @@ "message": "Enviar" }, "emailAddress": { - "message": "Endereço de correio eletrônico" + "message": "Endereço de e-mail" }, "masterPass": { - "message": "Senha Mestra" + "message": "Senha principal" }, "masterPassDesc": { - "message": "A senha mestra é a senha que você usa para acessar o seu cofre. É muito importante que você não esqueça sua senha mestra. Não há maneira de recuperar a senha caso você se esqueça." + "message": "A senha principal é a senha que você usa para acessar o seu cofre. É muito importante que você não esqueça sua senha principal. Não há maneira de recuperar a senha caso você se esqueça." }, "masterPassHintDesc": { - "message": "Uma dica de senha mestra pode ajudá-lo(a) a lembrá-lo(a) caso você esqueça." + "message": "Uma dica de senha principal pode ajudá-lo(a) a lembrá-la caso você esqueça." }, "masterPassHintText": { "message": "Se você esquecer sua senha, a dica de senha pode ser enviada ao seu e-mail. $CURRENT$/$MAXIMUM$ caracteres máximos.", @@ -78,13 +81,13 @@ } }, "reTypeMasterPass": { - "message": "Digite Novamente a Senha Mestra" + "message": "Digite novamente a senha principal" }, "masterPassHint": { - "message": "Dica de Senha Mestra (opcional)" + "message": "Dica de senha principal (opcional)" }, "passwordStrengthScore": { - "message": "Pontos fortes da senha: $SCORE$", + "message": "Pontuação de força da senha $SCORE$", "placeholders": { "score": { "content": "$1", @@ -96,7 +99,7 @@ "message": "Juntar-se à organização" }, "joinOrganizationName": { - "message": "Entrar em $ORGANIZATIONNAME$", + "message": "Juntar-se à $ORGANIZATIONNAME$", "placeholders": { "organizationName": { "content": "$1", @@ -105,7 +108,7 @@ } }, "finishJoiningThisOrganizationBySettingAMasterPassword": { - "message": "Termine de juntar-se nessa organização definindo uma senha mestra." + "message": "Termine de juntar-se à organização configurando uma senha principal." }, "tab": { "message": "Aba" @@ -114,10 +117,10 @@ "message": "Cofre" }, "myVault": { - "message": "Meu Cofre" + "message": "Meu cofre" }, "allVaults": { - "message": "Todos os Cofres" + "message": "Todos os cofres" }, "tools": { "message": "Ferramentas" @@ -126,28 +129,28 @@ "message": "Configurações" }, "currentTab": { - "message": "Aba Atual" + "message": "Aba atual" }, "copyPassword": { - "message": "Copiar Senha" - }, - "copyPassphrase": { "message": "Copiar senha" }, + "copyPassphrase": { + "message": "Copiar frase secreta" + }, "copyNote": { - "message": "Copiar Nota" + "message": "Copiar anotação" }, "copyUri": { "message": "Copiar URI" }, "copyUsername": { - "message": "Copiar Nome de Usuário" + "message": "Copiar nome de usuário" }, "copyNumber": { - "message": "Copiar Número" + "message": "Copiar número" }, "copySecurityCode": { - "message": "Copiar Código de Segurança" + "message": "Copiar código de segurança" }, "copyName": { "message": "Copiar nome" @@ -156,13 +159,13 @@ "message": "Copiar empresa" }, "copySSN": { - "message": "Cadastro de Pessoas Físicas" + "message": "Copiar número de CPF" }, "copyPassportNumber": { "message": "Copiar número do passaporte" }, "copyLicenseNumber": { - "message": "Copiar número da CNH" + "message": "Copiar número da licença" }, "copyPrivateKey": { "message": "Copiar chave privada" @@ -186,7 +189,7 @@ "message": "Copiar site" }, "copyNotes": { - "message": "Copiar Notas" + "message": "Copiar anotações" }, "copy": { "message": "Copiar", @@ -197,10 +200,10 @@ "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": "Autopreencher" + "message": "Preenchimento automático" }, "autoFillLogin": { - "message": "Preencher login automaticamente" + "message": "Preencher credencial automaticamente" }, "autoFillCard": { "message": "Preencher cartão automaticamente" @@ -209,29 +212,29 @@ "message": "Preencher identidade automaticamente" }, "fillVerificationCode": { - "message": "Preencher o código de verificação" + "message": "Preencher código de verificação" }, "fillVerificationCodeAria": { "message": "Preencher código de verificação", "description": "Aria label for the heading displayed the inline menu for totp code autofill" }, "generatePasswordCopied": { - "message": "Gerar Senha (copiada)" + "message": "Gerar senha (copiada)" }, "copyElementIdentifier": { - "message": "Copiar Nome do Campo Personalizado" + "message": "Copiar nome do campo personalizado" }, "noMatchingLogins": { - "message": "Sem credenciais correspondentes." + "message": "Nenhuma credencial correspondente" }, "noCards": { - "message": "Sem cartões" + "message": "Nenhum cartão" }, "noIdentities": { - "message": "Sem Identidade" + "message": "Nenhuma identidade" }, "addLoginMenu": { - "message": "Adicionar login" + "message": "Adicionar credencial" }, "addCardMenu": { "message": "Adicionar cartão" @@ -243,31 +246,31 @@ "message": "Desbloqueie seu cofre" }, "loginToVaultMenu": { - "message": "Acesse o seu cofre" + "message": "Conecte-se ao seu cofre" }, "autoFillInfo": { - "message": "Não há credenciais disponíveis para autopreenchimento para a aba do navegador atual." + "message": "Não há credenciais disponíveis para preencher automaticamente na aba atual do navegador." }, "addLogin": { - "message": "Adicionar um Login" + "message": "Adicionar uma credencial" }, "addItem": { - "message": "Adicionar Item" + "message": "Adicionar item" }, "accountEmail": { - "message": "Correio eletrônico da conta" + "message": "E-mail da conta" }, "requestHint": { "message": "Solicitar dica" }, "requestPasswordHint": { - "message": "Dica da senha mestra" + "message": "Solicitar dica da senha" }, "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou": { - "message": "Digite o endereço de seu correio eletrônico da sua conta e sua dica da senha será enviada para você" + "message": "Digite o endereço de e-mail da sua conta e dica da sua senha será enviada para você" }, "getMasterPasswordHint": { - "message": "Obter dica da senha mestra" + "message": "Receber dica da senha principal" }, "continue": { "message": "Continuar" @@ -276,25 +279,25 @@ "message": "Enviar um código de verificação para o seu e-mail" }, "sendCode": { - "message": "Enviar Código" + "message": "Enviar código" }, "codeSent": { - "message": "Código Enviado" + "message": "Código enviado" }, "verificationCode": { - "message": "Código de Verificação" + "message": "Código de verificação" }, "confirmIdentity": { "message": "Confirme a sua identidade para continuar." }, "changeMasterPassword": { - "message": "Alterar senha mestra" + "message": "Alterar senha principal" }, "continueToWebApp": { "message": "Continuar no aplicativo web?" }, "continueToWebAppDesc": { - "message": "Explore mais recursos da sua conta no Bitwarden no aplicativo web." + "message": "Explore mais recursos da sua conta do Bitwarden no aplicativo web." }, "continueToHelpCenter": { "message": "Continuar no Centro de Ajuda?" @@ -303,27 +306,27 @@ "message": "Saiba mais sobre como usar o Bitwarden no Centro de Ajuda." }, "continueToBrowserExtensionStore": { - "message": "Continuar na extensão da loja do navegador?" + "message": "Continuar na loja de extensões do navegador?" }, "continueToBrowserExtensionStoreDesc": { - "message": "Ajude outras pessoas a descobrirem se o Bitwarden é o que elas estão procurando. Visite a loja de extensões do seu navegador e deixe uma classificação agora." + "message": "Ajude outras pessoas a descobrirem se o Bitwarden é o que elas estão procurando. Visite a loja de extensões do seu navegador e deixe uma avaliação agora." }, "changeMasterPasswordOnWebConfirmation": { - "message": "Você pode alterar a sua senha mestra no aplicativo web Bitwarden." + "message": "Você pode alterar a sua senha principal no aplicativo web do Bitwarden." }, "fingerprintPhrase": { - "message": "Frase Biométrica", + "message": "Frase biométrica", "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": "A sua frase biométrica", + "message": "A frase biométrica da sua conta", "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": { - "message": "Login em Duas Etapas" + "message": "Autenticação em duas etapas" }, "logOut": { - "message": "Encerrar Sessão" + "message": "Desconectar" }, "aboutBitwarden": { "message": "Sobre o Bitwarden" @@ -335,34 +338,34 @@ "message": "Mais do Bitwarden" }, "continueToBitwardenDotCom": { - "message": "Continuar para bitwarden.com?" + "message": "Continuar em bitwarden.com?" }, "bitwardenForBusiness": { "message": "Bitwarden para Negócios" }, "bitwardenAuthenticator": { - "message": "Autenticador Bitwarden" + "message": "Bitwarden Authenticator" }, "continueToAuthenticatorPageDesc": { - "message": "O Autenticador Bitwarden permite que você armazene as chaves do autenticador e gere códigos TOTP para fluxos de verificação de 2 etapas. Saiba mais no site bitwarden.com" + "message": "O Bitwarden Authenticator permite que você armazene as chaves de autenticador e gere códigos TOTP para fluxos de verificação de 2 etapas. Saiba mais no site bitwarden.com" }, "bitwardenSecretsManager": { - "message": "Gerenciador de Segredos Bitwarden" + "message": "Bitwarden Gerenciador de Segredos" }, "continueToSecretsManagerPageDesc": { - "message": "Armazene, gerencie e compartilhe senhas de desenvolvedor com o Gerenciador de segredos do Bitwarden. Saiba mais no site bitwarden.com." + "message": "Armazene, gerencie e compartilhe segredos de desenvolvedor com o Bitwarden Gerenciador de Segredos. Saiba mais no site bitwarden.com." }, "passwordlessDotDev": { "message": "Passwordless.dev" }, "continueToPasswordlessDotDevPageDesc": { - "message": "Crie experiências de login suaves e seguras, livres de senhas tradicionais com Passwordless.dev. Saiba mais no site bitwarden.com." + "message": "Crie experiências de autenticação seguras, simples, livres de senhas tradicionais, com o Passwordless.dev. Saiba mais no site bitwarden.com." }, "freeBitwardenFamilies": { - "message": "Plano Familiar do Bitwarden Grátis" + "message": "Bitwarden Famílias grátis" }, "freeBitwardenFamiliesPageDesc": { - "message": "Você é elegível para o plano Familiar do Bitwarden Grátis. Resgate esta oferta hoje no aplicativo web." + "message": "Você é elegível para o plano Bitwarden Famílias grátis. Resgate esta oferta hoje no aplicativo web." }, "version": { "message": "Versão" @@ -374,13 +377,13 @@ "message": "Mover" }, "addFolder": { - "message": "Adicionar Pasta" + "message": "Adicionar pasta" }, "name": { "message": "Nome" }, "editFolder": { - "message": "Editar Pasta" + "message": "Editar pasta" }, "editFolderWithName": { "message": "Editar pasta: $FOLDERNAME$", @@ -398,7 +401,7 @@ "message": "Nome da pasta" }, "folderHintText": { - "message": "Aninhe uma pasta adicionando o nome da pasta pai seguido de um \"/\". Exemplo: Social/Fóruns" + "message": "Agrupe uma pasta adicionando o nome da pasta mãe seguido de uma \"/\". Exemplo: Social/Fóruns" }, "noFoldersAdded": { "message": "Nenhuma pasta adicionada" @@ -407,40 +410,40 @@ "message": "Crie pastas para organizar os itens do seu cofre" }, "deleteFolderPermanently": { - "message": "Você tem certeza que deseja excluir esta pasta permanentemente?" + "message": "Tem certeza que quer apagar esta pasta para sempre?" }, "deleteFolder": { - "message": "Excluir Pasta" + "message": "Apagar pasta" }, "folders": { "message": "Pastas" }, "noFolders": { - "message": "Não existem pastas para listar." + "message": "Não há pastas para listar." }, "helpFeedback": { - "message": "Ajuda & Feedback" + "message": "Ajuda e retorno" }, "helpCenter": { - "message": "Central de Ajuda" + "message": "Central de ajuda do Bitwarden" }, "communityForums": { - "message": "Explore os fóruns da comunidade" + "message": "Explorar os fóruns da comunidade do Bitwarden" }, "contactSupport": { - "message": "Contate o suporte Bitwarden" + "message": "Entrar em contato com o suporte do Bitwarden" }, "sync": { "message": "Sincronizar" }, "syncVaultNow": { - "message": "Sincronizar Cofre Agora" + "message": "Sincronizar cofre agora" }, "lastSync": { - "message": "Última Sincronização:" + "message": "Última sincronização:" }, "passGen": { - "message": "Gerador de Senha" + "message": "Gerador de senhas" }, "generator": { "message": "Gerador", @@ -450,16 +453,16 @@ "message": "Gere automaticamente senhas fortes e únicas para as suas credenciais." }, "bitWebVaultApp": { - "message": "Aplicativo Web Bitwarden" + "message": "Aplicativo web do Bitwarden" }, "importItems": { - "message": "Importar Itens" + "message": "Importar itens" }, "select": { "message": "Selecionar" }, "generatePassword": { - "message": "Gerar Senha" + "message": "Gerar senha" }, "generatePassphrase": { "message": "Gerar frase secreta" @@ -468,7 +471,7 @@ "message": "Senha gerada" }, "passphraseGenerated": { - "message": "Senha gerada" + "message": "Frase secreta gerada" }, "usernameGenerated": { "message": "Nome de usuário gerado" @@ -477,7 +480,7 @@ "message": "E-mail gerado" }, "regeneratePassword": { - "message": "Gerar Nova Senha" + "message": "Regerar senha" }, "options": { "message": "Opções" @@ -510,7 +513,7 @@ "description": "Full description for the password generator numbers checkbox" }, "numbersLabel": { - "message": "0 – 9", + "message": "0-9", "description": "Label for the password generator numbers checkbox" }, "specialCharactersDescription": { @@ -518,64 +521,72 @@ "description": "Full description for the password generator special characters checkbox" }, "numWords": { - "message": "Número de Palavras" + "message": "Número de palavras" }, "wordSeparator": { - "message": "Separador de Palavra" + "message": "Separador de palavras" }, "capitalize": { - "message": "Iniciais em Maiúsculas", + "message": "Iniciais maiúsculas", "description": "Make the first letter of a work uppercase." }, "includeNumber": { - "message": "Incluir Número" + "message": "Incluir número" }, "minNumbers": { - "message": "Números Mínimos" + "message": "Mínimo de números" }, "minSpecial": { - "message": "Especiais Mínimos" + "message": "Mínimo de caracteres especiais" }, "avoidAmbiguous": { "message": "Evitar caracteres ambíguos", "description": "Label for the avoid ambiguous characters checkbox." }, "generatorPolicyInEffect": { - "message": "Os requisitos de política empresarial foram aplicados nesta configuração", + "message": "Os requisitos de política empresarial foram aplicados às configurações do seu gerador.", "description": "Indicates that a policy limits the credential generator screen." }, "searchVault": { - "message": "Pesquisar no Cofre" + "message": "Buscar no cofre" }, "resetSearch": { - "message": "Reset search" + "message": "Apagar busca" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Arquivo", + "description": "Noun" }, - "unarchive": { - "message": "Unarchive" + "archiveVerb": { + "message": "Arquivar", + "description": "Verb" + }, + "unArchive": { + "message": "Desarquivar" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Itens no arquivo" }, "noItemsInArchive": { - "message": "No items in archive" + "message": "Nenhum item no arquivo" }, "noItemsInArchiveDesc": { - "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + "message": "Os itens arquivados aparecerão aqui e serão excluídos dos resultados gerais de busca e das sugestões de preenchimento automático." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "O item foi enviado para o arquivo" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "O item foi desarquivado" }, "archiveItem": { - "message": "Archive item" + "message": "Arquivar item" }, "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "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?" + }, + "upgradeToUseArchive": { + "message": "Um plano Premium é necessário para usar o arquivamento." }, "edit": { "message": "Editar" @@ -583,29 +594,38 @@ "view": { "message": "Ver" }, + "viewAll": { + "message": "Ver tudo" + }, + "showAll": { + "message": "Mostrar tudo" + }, + "viewLess": { + "message": "Ver menos" + }, "viewLogin": { - "message": "View login" + "message": "Ver credencial" }, "noItemsInList": { "message": "Não há itens para listar." }, "itemInformation": { - "message": "Informação do Item" + "message": "Informações do item" }, "username": { - "message": "Nome de Usuário" + "message": "Nome de usuário" }, "password": { "message": "Senha" }, "totp": { - "message": "Senha do autenticador" + "message": "Segredo do autenticador" }, "passphrase": { - "message": "Frase Secreta" + "message": "Frase secreta" }, "favorite": { - "message": "Favorito" + "message": "Favoritar" }, "unfavorite": { "message": "Desfavoritar" @@ -617,25 +637,25 @@ "message": "Item removido dos favoritos" }, "notes": { - "message": "Notas" + "message": "Anotações" }, "privateNote": { - "message": "Nota privada" + "message": "Anotação privada" }, "note": { - "message": "Nota" + "message": "Anotação" }, "editItem": { - "message": "Editar Item" + "message": "Editar item" }, "folder": { "message": "Pasta" }, "deleteItem": { - "message": "Excluir Item" + "message": "Apagar item" }, "viewItem": { - "message": "Visualizar Item" + "message": "Ver item" }, "launch": { "message": "Abrir" @@ -644,7 +664,7 @@ "message": "Abrir site" }, "launchWebsiteName": { - "message": "Iniciar site $ITEMNAME$", + "message": "Abrir o site $ITEMNAME$", "placeholders": { "itemname": { "content": "$1", @@ -656,7 +676,7 @@ "message": "Site" }, "toggleVisibility": { - "message": "Alternar Visibilidade" + "message": "Habilitar visibilidade" }, "manage": { "message": "Gerenciar" @@ -668,7 +688,7 @@ "message": "Opções de desbloqueio" }, "unlockMethodNeededToChangeTimeoutActionDesc": { - "message": "Configure um método de desbloqueio para alterar o tempo limite do cofre." + "message": "Configure um método de desbloqueio para alterar a ação do tempo limite do cofre." }, "unlockMethodNeeded": { "message": "Configure um método de desbloqueio nas Configurações" @@ -677,16 +697,16 @@ "message": "Tempo limite da sessão" }, "vaultTimeoutHeader": { - "message": "Tempo Limite do Cofre" + "message": "Tempo limite do cofre" }, "otherOptions": { "message": "Outras opções" }, "rateExtension": { - "message": "Avaliar a Extensão" + "message": "Avalie a extensão" }, "browserNotSupportClipboard": { - "message": "O seu navegador web não suporta cópia para a área de transferência. Em alternativa, copie manualmente." + "message": "O seu navegador web não suporta copiar para a área de transferência. Em vez disso, copie manualmente." }, "verifyYourIdentity": { "message": "Verifique a sua identidade" @@ -695,10 +715,10 @@ "message": "Não reconhecemos este dispositivo. Digite o código enviado por e-mail para verificar a sua identidade." }, "continueLoggingIn": { - "message": "Manter sessão" + "message": "Continuar acessando" }, "yourVaultIsLocked": { - "message": "Seu cofre está trancado. Verifique sua identidade para continuar." + "message": "Seu cofre está bloqueado. Verifique sua identidade para continuar." }, "yourVaultIsLockedV2": { "message": "Seu cofre está bloqueado" @@ -713,7 +733,7 @@ "message": "Desbloquear" }, "loggedInAsOn": { - "message": "Entrou como $EMAIL$ em $HOSTNAME$.", + "message": "Conectado como $EMAIL$ em $HOSTNAME$.", "placeholders": { "email": { "content": "$1", @@ -726,16 +746,25 @@ } }, "invalidMasterPassword": { - "message": "Senha mestra inválida" + "message": "Senha principal inválida" + }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Senha principal inválida. Confirme que seu e-mail está correto e sua conta foi criada em $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } }, "vaultTimeout": { - "message": "Cofre - tempo esgotado" + "message": "Tempo limite do cofre" }, "vaultTimeout1": { - "message": "Tempo de espera" + "message": "Tempo limite" }, "lockNow": { - "message": "Bloquear Agora" + "message": "Bloquear agora" }, "lockAll": { "message": "Bloquear tudo" @@ -774,10 +803,16 @@ "message": "4 horas" }, "onLocked": { - "message": "No bloqueio" + "message": "No bloqueio do sistema" + }, + "onIdle": { + "message": "Na inatividade do sistema" + }, + "onSleep": { + "message": "Na hibernação do sistema" }, "onRestart": { - "message": "Ao Reiniciar" + "message": "No reinício do navegador" }, "never": { "message": "Nunca" @@ -786,16 +821,16 @@ "message": "Segurança" }, "confirmMasterPassword": { - "message": "Confirme a senha mestra" + "message": "Confirme a senha principal" }, "masterPassword": { - "message": "Senha mestra" + "message": "Senha principal" }, "masterPassImportant": { - "message": "Sua senha mestra não pode ser recuperada se você a esquecer!" + "message": "Sua senha principal não pode ser recuperada se você esquecê-la!" }, "masterPassHintLabel": { - "message": "Dica da senha mestra" + "message": "Dica da senha principal" }, "errorOccurred": { "message": "Ocorreu um erro" @@ -807,13 +842,13 @@ "message": "Endereço de e-mail inválido." }, "masterPasswordRequired": { - "message": "A senha mestra é obrigatória." + "message": "A senha principal é necessária." }, "confirmMasterPasswordRequired": { - "message": "É necessário redigitar a senha mestra." + "message": "É necessário redigitar a senha principal." }, "masterPasswordMinlength": { - "message": "A senha mestra deve ter pelo menos $VALUE$ caracteres.", + "message": "A senha principal deve ter pelo menos $VALUE$ caracteres.", "description": "The Master Password must be at least a specific number of characters long.", "placeholders": { "value": { @@ -823,37 +858,37 @@ } }, "masterPassDoesntMatch": { - "message": "A confirmação da senha mestra não corresponde." + "message": "A confirmação da senha principal não corresponde." }, "newAccountCreated": { - "message": "A sua nova conta foi criada! Agora você pode iniciar a sessão." + "message": "A sua nova conta foi criada! Agora você pode conectar-se." }, "newAccountCreated2": { "message": "Sua nova conta foi criada!" }, "youHaveBeenLoggedIn": { - "message": "Você está conectado!" + "message": "Você foi conectado!" }, "youSuccessfullyLoggedIn": { - "message": "Você logou na sua conta com sucesso" + "message": "Você conectou-se à sua conta com sucesso" }, "youMayCloseThisWindow": { "message": "Você pode fechar esta janela" }, "masterPassSent": { - "message": "Enviamos um e-mail com a dica da sua senha mestra." + "message": "Enviamos um e-mail com a dica da sua senha principal." }, "verificationCodeRequired": { "message": "O código de verificação é necessário." }, "webauthnCancelOrTimeout": { - "message": "A autenticação foi cancelada ou demorou muito. Por favor tente novamente." + "message": "A autenticação foi cancelada ou demorou muito. Tente novamente." }, "invalidVerificationCode": { "message": "Código de verificação inválido" }, "valueCopied": { - "message": " copiado", + "message": "$VALUE$ copiado(a)", "description": "Value has been copied to the clipboard.", "placeholders": { "value": { @@ -866,16 +901,16 @@ "message": "Não é possível preencher automaticamente o item selecionado nesta página. Em vez disso, copie e cole a informação." }, "totpCaptureError": { - "message": "Não foi possível escanear o código QR a partir da página atual" + "message": "Não é possível ler o código QR da página atual" }, "totpCaptureSuccess": { "message": "Chave do autenticador adicionada" }, "totpCapture": { - "message": "Escaneie o código QR do autenticador na página atual" + "message": "Ler o código QR do autenticador na página atual" }, "totpHelperTitle": { - "message": "Tornar a verificação em duas etapas fácil" + "message": "Torne a verificação em 2 etapas mais simples" }, "totpHelper": { "message": "O Bitwarden pode armazenar e preencher códigos de verificação de duas etapas. Copie e cole a chave neste campo." @@ -887,10 +922,10 @@ "message": "Saiba mais sobre os autenticadores" }, "copyTOTP": { - "message": "Copiar chave de Autenticação (TOTP)" + "message": "Copiar chave do autenticador (TOTP)" }, "loggedOut": { - "message": "Sessão encerrada" + "message": "Desconectado" }, "loggedOutDesc": { "message": "Você foi desconectado de sua conta." @@ -899,43 +934,43 @@ "message": "A sua sessão expirou." }, "logIn": { - "message": "Fazer login" + "message": "Conectar-se" }, "logInToBitwarden": { - "message": "Inicie a sessão no Bitwarden" + "message": "Conecte-se ao Bitwarden" }, "enterTheCodeSentToYourEmail": { - "message": "Digite o código enviado por e-mail" + "message": "Digite o código enviado ao seu e-mail" }, "enterTheCodeFromYourAuthenticatorApp": { - "message": "Digite o código a partir do seu autenticador" + "message": "Digite o código do seu autenticador" }, "pressYourYubiKeyToAuthenticate": { - "message": "Insira sua YubiKey para autenticar" + "message": "Pressione sua YubiKey para autenticar-se" }, "duoTwoFactorRequiredPageSubtitle": { - "message": "A autenticação de dois fatores é necessária para sua conta. Siga os passos abaixo para conseguir entrar." + "message": "A autenticação de duas etapas do Duo é necessária para sua conta. Siga os passos abaixo para conseguir se conectar." }, "followTheStepsBelowToFinishLoggingIn": { - "message": "Siga os passos abaixo para finalizar o login." + "message": "Siga os passos abaixo para terminar de se conectar." }, "followTheStepsBelowToFinishLoggingInWithSecurityKey": { - "message": "Follow the steps below to finish logging in with your security key." + "message": "Siga os passos abaixo para finalizar de se conectar com a sua chave de segurança." }, "restartRegistration": { - "message": "Reiniciar registro" + "message": "Reiniciar cadastro" }, "expiredLink": { - "message": "Link expirado" + "message": "Link vencido" }, "pleaseRestartRegistrationOrTryLoggingIn": { - "message": "Por favor, reinicie o registro ou tente fazer login." + "message": "Reinicie o cadastro ou tente conectar-se." }, "youMayAlreadyHaveAnAccount": { "message": "Você pode já ter uma conta" }, "logOutConfirmation": { - "message": "Você tem certeza que deseja sair?" + "message": "Tem certeza que quer se desconectar?" }, "yes": { "message": "Sim" @@ -956,40 +991,40 @@ "message": "Pasta adicionada" }, "twoStepLoginConfirmation": { - "message": "O login de duas etapas torna a sua conta mais segura ao exigir que digite um código de segurança de um aplicativo de autenticação quando for iniciar a sessão. O login de duas etapas pode ser ativado no cofre web bitwarden.com. Deseja visitar o site agora?" + "message": "A autenticação de duas etapas torna a sua conta mais segura ao exigir que digite um código de segurança de um aplicativo autenticador ao se conectar. A autenticação de duas etapas pode ser ativada no cofre web do bitwarden.com. Deseja visitar o site agora?" }, "twoStepLoginConfirmationContent": { - "message": "Torne sua conta mais segura configurando o 'login' em duas etapas no aplicativo ‘web’ do Bitwarden." + "message": "Torne sua conta mais segura configurando a autenticação em duas etapas no aplicativo web do Bitwarden." }, "twoStepLoginConfirmationTitle": { - "message": "Continuar para o aplicativo web?" + "message": "Continuar no aplicativo web?" }, "editedFolder": { - "message": "Pasta Editada" + "message": "Pasta salva" }, "deleteFolderConfirmation": { - "message": "Você tem certeza que deseja excluir esta pasta?" + "message": "Tem certeza que deseja apagar esta pasta?" }, "deletedFolder": { - "message": "Pasta excluída" + "message": "Pasta apagada" }, "gettingStartedTutorial": { - "message": "Tutorial de Introdução" + "message": "Tutorial de introdução" }, "gettingStartedTutorialVideo": { "message": "Assista o nosso tutorial de introdução e saiba como tirar o máximo de proveito da extensão de navegador." }, "syncingComplete": { - "message": "Sincronização completa" + "message": "Sincronização concluída" }, "syncingFailed": { - "message": "A Sincronização falhou" + "message": "A sincronização falhou" }, "passwordCopied": { "message": "Senha copiada" }, "uri": { - "message": "URL" + "message": "URI" }, "uriPosition": { "message": "URI $POSITION$", @@ -1012,77 +1047,89 @@ "message": "Item adicionado" }, "editedItem": { - "message": "Item editado" + "message": "Item salvo" + }, + "savedWebsite": { + "message": "Site salvo" + }, + "savedWebsites": { + "message": "Sites salvos ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } }, "deleteItemConfirmation": { - "message": "Você tem certeza que deseja enviar este item para a lixeira?" + "message": "Tem certeza que quer enviar este item para a lixeira?" }, "deletedItem": { - "message": "Item excluído" + "message": "Item enviado para a lixeira" }, "overwritePassword": { - "message": "Sobrescrever Senha" + "message": "Substituir senha" }, "overwritePasswordConfirmation": { "message": "Você tem certeza que deseja substituir a senha atual?" }, "overwriteUsername": { - "message": "Sobrescrever Usuário" + "message": "Substituir nome de usuário" }, "overwriteUsernameConfirmation": { - "message": "Tem certeza que deseja substituir o usuário atual?" + "message": "Tem certeza que deseja substituir o nome de usuário atual?" }, "searchFolder": { - "message": "Pesquisar pasta" + "message": "Buscar na pasta" }, "searchCollection": { - "message": "Pesquisar coleção" + "message": "Buscar no conjunto" }, "searchType": { - "message": "Pesquisar tipo" + "message": "Buscar tipo" }, "noneFolder": { - "message": "Nenhuma Pasta", + "message": "Sem pasta", "description": "This is the folder for uncategorized items" }, "enableAddLoginNotification": { - "message": "Peça para adicionar login" + "message": "Pedir para adicionar credencial" }, "vaultSaveOptionsTitle": { - "message": "Salvar nas opções do cofre" + "message": "Opções de salvar no cofre" }, "addLoginNotificationDesc": { - "message": "A \"Notificação de Adicionar Login\" pede para salvar automaticamente novas logins para o seu cofre quando você inicia uma sessão em um site pela primeira vez." + "message": "Pedir para adicionar um item se um não for encontrado no seu cofre." }, "addLoginNotificationDescAlt": { - "message": "Pedir para adicionar um item se um não for encontrado no seu cofre. Aplica-se a todas as contas logadas." + "message": "Pedir para adicionar um item se um não for encontrado no seu cofre. Aplica-se a todas as contas conectadas." }, "showCardsInVaultViewV2": { "message": "Sempre mostrar cartões como sugestões de preenchimento automático na tela do Cofre" }, "showCardsCurrentTab": { - "message": "Mostrar cartões em páginas com guias." + "message": "Mostrar cartões na página da aba" }, "showCardsCurrentTabDesc": { - "message": "Exibir itens de cartão em páginas com abas para simplificar o preenchimento automático" + "message": "Listar itens de cartão na página da aba para preenchimento automático fácil." }, "showIdentitiesInVaultViewV2": { "message": "Sempre mostrar identidades como sugestões de preenchimento automático na tela do Cofre" }, "showIdentitiesCurrentTab": { - "message": "Exibir Identidades na Aba Atual" + "message": "Mostrar identidades na página da aba" }, "showIdentitiesCurrentTabDesc": { - "message": "Liste os itens de identidade na aba atual para facilitar preenchimento automático." + "message": "Listar as identidades na página da aba para facilitar o preenchimento automático." }, "clickToAutofillOnVault": { "message": "Clique em itens na tela do Cofre para preencher automaticamente" }, "clickToAutofill": { - "message": "Selecione o item para preenchê-lo automaticamente" + "message": "Clique em um item para preenchê-lo automaticamente" }, "clearClipboard": { - "message": "Limpar Área de Transferência", + "message": "Limpar área de transferência", "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "clearClipboardDesc": { @@ -1096,7 +1143,7 @@ "message": "Salvar" }, "notificationViewAria": { - "message": "View $ITEMNAME$, opens in new window", + "message": "Ver $ITEMNAME$, abre em uma nova janela", "placeholders": { "itemName": { "content": "$1" @@ -1105,18 +1152,18 @@ "description": "Aria label for the view button in notification bar confirmation message" }, "notificationNewItemAria": { - "message": "New Item, opens in new window", + "message": "Novo item, abre em uma nova janela", "description": "Aria label for the new item button in notification bar confirmation message when error is prompted" }, "notificationEditTooltip": { - "message": "Edit before saving", + "message": "Editar antes de salvar", "description": "Tooltip and Aria label for edit button on cipher item" }, "newNotification": { - "message": "New notification" + "message": "Nova notificação" }, "labelWithNotification": { - "message": "$LABEL$: New notification", + "message": "$LABEL$: Nova notificação", "description": "Label for the notification with a new login suggestion.", "placeholders": { "label": { @@ -1126,15 +1173,15 @@ } }, "notificationLoginSaveConfirmation": { - "message": "saved to Bitwarden.", + "message": "salvo no Bitwarden.", "description": "Shown to user after item is saved." }, "notificationLoginUpdatedConfirmation": { - "message": "updated in Bitwarden.", + "message": "atualizado no Bitwarden.", "description": "Shown to user after item is updated." }, "selectItemAriaLabel": { - "message": "Select $ITEMTYPE$, $ITEMNAME$", + "message": "Selecionar $ITEMTYPE$, $ITEMNAME$", "description": "Used by screen readers. $1 is the item type (like vault or folder), $2 is the selected item name.", "placeholders": { "itemType": { @@ -1146,35 +1193,35 @@ } }, "saveAsNewLoginAction": { - "message": "Salvar como nova sessão", + "message": "Salvar como nova credencial", "description": "Button text for saving login details as a new entry." }, "updateLoginAction": { - "message": "Atualizar sessão", + "message": "Atualizar credencial", "description": "Button text for updating an existing login entry." }, "unlockToSave": { - "message": "Unlock to save this login", + "message": "Desbloqueie para salvar esta credencial", "description": "User prompt to take action in order to save the login they just entered." }, "saveLogin": { - "message": "Save login", + "message": "Salvar credencial", "description": "Prompt asking the user if they want to save their login details." }, "updateLogin": { - "message": "Update existing login", + "message": "Atualizar credencial existente", "description": "Prompt asking the user if they want to update an existing login entry." }, "loginSaveSuccess": { - "message": "Sessão salva", + "message": "Credencial salva", "description": "Message displayed when login details are successfully saved." }, "loginUpdateSuccess": { - "message": "Sessão atualizada", + "message": "Credencial atualizada", "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": "Ótimo trabalho! Você passou pelas etapas para tornar você e $ORGANIZATION$ mais seguros.", "placeholders": { "organization": { "content": "$1" @@ -1183,7 +1230,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": "Obrigado por tornar $ORGANIZATION$ mais seguro. Você tem mais $TASK_COUNT$ senhas para atualizar.", "placeholders": { "organization": { "content": "$1" @@ -1195,7 +1242,7 @@ "description": "Shown to user after login is updated." }, "nextSecurityTaskAction": { - "message": "Change next password", + "message": "Alterar próxima senha", "description": "Message prompting user to undertake completion of another security task." }, "saveFailure": { @@ -1207,25 +1254,25 @@ "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": "Ao alterar sua senha, você precisará se conectar com a sua senha nova. Sessões ativas em outros dispositivos serão desconectadas dentro de uma hora." }, "accountRecoveryUpdateMasterPasswordSubtitle": { - "message": "Change your master password to complete account recovery." + "message": "Altere a sua senha principal para concluir a recuperação da conta." }, "enableChangedPasswordNotification": { - "message": "Pedir para atualizar os dados de login existentes" + "message": "Pedir para atualizar credencial existente" }, "changedPasswordNotificationDesc": { - "message": "Peça para atualizar a senha de login quando uma mudança for detectada em um site." + "message": "Peça para atualizar a senha de uma credencial quando uma mudança for detectada em um site." }, "changedPasswordNotificationDescAlt": { - "message": "Pedir para atualizar a senha de uma credencial quando uma alteração for detectada em um site. Aplica-se a todas as contas conectadas." + "message": "Peça para atualizar a senha de uma credencial quando uma mudança for detectada em um site. Aplica-se a todas as contas conectadas." }, "enableUsePasskeys": { "message": "Pedir para salvar e usar chaves de acesso" }, "usePasskeysDesc": { - "message": "Pedir para salvar novas chaves de acesso ou entrar com as mesmas armazenadas no seu cofre. Aplica-se a todas as contas conectadas." + "message": "Pedir para salvar novas chaves de acesso ou se conectar com as mesmas armazenadas no seu cofre. Aplica-se a todas as contas conectadas." }, "notificationChangeDesc": { "message": "Você quer atualizar esta senha no Bitwarden?" @@ -1243,20 +1290,20 @@ "message": "Opções adicionais" }, "enableContextMenuItem": { - "message": "Mostrar opções de menu de contexto" + "message": "Mostrar opções do menu de contexto" }, "contextMenuItemDesc": { - "message": "Use um duplo clique para acessar a geração de usuários e senhas correspondentes para o site. " + "message": "Use um clique duplo para acessar a geração de senhas e credenciais correspondentes para o site." }, "contextMenuItemDescAlt": { - "message": "Use um clique secundário para acessar a geração de senha e os logins correspondentes para o site. Aplica-se a todas as contas logadas." + "message": "Use um clique secundário para acessar a geração de senha e as credenciais correspondentes para o site. Aplica-se a todas as contas conectadas." }, "defaultUriMatchDetection": { - "message": "Detecção de correspondência de URI padrão", + "message": "Detecção de correspondência padrão de URI", "description": "Default URI match detection for autofill." }, "defaultUriMatchDetectionDesc": { - "message": "Escolha a maneira padrão pela qual a detecção de correspondência de URI é manipulada para logins ao executar ações como preenchimento automático." + "message": "Escolha a maneira padrão pela qual a detecção de correspondência de URI é manipulada para credenciais ao executar ações como preenchimento automático." }, "theme": { "message": "Tema" @@ -1265,7 +1312,7 @@ "message": "Altere o tema de cores do aplicativo." }, "themeDescAlt": { - "message": "Altere o tema de cores da aplicação. Aplica-se para todas as contas conectadas." + "message": "Altere o tema de cores do aplicativo. Aplica-se para todas as contas conectadas." }, "dark": { "message": "Escuro", @@ -1279,13 +1326,13 @@ "message": "Exportar de" }, "exportVault": { - "message": "Exportar Cofre" + "message": "Exportar cofre" }, "fileFormat": { - "message": "Formato de arquivo" + "message": "Formato do arquivo" }, "fileEncryptedExportWarningDesc": { - "message": "Esta arquivo de exportação será protegido por senha e precisará da mesma para ser descriptografado." + "message": "Este arquivo de exportação será protegido por senha e precisará da mesma para ser descriptografado." }, "filePassword": { "message": "Senha do arquivo" @@ -1294,22 +1341,22 @@ "message": "Esta senha será usada para exportar e importar este arquivo" }, "accountRestrictedOptionDescription": { - "message": "Use sua chave criptográfica da conta, derivada do nome de usuário e senha mestra da sua conta, para criptografar a exportação e restringir importação para apenas a conta atual do Bitwarden." + "message": "Use a chave de criptografia da sua conta, derivada do nome de usuário e senha principal da sua conta, para criptografar a exportação e restringir a importação para apenas a conta atual do Bitwarden." }, "passwordProtectedOptionDescription": { - "message": "Defina uma senha de arquivo para criptografar a exportação e importá-la para qualquer conta do Bitwarden usando a senha para descriptografia." + "message": "Configure uma senha de arquivo para criptografar a exportação e importá-la para qualquer conta do Bitwarden usando a senha para descriptografá-la." }, "exportTypeHeading": { "message": "Tipo da exportação" }, "accountRestricted": { - "message": "Conta restrita" + "message": "Restrita à conta" }, "filePasswordAndConfirmFilePasswordDoNotMatch": { - "message": "\"Senha do arquivo\" e \"Confirmação de senha\" não correspondem." + "message": "\"Senha do arquivo\" e \"Confirmar senha do arquivo\" não correspondem." }, "warning": { - "message": "AVISO", + "message": "ALERTA", "description": "WARNING (should stay in capitalized letters if the language permits)" }, "warningCapitalized": { @@ -1317,10 +1364,10 @@ "description": "Warning (should maintain locale-relevant capitalization)" }, "confirmVaultExport": { - "message": "Confirmar Exportação do Cofre" + "message": "Confirmar exportação do cofre" }, "exportWarningDesc": { - "message": "Esta exportação contém os dados do seu cofre em um formato não criptografado. Você não deve armazenar ou enviar o arquivo exportado por canais inseguros (como e-mail). Exclua o arquivo imediatamente após terminar de usá-lo." + "message": "Esta exportação contém os dados do seu cofre em um formato não criptografado. Você não deve armazenar ou enviar o arquivo exportado por canais inseguros (como e-mail). Apague o arquivo imediatamente após terminar de usá-lo." }, "encExportKeyWarningDesc": { "message": "Esta exportação criptografa seus dados usando a chave de criptografia da sua conta. Se você rotacionar a chave de criptografia da sua conta, você deve exportar novamente, já que você não será capaz de descriptografar este arquivo de exportação." @@ -1329,16 +1376,16 @@ "message": "As chaves de criptografia são únicas para cada conta de usuário do Bitwarden, então você não pode importar um arquivo de exportação criptografado para uma conta diferente." }, "exportMasterPassword": { - "message": "Insira a sua senha mestra para exportar os dados do seu cofre." + "message": "Digite a sua senha principal para exportar os dados do seu cofre." }, "shared": { "message": "Compartilhado" }, "bitwardenForBusinessPageDesc": { - "message": "O Bitwarden para Business permite que você compartilhe os itens do seu cofre com outras pessoas usando uma organização. Saiba mais no site bitwarden.com." + "message": "O Bitwarden para Empresas permite que você compartilhe os itens do seu cofre com outras pessoas usando uma organização. Saiba mais no site do bitwarden.com." }, "moveToOrganization": { - "message": "Mover para a Organização" + "message": "Mover para organização" }, "movedItemToOrg": { "message": "$ITEMNAME$ movido para $ORGNAME$", @@ -1357,37 +1404,37 @@ "message": "Escolha uma organização para a qual deseja mover este item. Mudar para uma organização transfere a propriedade do item para essa organização. Você não será mais o proprietário direto deste item depois que ele for movido." }, "learnMore": { - "message": "Saber mais" + "message": "Saiba mais" }, "authenticatorKeyTotp": { - "message": "Chave de Autenticação (TOTP)" + "message": "Chave do autenticador (TOTP)" }, "verificationCodeTotp": { - "message": "Código de Verificação (TOTP)" + "message": "Código de verificação (TOTP)" }, "copyVerificationCode": { - "message": "Copiar Código de Verificação" + "message": "Copiar código de verificação" }, "attachments": { "message": "Anexos" }, "deleteAttachment": { - "message": "Excluir anexo" + "message": "Apagar anexo" }, "deleteAttachmentConfirmation": { - "message": "Tem a certeza de que deseja excluir este anexo?" + "message": "Tem a certeza de que deseja apagar este anexo?" }, "deletedAttachment": { - "message": "Anexo excluído" + "message": "Anexo apagado" }, "newAttachment": { - "message": "Adicionar Novo Anexo" + "message": "Adicionar novo anexo" }, "noAttachments": { - "message": "Sem anexos." + "message": "Nenhum anexo." }, "attachmentSaved": { - "message": "O anexo foi salvo." + "message": "Anexo salvo" }, "file": { "message": "Arquivo" @@ -1396,46 +1443,46 @@ "message": "Arquivo para compartilhar" }, "selectFile": { - "message": "Selecione um arquivo." + "message": "Selecione um arquivo" }, "maxFileSize": { "message": "O tamanho máximo do arquivo é de 500 MB." }, "featureUnavailable": { - "message": "Funcionalidade Indisponível" + "message": "Recurso indisponível" }, "legacyEncryptionUnsupported": { - "message": "Legacy encryption is no longer supported. Please contact support to recover your account." + "message": "A criptografia legada não é mais suportada. Entre em contato com o suporte para recuperar a sua conta." }, "premiumMembership": { - "message": "Assinatura Premium" + "message": "Plano Premium" }, "premiumManage": { - "message": "Gerenciar Plano" + "message": "Gerenciar plano" }, "premiumManageAlert": { - "message": "Você pode gerenciar a sua assinatura premium no cofre web em bitwarden.com. Você deseja visitar o site agora?" + "message": "Você pode gerenciar o seu plano no cofre web do bitwarden.com. Você deseja visitar o site agora?" }, "premiumRefresh": { - "message": "Atualizar Assinatura" + "message": "Recarregar plano" }, "premiumNotCurrentMember": { "message": "Você não é um membro Premium atualmente." }, "premiumSignUpAndGet": { - "message": "Registre-se para uma assinatura Premium e obtenha:" + "message": "Inscreva-se para um plano Premium e receba:" }, "ppremiumSignUpStorage": { - "message": "1 GB de armazenamento de arquivos encriptados." + "message": "1 GB de armazenamento criptografado para anexo de arquivos." }, "premiumSignUpEmergency": { "message": "Acesso de emergência." }, "premiumSignUpTwoStepOptions": { - "message": "Opções de login em duas etapas como YubiKey e Duo." + "message": "Opções de autenticação em duas etapas proprietárias como YubiKey e Duo." }, "ppremiumSignUpReports": { - "message": "Higiene de senha, saúde da conta, e relatórios sobre violação de dados para manter o seu cofre seguro." + "message": "Relatórios de higiene de senha, saúde da conta, e vazamentos de dados para manter o seu cofre seguro." }, "ppremiumSignUpTotp": { "message": "Gerador de códigos de verificação TOTP (2FA) para credenciais no seu cofre." @@ -1444,7 +1491,7 @@ "message": "Prioridade no suporte ao cliente." }, "ppremiumSignUpFuture": { - "message": "Todas as funcionalidades Premium no futuro. Mais em breve!" + "message": "Todos as recursos do Premium no futuro. Mais em breve!" }, "premiumPurchase": { "message": "Comprar Premium" @@ -1453,16 +1500,16 @@ "message": "Você pode comprar Premium nas configurações de sua conta no aplicativo web do Bitwarden." }, "premiumCurrentMember": { - "message": "Você é um membro premium!" + "message": "Você é um membro Premium!" }, "premiumCurrentMemberThanks": { "message": "Obrigado por apoiar o Bitwarden." }, "premiumFeatures": { - "message": "Atualize para a versão Premium e receba:" + "message": "Faça upgrade para o Premium e receba:" }, "premiumPrice": { - "message": "Tudo por apenas %price% /ano!", + "message": "Tudo por apenas $PRICE$ por ano!", "placeholders": { "price": { "content": "$1", @@ -1480,28 +1527,22 @@ } }, "refreshComplete": { - "message": "Atualização completa" + "message": "Recarregamento concluído" }, "enableAutoTotpCopy": { "message": "Copiar TOTP automaticamente" }, "disableAutoTotpCopyDesc": { - "message": "Se sua credencial tiver uma chave de autenticação, copie o código de verificação TOTP quando for autopreenchê-la." + "message": "Se uma credencial tiver uma chave de autenticador, copie o código de verificação TOTP quando for preenchê-la automaticamente." }, "enableAutoBiometricsPrompt": { - "message": "Pedir biometria ao iniciar" - }, - "premiumRequired": { - "message": "Requer Assinatura Premium" - }, - "premiumRequiredDesc": { - "message": "Uma conta premium é necessária para usar esse recurso." + "message": "Pedir biometria ao abrir" }, "authenticationTimeout": { - "message": "Tempo de autenticação esgotado" + "message": "Tempo limite da autenticação atingido" }, "authenticationSessionTimedOut": { - "message": "A sessão de autenticação expirou. Por favor, reinicie o processo de login." + "message": "A sessão de autenticação expirou. Reinicie o processo de autenticação." }, "verificationCodeEmailSent": { "message": "E-mail de verificação enviado para $EMAIL$.", @@ -1520,13 +1561,13 @@ "description": "Select another two-step login method" }, "useYourRecoveryCode": { - "message": "Use seu código de recuperação" + "message": "Usar seu código de recuperação" }, "insertU2f": { - "message": "Insira a sua chave de segurança na porta USB do seu computador. Se ele tiver um botão, toque nele." + "message": "Insira a sua chave de segurança na porta USB do seu computador. Se ela tiver um botão, toque nele." }, "openInNewTab": { - "message": "Abrir numa nova aba" + "message": "Abrir em uma nova aba" }, "webAuthnAuthenticate": { "message": "Autenticar WebAuthn" @@ -1534,53 +1575,62 @@ "readSecurityKey": { "message": "Ler chave de segurança" }, + "readingPasskeyLoading": { + "message": "Lendo a chave de acesso..." + }, + "passkeyAuthenticationFailed": { + "message": "Falha na autenticação da chave de acesso" + }, + "useADifferentLogInMethod": { + "message": "Usar um método de autenticação diferente" + }, "awaitingSecurityKeyInteraction": { "message": "Aguardando interação com a chave de segurança..." }, "loginUnavailable": { - "message": "Sessão Indisponível" + "message": "Autenticação indisponível" }, "noTwoStepProviders": { - "message": "Esta conta tem a verificação de duas etapas ativado, no entanto, nenhum dos provedores de verificação de duas etapas configurados são suportados por este navegador web." + "message": "Esta conta tem a autenticação de duas etapas ativada, no entanto, nenhum dos provedores configurados são suportados por este navegador web." }, "noTwoStepProviders2": { - "message": "Por favor utilize um navegador web suportado (tal como o Chrome) e/ou inclua provedores adicionais que são melhor suportados entre navegadores web (tal como uma aplicativo de autenticação)." + "message": "Use um navegador web suportado (tal como o Chrome) e/ou inclua provedores adicionais que são melhor suportados entre navegadores web (tal como um aplicativo autenticator)." }, "twoStepOptions": { - "message": "Opções de Login em Duas Etapas" + "message": "Opções de autenticação em duas etapas" }, "selectTwoStepLoginMethod": { - "message": "Escolher iniciar sessão em duas etapas" + "message": "Selecionar método de autenticação em duas etapas" }, "recoveryCodeTitle": { - "message": "Código de Recuperação" + "message": "Código de recuperação" }, "authenticatorAppTitle": { - "message": "Aplicativo de Autenticação" + "message": "Aplicativo autenticador" }, "authenticatorAppDescV2": { - "message": "Insira um código gerado por um aplicativo autenticador como o Bitwarden Authenticator.", + "message": "Digite um código gerado por um aplicativo autenticador como o Bitwarden Authenticator.", "description": "'Bitwarden Authenticator' is a product name and should not be translated." }, "yubiKeyTitleV2": { - "message": "Chave de Segurança Yubico OTP" + "message": "Chave de segurança Yubico OTP" }, "yubiKeyDesc": { - "message": "Utilize uma YubiKey para acessar a sua conta. Funciona com YubiKey 4, 4 Nano, 4C, e dispositivos NEO." + "message": "Utilize uma YubiKey para acessar a sua conta. Funciona com os dispositivos YubiKey 4, 4 Nano, 4C, e NEO." }, "duoDescV2": { - "message": "Insira um código gerado pelo Duo Security.", + "message": "Digite um código gerado pelo Duo Security.", "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "duoOrganizationDesc": { - "message": "Verifique com o Duo Security utilizando o aplicativo Duo Mobile, SMS, chamada telefônica, ou chave de segurança U2F.", + "message": "Verifique com o Duo Security para a sua organização usando o aplicativo Duo Mobile, SMS, chamada telefônica, ou chave de segurança U2F.", "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "webAuthnTitle": { "message": "WebAuthn FIDO2" }, "webAuthnDesc": { - "message": "Utilize qualquer chave de segurança ativada por WebAuthn para acessar a sua conta." + "message": "Use qualquer chave de segurança compatível com WebAuthn para acessar a sua conta." }, "emailTitle": { "message": "E-mail" @@ -1589,60 +1639,63 @@ "message": "Digite o código enviado para seu e-mail." }, "selfHostedEnvironment": { - "message": "Ambiente Auto-hospedado" + "message": "Ambiente auto-hospedado" }, "selfHostedBaseUrlHint": { - "message": "Especifique a URL de base da sua instalação local do Bitwarden. Exemplo: https://bitwarden.company.com" + "message": "Especifique o URL de base da sua instalação local do Bitwarden. Exemplo: https://bitwarden.company.com" }, "selfHostedCustomEnvHeader": { - "message": "Para usuários avançados. Você pode especificar a URL de base de cada serviço independentemente." + "message": "Para configuração avançada, você pode especificar a URL de base de cada serviço independentemente." }, "selfHostedEnvFormInvalid": { - "message": "Você deve adicionar um URL do servidor de base ou pelo menos um ambiente personalizado." + "message": "Você deve adicionar um URL de base de um servidor ou pelo menos um ambiente personalizado." + }, + "selfHostedEnvMustUseHttps": { + "message": "URLs devem usar HTTPS." }, "customEnvironment": { - "message": "Ambiente Personalizado" + "message": "Ambiente personalizado" }, "baseUrl": { - "message": "URL do Servidor" + "message": "URL do servidor" }, "selfHostBaseUrl": { - "message": "URL do servidor auto-host", + "message": "URL do servidor auto-hospedado", "description": "Label for field requesting a self-hosted integration service URL" }, "apiUrl": { - "message": "URL do Servidor da API" + "message": "URL do servidor da API" }, "webVaultUrl": { - "message": "URL do Servidor do Cofre Web" + "message": "URL do servidor do cofre web" }, "identityUrl": { - "message": "URL do Servidor de Identidade" + "message": "URL do servidor de identidade" }, "notificationsUrl": { - "message": "URL do Servidor de Notificações" + "message": "URL do servidor de notificações" }, "iconsUrl": { - "message": "URL do Servidor de Ícones" + "message": "URL do servidor de ícones" }, "environmentSaved": { - "message": "As URLs do ambiente foram salvas." + "message": "URLs do ambiente salvas" }, "showAutoFillMenuOnFormFields": { - "message": "Exibir o menu de preenchimento automático nos campos do formulário", + "message": "Mostrar o menu de preenchimento automático em campos de formulário", "description": "Represents the message for allowing the user to enable the autofill overlay" }, "autofillSuggestionsSectionTitle": { "message": "Sugestões de preenchimento automático" }, "autofillSpotlightTitle": { - "message": "Easily find autofill suggestions" + "message": "Encontre sugestões de preenchimento automático com facilidade" }, "autofillSpotlightDesc": { - "message": "Turn off your browser's autofill settings, so they don't conflict with Bitwarden." + "message": "Desative as configurações de preenchimento automático do seu navegador, para que elas não entrem em conflito com o Bitwarden." }, "turnOffBrowserAutofill": { - "message": "Turn off $BROWSER$ autofill", + "message": "Desativar o preenchimento automático do $BROWSER$", "placeholders": { "browser": { "content": "$1", @@ -1651,16 +1704,37 @@ } }, "turnOffAutofill": { - "message": "Turn off autofill" + "message": "Desativar o preenchimento automático" + }, + "confirmAutofill": { + "message": "Confirmar preenchimento automático" + }, + "confirmAutofillDesc": { + "message": "Esse site não corresponde aos detalhes salvos na credencial. Antes de preencher suas credenciais de acesso, certifique-se de que é um site confiável." }, "showInlineMenuLabel": { - "message": "Mostrar sugestões de preenchimento automático nos campos de formulários" + "message": "Mostrar sugestões de preenchimento automático em campos de formulário" + }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Como que o Bitwarden protege seus dados de phishing?" + }, + "currentWebsite": { + "message": "Site atual" + }, + "autofillAndAddWebsite": { + "message": "Preencher automaticamente e adicionar este site" + }, + "autofillWithoutAdding": { + "message": "Preencher automaticamente sem adicionar" + }, + "doNotAutofill": { + "message": "Não preencher automaticamente" }, "showInlineMenuIdentitiesLabel": { "message": "Exibir identidades como sugestões" }, "showInlineMenuCardsLabel": { - "message": "Exibir cards como sugestões" + "message": "Exibir cartões como sugestões" }, "showInlineMenuOnIconSelectionLabel": { "message": "Exibir sugestões quando o ícone for selecionado" @@ -1669,13 +1743,13 @@ "message": "Aplica-se a todas as contas conectadas." }, "turnOffBrowserBuiltInPasswordManagerSettings": { - "message": "Desative o gerenciador de senhas padrão do seu navegador para evitar conflitos." + "message": "Desative o gerenciador de senhas embutido no seu navegador para evitar conflitos." }, "turnOffBrowserBuiltInPasswordManagerSettingsLink": { - "message": "Editar configurações do navegador." + "message": "Edite as configurações do navegador." }, "autofillOverlayVisibilityOff": { - "message": "Desligado", + "message": "Desativado", "description": "Overlay setting select option for disabling autofill overlay" }, "autofillOverlayVisibilityOnFieldFocus": { @@ -1687,16 +1761,16 @@ "description": "Overlay appearance select option for showing the field on click of the overlay icon" }, "enableAutoFillOnPageLoadSectionTitle": { - "message": "Preenchimento automático ao carregar a página" + "message": "Preencher automaticamente ao carregar a página" }, "enableAutoFillOnPageLoad": { "message": "Preencher automaticamente ao carregar a página" }, "enableAutoFillOnPageLoadDesc": { - "message": "Se um formulário de login for detectado, realizar automaticamente um auto-preenchimento quando a página web carregar." + "message": "Se um formulário de credencial for detectado, preencha automaticamente quando a página carregar." }, "experimentalFeature": { - "message": "Sites comprometidos ou não confiáveis podem tomar vantagem do autopreenchimento ao carregar a página." + "message": "Sites comprometidos ou não confiáveis podem explorar do preenchimento automático ao carregar a página." }, "learnMoreAboutAutofillOnPageLoadLinkText": { "message": "Saiba mais sobre riscos" @@ -1705,7 +1779,7 @@ "message": "Saiba mais sobre preenchimento automático" }, "defaultAutoFillOnPageLoad": { - "message": "Configuração de autopreenchimento padrão para itens de credenciais" + "message": "Configuração padrão de preenchimento automático para credenciais" }, "defaultAutoFillOnPageLoadDesc": { "message": "Você pode desativar o preenchimento automático no carregamento da página para credenciais individuais na tela de Editar do item." @@ -1726,7 +1800,7 @@ "message": "Abrir cofre na barra lateral" }, "commandAutofillLoginDesc": { - "message": "Preencher automaticamente o último login utilizado para o site atual" + "message": "Preencher automaticamente a última credencial utilizada para o site atual" }, "commandAutofillCardDesc": { "message": "Preencher automaticamente o último cartão utilizado para o site atual" @@ -1735,25 +1809,25 @@ "message": "Preencher automaticamente a última identidade usada para o site atual" }, "commandGeneratePasswordDesc": { - "message": "Gerar e copiar uma nova senha aleatória para a área de transferência." + "message": "Gere e copie uma nova senha aleatória para a área de transferência" }, "commandLockVaultDesc": { "message": "Bloquear o cofre" }, "customFields": { - "message": "Campos Personalizados" + "message": "Campos personalizados" }, "copyValue": { - "message": "Copiar Valor" + "message": "Copiar valor" }, "value": { "message": "Valor" }, "newCustomField": { - "message": "Novo Campo Personalizado" + "message": "Novo campo personalizado" }, "dragToSort": { - "message": "Arrastar para ordenar" + "message": "Arraste para ordenar" }, "dragToReorder": { "message": "Arraste para reorganizar" @@ -1762,7 +1836,7 @@ "message": "Texto" }, "cfTypeHidden": { - "message": "Ocultado" + "message": "Oculto" }, "cfTypeBoolean": { "message": "Booleano" @@ -1779,13 +1853,13 @@ "description": "This describes a value that is 'linked' (tied) to another value." }, "popup2faCloseMessage": { - "message": "Ao clicar fora da janela de pop-up para verificar seu e-mail para o seu código de verificação fará com que este pop-up feche. Você deseja abrir este pop-up em uma nova janela para que ele não seja fechado?" + "message": "Ao clicar fora da janela de pop-up para conferir seu e-mail pelo seu código de verificação, este pop-up fechará. Você deseja abrir este pop-up em uma nova janela para que ele não seja fechado?" }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Mostrar ícones de sites e obter URLs de alteração de senha" }, "cardholderName": { - "message": "Titular do Cartão" + "message": "Nome do titular do cartão" }, "number": { "message": "Número" @@ -1794,10 +1868,13 @@ "message": "Bandeira" }, "expirationMonth": { - "message": "Mês de Vencimento" + "message": "Mês de vencimento" }, "expirationYear": { - "message": "Ano de Vencimento" + "message": "Ano de vencimento" + }, + "monthly": { + "message": "mês" }, "expiration": { "message": "Vencimento" @@ -1839,13 +1916,13 @@ "message": "Dezembro" }, "securityCode": { - "message": "Código de Segurança" + "message": "Código de segurança" }, "cardNumber": { - "message": "card number" + "message": "número do cartão" }, "ex": { - "message": "ex." + "message": "p. ex." }, "title": { "message": "Título" @@ -1863,34 +1940,34 @@ "message": "Dr" }, "mx": { - "message": "Mx" + "message": "Sre" }, "firstName": { - "message": "Primeiro Nome" + "message": "Primeiro nome" }, "middleName": { - "message": "Nome do Meio" + "message": "Nome do meio" }, "lastName": { - "message": "Último Nome" + "message": "Sobrenome" }, "fullName": { - "message": "Nome Completo" + "message": "Nome completo" }, "identityName": { - "message": "Nome de Identidade" + "message": "Nome na identidade" }, "company": { "message": "Empresa" }, "ssn": { - "message": "Número de Segurança Social" + "message": "Número de CPF" }, "passportNumber": { - "message": "Número do Passaporte" + "message": "Número do passaporte" }, "licenseNumber": { - "message": "Número da Licença" + "message": "Número da licença" }, "email": { "message": "E-mail" @@ -1911,13 +1988,13 @@ "message": "Endereço 3" }, "cityTown": { - "message": "Cidade / Localidade" + "message": "Cidade ou localidade" }, "stateProvince": { - "message": "Estado / Província" + "message": "Estado ou província" }, "zipPostalCode": { - "message": "CEP / Código Postal" + "message": "CEP / Código postal" }, "country": { "message": "País" @@ -1932,7 +2009,7 @@ "message": "Credenciais" }, "typeSecureNote": { - "message": "Nota Segura" + "message": "Anotação segura" }, "typeCard": { "message": "Cartão" @@ -1944,86 +2021,86 @@ "message": "Chave SSH" }, "typeNote": { - "message": "Nota" + "message": "Anotação" }, "newItemHeaderLogin": { - "message": "New Login", + "message": "Nova credencial", "description": "Header for new login item type" }, "newItemHeaderCard": { - "message": "New Card", + "message": "Novo cartão", "description": "Header for new card item type" }, "newItemHeaderIdentity": { - "message": "New Identity", + "message": "Nova identidade", "description": "Header for new identity item type" }, "newItemHeaderNote": { - "message": "New Note", + "message": "Nova anotação", "description": "Header for new note item type" }, "newItemHeaderSshKey": { - "message": "New SSH key", + "message": "Nova chave SSH", "description": "Header for new SSH key item type" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Novo Send de texto", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Novo Send de arquivo", "description": "Header for new file send" }, "editItemHeaderLogin": { - "message": "Edit Login", + "message": "Editar credencial", "description": "Header for edit login item type" }, "editItemHeaderCard": { - "message": "Edit Card", + "message": "Editar cartão", "description": "Header for edit card item type" }, "editItemHeaderIdentity": { - "message": "Edit Identity", + "message": "Editar identidade", "description": "Header for edit identity item type" }, "editItemHeaderNote": { - "message": "Edit Note", + "message": "Editar anotação", "description": "Header for edit note item type" }, "editItemHeaderSshKey": { - "message": "Edit SSH key", + "message": "Editar chave 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 arquivo", "description": "Header for edit file send" }, "viewItemHeaderLogin": { - "message": "View Login", + "message": "Ver credencial", "description": "Header for view login item type" }, "viewItemHeaderCard": { - "message": "View Card", + "message": "Ver cartão", "description": "Header for view card item type" }, "viewItemHeaderIdentity": { - "message": "View Identity", + "message": "Ver identidade", "description": "Header for view identity item type" }, "viewItemHeaderNote": { - "message": "View Note", + "message": "Ver anotação", "description": "Header for view note item type" }, "viewItemHeaderSshKey": { - "message": "View SSH key", + "message": "Ver chave SSH", "description": "Header for view SSH key item type" }, "passwordHistory": { - "message": "Histórico de Senha" + "message": "Histórico de senhas" }, "generatorHistory": { "message": "Histórico do gerador" @@ -2032,16 +2109,16 @@ "message": "Limpar histórico do gerador" }, "cleargGeneratorHistoryDescription": { - "message": "Se continuar, todas as entradas serão permanentemente excluídas do histórico do gerador. Tem certeza que deseja continuar?" + "message": "Se continuar, todos os itens serão apagados para sempre do histórico do gerador. Tem certeza que deseja continuar?" }, "back": { "message": "Voltar" }, "collections": { - "message": "Coleções" + "message": "Conjuntos" }, "nCollections": { - "message": "Coleções $COUNT$", + "message": "$COUNT$ conjuntos", "placeholders": { "count": { "content": "$1", @@ -2056,7 +2133,7 @@ "message": "Abrir em uma nova janela" }, "refresh": { - "message": "Atualizar" + "message": "Recarregar" }, "cards": { "message": "Cartões" @@ -2068,7 +2145,7 @@ "message": "Credenciais" }, "secureNotes": { - "message": "Notas seguras" + "message": "Anotações seguras" }, "sshKeys": { "message": "Chaves SSH" @@ -2078,10 +2155,10 @@ "description": "To clear something out. example: To clear browser history." }, "checkPassword": { - "message": "Verifique se a senha foi exposta." + "message": "Confira se a senha foi exposta." }, "passwordExposed": { - "message": "Esta senha foi exposta $VALUE$ vez(es) em violações de dados. Você deve alterá-la.", + "message": "Esta senha foi exposta $VALUE$ vez(es) em vazamentos de dados. Você deve alterá-la.", "placeholders": { "value": { "content": "$1", @@ -2090,7 +2167,7 @@ } }, "passwordSafe": { - "message": "Esta senha não foi encontrada em violações de dados conhecidas. Deve ser seguro de usar." + "message": "Esta senha não foi encontrada em vazamentos de dados conhecidos. Deve ser segura de usar." }, "baseDomain": { "message": "Domínio de base", @@ -2127,10 +2204,10 @@ "description": "Default URI match detection for autofill." }, "toggleOptions": { - "message": "Alternar Opções" + "message": "Habilitar opções" }, "toggleCurrentUris": { - "message": "Alternar URIs atuais", + "message": "Habilitar URIs atuais", "description": "Toggle the display of the URIs of the currently open tabs in the browser." }, "currentUri": { @@ -2145,10 +2222,10 @@ "message": "Tipos" }, "allItems": { - "message": "Todos os Itens" + "message": "Todos os itens" }, "noPasswordsInList": { - "message": "Não existem senhas para listar." + "message": "Não há senhas para listar." }, "clearHistory": { "message": "Limpar histórico" @@ -2174,23 +2251,23 @@ "description": "ex. Date this item was created" }, "datePasswordUpdated": { - "message": "Senha Atualizada", + "message": "Senha atualizada", "description": "ex. Date this password was updated" }, "neverLockWarning": { - "message": "Você tem certeza que deseja usar a opção \"Nunca\"? Definir suas opções de bloqueio para \"Nunca\" armazena a chave de criptografia do seu cofre no seu dispositivo. Se você usar esta opção, você deve garantir que irá manter o seu dispositivo devidamente protegido." + "message": "Você tem certeza que deseja usar a opção \"Nunca\"? Configurar suas opções de bloqueio para \"Nunca\" armazena a chave de criptografia do seu cofre no seu dispositivo. Se você usar esta opção, você deve garantir que irá manter o seu dispositivo devidamente protegido." }, "noOrganizationsList": { - "message": "Você pertence a nenhuma organização. As organizações permitem que você compartilhe itens em segurança com outros usuários." + "message": "Você não pertence a nenhuma organização. As organizações permitem que você compartilhe itens em segurança com outros usuários." }, "noCollectionsInList": { - "message": "Não há coleções para listar." + "message": "Não há conjuntos para listar." }, "ownership": { "message": "Propriedade" }, "whoOwnsThisItem": { - "message": "Quem possui este item?" + "message": "Quem é o proprietário deste item?" }, "strong": { "message": "Forte", @@ -2205,29 +2282,29 @@ "description": "ex. A weak password. Scale: Weak -> Good -> Strong" }, "weakMasterPassword": { - "message": "Senha mestra fraca" + "message": "Senha principal fraca" }, "weakMasterPasswordDesc": { - "message": "A senha mestra que você selecionou está fraca. Você deve usar uma senha mestra forte (ou uma frase-passe) para proteger a sua conta Bitwarden adequadamente. Tem certeza que deseja usar esta senha mestra?" + "message": "A senha principal que você selecionou está fraca. Você deve usar uma senha principal forte (ou uma frase secreta) para proteger a sua conta Bitwarden adequadamente. Tem certeza que deseja usar esta senha principal?" }, "pin": { "message": "PIN", "description": "PIN code. Ex. The short code (often numeric) that you use to unlock a device." }, "unlockWithPin": { - "message": "Desbloquear com o PIN" + "message": "Desbloquear com PIN" }, "setYourPinTitle": { - "message": "Definir PIN" + "message": "Configurar PIN" }, "setYourPinButton": { - "message": "Definir PIN" + "message": "Configurar PIN" }, "setYourPinCode": { - "message": "Defina o seu código PIN para desbloquear o Bitwarden. Suas configurações de PIN serão redefinidas se alguma vez você encerrar completamente toda a sessão do aplicativo." + "message": "Configure o seu código PIN para desbloquear o Bitwarden. Suas configurações de PIN serão reconfiguradas se você desconectar a sua conta do aplicativo." }, "setPinCode": { - "message": "You can use this PIN to unlock Bitwarden. Your PIN will be reset if you ever fully log out of the application." + "message": "Você pode usar este PIN para desbloquear o Bitwarden. O seu PIN será reconfigurado se você desconectar a sua conta do aplicativo." }, "pinRequired": { "message": "O código PIN é necessário." @@ -2239,55 +2316,55 @@ "message": "Muitas tentativas de entrada de PIN inválidas. Desconectando." }, "unlockWithBiometrics": { - "message": "Desbloquear com a biometria" + "message": "Desbloquear com biometria" }, "unlockWithMasterPassword": { - "message": "Desbloquear com a senha mestra" + "message": "Desbloquear com senha principal" }, "awaitDesktop": { - "message": "Aguardando confirmação do desktop" + "message": "Aguardando confirmação do computador" }, "awaitDesktopDesc": { - "message": "Por favor, confirme o uso de dados biométricos no aplicativo Bitwarden Desktop para ativar a biometria para o navegador." + "message": "Confirme o uso de biometria no aplicativo do Bitwarden Desktop para ativar a biometria para o navegador." }, "lockWithMasterPassOnRestart": { - "message": "Bloquear com senha mestra ao reiniciar o navegador" + "message": "Bloquear com senha principal ao reiniciar o navegador" }, "lockWithMasterPassOnRestart1": { - "message": "Exigir senha mestra ao reiniciar o navegador" + "message": "Exigir senha principal ao reiniciar o navegador" }, "selectOneCollection": { - "message": "Você deve selecionar pelo menos uma coleção." + "message": "Você deve selecionar pelo menos um conjunto." }, "cloneItem": { - "message": "Clonar Item" + "message": "Clonar item" }, "clone": { "message": "Clonar" }, "passwordGenerator": { - "message": "Gerador de Senha" + "message": "Gerador de senhas" }, "usernameGenerator": { - "message": "Gerador de usuário" + "message": "Gerador de nome de usuário" }, "useThisEmail": { "message": "Usar este e-mail" }, "useThisPassword": { - "message": "Use esta senha" + "message": "Usar esta senha" }, "useThisPassphrase": { - "message": "Use this passphrase" + "message": "Usar esta frase secreta" }, "useThisUsername": { - "message": "Use este nome de usuário" + "message": "Usar este nome de usuário" }, "securePasswordGenerated": { - "message": "Senha segura gerada! Não se esqueça de atualizar também sua senha no site." + "message": "Senha segura gerada! Não se esqueça de atualizar sua senha no site também." }, "useGeneratorHelpTextPartOne": { - "message": "Usar o gerador", + "message": "Use o gerador", "description": "This will be used as part of a larger sentence, broken up to include the generator icon. The full sentence will read 'Use the generator [GENERATOR_ICON] to create a strong unique password'" }, "useGeneratorHelpTextPartTwo": { @@ -2298,10 +2375,10 @@ "message": "Personalização do cofre" }, "vaultTimeoutAction": { - "message": "Ação de Tempo Limite do Cofre" + "message": "Ação do tempo limite do cofre" }, "vaultTimeoutAction1": { - "message": "Ação do tempo" + "message": "Ação do tempo limite" }, "lock": { "message": "Bloquear", @@ -2312,31 +2389,31 @@ "description": "Noun: a special folder to hold deleted items" }, "searchTrash": { - "message": "Pesquisar na lixeira" + "message": "Buscar na lixeira" }, "permanentlyDeleteItem": { - "message": "Excluir o Item Permanentemente" + "message": "Apagar item para sempre" }, "permanentlyDeleteItemConfirmation": { - "message": "Você tem certeza que deseja excluir permanentemente esse item?" + "message": "Tem certeza que deseja apagar esse item para sempre?" }, "permanentlyDeletedItem": { - "message": "Item Permanentemente Excluído" + "message": "Item apagado para sempre" }, "restoreItem": { - "message": "Restaurar Item" + "message": "Restaurar item" }, "restoredItem": { - "message": "Item Restaurado" + "message": "Item restaurado" }, "alreadyHaveAccount": { "message": "Já tem uma conta?" }, "vaultTimeoutLogOutConfirmation": { - "message": "Sair irá remover todo o acesso ao seu cofre e requer autenticação online após o período de tempo limite. Tem certeza de que deseja usar esta configuração?" + "message": "Desconectar-se irá remover todo o acesso ao seu cofre e requirirá autenticação online após o período de tempo limite. Tem certeza de que deseja usar esta configuração?" }, "vaultTimeoutLogOutConfirmationTitle": { - "message": "Confirmação de Ação de Tempo Limite" + "message": "Confirmação da ação do tempo limite" }, "autoFillAndSave": { "message": "Preencher automaticamente e salvar" @@ -2351,16 +2428,16 @@ "message": "Item preenchido automaticamente " }, "insecurePageWarning": { - "message": "Aviso: Esta é uma página HTTP não segura, e qualquer informação que você enviar poderá ser interceptada e modificada por outras pessoas. Este login foi originalmente salvo em uma página segura (HTTPS)." + "message": "Aviso: Esta é uma página HTTP sem segurança, e qualquer informação que você enviar poderá ser interceptada e modificada por outras pessoas. Esta credencial foi originalmente salvo em uma página segura (HTTPS)." }, "insecurePageWarningFillPrompt": { - "message": "Você ainda deseja preencher esse login?" + "message": "Você ainda deseja preencher esta credencial?" }, "autofillIframeWarning": { - "message": "O formulário está hospedado em um domínio diferente do URI do seu login salvo. Escolha OK para preencher automaticamente mesmo assim ou Cancelar para parar." + "message": "O formulário está hospedado em um domínio diferente do URI da sua credencial salva. Escolha OK para preencher automaticamente mesmo assim, ou Cancelar para parar." }, "autofillIframeWarningTip": { - "message": "Para evitar este aviso no futuro, salve este URI, $HOSTNAME$, no seu item de login no Bitwarden para este site.", + "message": "Para evitar este aviso no futuro, salve este URI, $HOSTNAME$, no seu item de credencial no Bitwarden para este site.", "placeholders": { "hostname": { "content": "$1", @@ -2368,20 +2445,23 @@ } } }, + "topLayerHijackWarning": { + "message": "Esta página está interferindo com a experiência do Bitwarden. O menu inline do Bitwarden foi temporariamente desativado como uma medida de segurança." + }, "setMasterPassword": { - "message": "Definir senha mestra" + "message": "Configurar senha principal" }, "currentMasterPass": { - "message": "Senha mestra atual" + "message": "Senha principal atual" }, "newMasterPass": { - "message": "Nova senha mestra" + "message": "Nova senha principal" }, "confirmNewMasterPass": { - "message": "Confirme a nova senha mestre" + "message": "Confirmar nova senha principal" }, "masterPasswordPolicyInEffect": { - "message": "Uma ou mais políticas da organização exigem que a sua senha mestra cumpra aos seguintes requisitos:" + "message": "Uma ou mais políticas da organização exigem que a sua senha principal cumpra aos seguintes requisitos:" }, "policyInEffectMinComplexity": { "message": "Pontuação mínima de complexidade de $SCORE$", @@ -2420,13 +2500,13 @@ } }, "masterPasswordPolicyRequirementsNotMet": { - "message": "A sua nova senha mestra não cumpre aos requisitos da política." + "message": "A sua nova senha principal não cumpre aos requisitos da política." }, "receiveMarketingEmailsV2": { - "message": "Obtenha dicas, novidades e oportunidades de pesquisa do Bitwarden em sua caixa de entrada." + "message": "Receba conselhos, novidades, e oportunidades de pesquisa do Bitwarden em sua caixa de entrada." }, "unsubscribe": { - "message": "Cancelar subscrição" + "message": "Desinscreva-se" }, "atAnyTime": { "message": "a qualquer momento." @@ -2450,16 +2530,16 @@ "message": "Política de Privacidade" }, "yourNewPasswordCannotBeTheSameAsYourCurrentPassword": { - "message": "Sua nova senha não pode ser a mesma que a sua atual." + "message": "Sua senha nova não pode ser a mesma que a sua atual." }, "hintEqualsPassword": { - "message": "Sua dica de senha não pode ser o mesmo que sua senha." + "message": "A dica da sua senha não pode ser a mesma coisa que sua senha." }, "ok": { "message": "Ok" }, "errorRefreshingAccessToken": { - "message": "Erro ao Atualizar Token" + "message": "Erro ao acessar token de recarregamento" }, "errorRefreshingAccessTokenDesc": { "message": "Nenhum token de atualização ou chave de API foi encontrado. Tente sair e entrar novamente." @@ -2468,49 +2548,49 @@ "message": "Verificação de sincronização do Desktop" }, "desktopIntegrationVerificationText": { - "message": "Por favor, verifique se o aplicativo desktop mostra esta impressão digital: " + "message": "Verifique se o aplicativo de computador mostra esta frase biométrica: " }, "desktopIntegrationDisabledTitle": { - "message": "A integração com o navegador não está habilitada" + "message": "A integração com o navegador não foi configurada" }, "desktopIntegrationDisabledDesc": { - "message": "A integração com o navegador não está habilitada no aplicativo Bitwarden Desktop. Por favor, habilite-a nas configurações do aplicativo desktop." + "message": "A integração com o navegador não foi configurada no aplicativo do Bitwarden Desktop. Configure ela nas configurações do aplicativo de computador." }, "startDesktopTitle": { - "message": "Iniciar o aplicativo Bitwarden Desktop" + "message": "Abrir o aplicativo Bitwarden Desktop" }, "startDesktopDesc": { - "message": "O aplicativo Bitwarden Desktop precisa ser iniciado antes que esta função possa ser usada." + "message": "O aplicativo Bitwarden para computador precisa ser aberto antes que o desbloqueio com biometria possa ser usado." }, "errorEnableBiometricTitle": { - "message": "Não foi possível ativar a biometria" + "message": "Não é possível ativar a biometria" }, "errorEnableBiometricDesc": { - "message": "A ação foi cancelada pelo aplicativo desktop" + "message": "A ação foi cancelada pelo aplicativo de computador" }, "nativeMessagingInvalidEncryptionDesc": { - "message": "O aplicativo desktop invalidou o canal de comunicação seguro. Por favor, tente esta operação novamente" + "message": "O aplicativo de computador invalidou o canal de comunicação seguro. Tente esta operação novamente" }, "nativeMessagingInvalidEncryptionTitle": { - "message": "Comunicação com o desktop interrompida" + "message": "Comunicação com o computador interrompida" }, "nativeMessagingWrongUserDesc": { - "message": "O aplicativo desktop está conectado em uma conta diferente. Por favor, certifique-se de que ambos os aplicativos estejam conectados na mesma conta." + "message": "O aplicativo de computador está conectado em uma conta diferente. Certifique-se de que ambos os aplicativos estejam conectados na mesma conta." }, "nativeMessagingWrongUserTitle": { - "message": "A conta não confere" + "message": "Não correspondência da conta" }, "nativeMessagingWrongUserKeyTitle": { - "message": "Falta de chave biométrica" + "message": "Não correspondência da chave biométrica" }, "nativeMessagingWrongUserKeyDesc": { - "message": "O desbloqueio biométrico falhou. A chave secreta biométrica não conseguiu desbloquear o cofre. Tente configurar os dados biométricos novamente." + "message": "O desbloqueio biométrico falhou. A chave secreta da biometria falhou ao desbloquear o cofre. Tente configurar a biometria novamente." }, "biometricsNotEnabledTitle": { - "message": "Biometria não ativada" + "message": "Biometria não configurada" }, "biometricsNotEnabledDesc": { - "message": "A biometria com o navegador requer que a biometria de desktop seja habilitada nas configurações primeiro." + "message": "A biometria com o navegador requer que a biometria do desktop seja configurada nas configurações primeiro." }, "biometricsNotSupportedTitle": { "message": "Biometria não suportada" @@ -2522,7 +2602,7 @@ "message": "Usuário bloqueado ou desconectado" }, "biometricsNotUnlockedDesc": { - "message": "Por favor, desbloqueie esse usuário no aplicativo da área de trabalho e tente novamente." + "message": "Desbloqueie esse usuário no aplicativo de computador e tente novamente." }, "biometricsNotAvailableTitle": { "message": "Desbloqueio biométrico indisponível" @@ -2534,34 +2614,34 @@ "message": "Biometria falhou" }, "biometricsFailedDesc": { - "message": "A biometria não pode ser concluída, considere usar uma senha mestra ou desconectar. Se isso persistir, entre em contato com o suporte do Bitwarden." + "message": "A biometria não pode ser concluída, considere usar uma senha principal ou desconectar. Se isso persistir, entre em contato com o suporte do Bitwarden." }, "nativeMessaginPermissionErrorTitle": { "message": "Permissão não fornecida" }, "nativeMessaginPermissionErrorDesc": { - "message": "Sem a permissão para se comunicar com o Aplicativo Bitwarden Desktop, não podemos fornecer dados biométricos na extensão do navegador. Por favor, tente novamente." + "message": "Sem a permissão para se comunicar com o aplicativo do Bitwarden Desktop, não podemos fornecer a biometria na extensão do navegador. Tente novamente." }, "nativeMessaginPermissionSidebarTitle": { "message": "Erro ao solicitar permissão" }, "nativeMessaginPermissionSidebarDesc": { - "message": "Esta ação não pode ser feita na barra lateral. Por favor, tente novamente no pop-up ou popout." + "message": "Esta ação não pode ser feita na barra lateral. Tente novamente no pop-up." }, "personalOwnershipSubmitError": { - "message": "Devido a uma Política Empresarial, você está restrito de salvar itens para seu cofre pessoal. Altere a opção de Propriedade para uma organização e escolha entre as Coleções disponíveis." + "message": "Devido a uma política empresarial, você não pode salvar itens no seu cofre pessoal. Altere a opção de propriedade para uma organização e escolha entre os conjuntos disponíveis." }, "personalOwnershipPolicyInEffect": { "message": "Uma política de organização está afetando suas opções de propriedade." }, "personalOwnershipPolicyInEffectImports": { - "message": "A política da organização bloqueou a importação de itens para o seu cofre." + "message": "Uma política da organização bloqueou a importação de itens em seu cofre pessoal." }, "restrictCardTypeImport": { - "message": "Cannot import card item types" + "message": "Não é possível importar itens do tipo de cartão" }, "restrictCardTypeImportDesc": { - "message": "A policy set by 1 or more organizations prevents you from importing cards to your vaults." + "message": "Uma política definida por 1 ou mais organizações impedem que você importe cartões em seus cofres." }, "domainsTitle": { "message": "Domínios", @@ -2574,28 +2654,28 @@ "message": "Saiba mais sobre domínios bloqueados" }, "excludedDomains": { - "message": "Domínios Excluídos" + "message": "Domínios excluídos" }, "excludedDomainsDesc": { "message": "O Bitwarden não irá pedir para salvar os detalhes de credencial para estes domínios. Você deve atualizar a página para que as alterações entrem em vigor." }, "excludedDomainsDescAlt": { - "message": "O Bitwarden não irá pedir para salvar os detalhes de credencial para estes domínios. Você deve atualizar a página para que as alterações entrem em vigor." + "message": "O Bitwarden não irá pedir para salvar os detalhes de credencial para estes domínios, em todas as contas. Você deve recarregar a página para que as alterações entrem em vigor." }, "blockedDomainsDesc": { - "message": "O preenchimento automático e outros recursos podem não estar disponíveis para estes sites. Atualize a página para que as mudanças surtam efeito." + "message": "O preenchimento automático e outros recursos relacionados não serão oferecidos para estes sites. Recarregue a página para que as mudanças surtam efeito." }, "autofillBlockedNoticeV2": { "message": "O preenchimento automático está bloqueado para este site." }, "autofillBlockedNoticeGuidance": { - "message": "Altere isso em configurações" + "message": "Altere isso nas configurações" }, "change": { "message": "Alterar" }, "changePassword": { - "message": "Change password", + "message": "Alterar senha", "description": "Change password button for browser at risk notification on login." }, "changeButtonTitle": { @@ -2608,13 +2688,13 @@ } }, "atRiskPassword": { - "message": "At-risk password" + "message": "Senha em risco" }, "atRiskPasswords": { "message": "Senhas em risco" }, "atRiskPasswordDescSingleOrg": { - "message": "$ORGANIZATION$ solicita que altere uma senha, pois ela está vulnerável.", + "message": "$ORGANIZATION$ está solicitando que altere uma senha, pois ela está em risco.", "placeholders": { "organization": { "content": "$1", @@ -2623,7 +2703,7 @@ } }, "atRiskPasswordsDescSingleOrgPlural": { - "message": "$ORGANIZATION$ solicita que altere $COUNT$ senhas, pois elas estão vulneráveis.", + "message": "$ORGANIZATION$ está solicitando que altere $COUNT$ senhas, pois elas estão em risco.", "placeholders": { "organization": { "content": "$1", @@ -2636,7 +2716,7 @@ } }, "atRiskPasswordsDescMultiOrgPlural": { - "message": "Suas organizações estão solicitando que altere $COUNT$ senhas porque elas estão vulneráveis.", + "message": "Suas organizações estão solicitando que altere $COUNT$ senhas porque elas estão em risco.", "placeholders": { "count": { "content": "$1", @@ -2645,7 +2725,7 @@ } }, "atRiskChangePrompt": { - "message": "Your password for this site is at-risk. $ORGANIZATION$ has requested that you change it.", + "message": "Sua senha para este site está em risco. $ORGANIZATION$ solicitou que você altere ela.", "placeholders": { "organization": { "content": "$1", @@ -2655,7 +2735,7 @@ "description": "Notification body when a login triggers an at-risk password change request and the change password domain is known." }, "atRiskNavigatePrompt": { - "message": "$ORGANIZATION$ wants you to change this password because it is at-risk. Navigate to your account settings to change the password.", + "message": "$ORGANIZATION$ quer que você altere esta senha porque está em risco. Navegue até as configurações da sua conta para alterar a senha.", "placeholders": { "organization": { "content": "$1", @@ -2665,10 +2745,10 @@ "description": "Notification body when a login triggers an at-risk password change request and no change password domain is provided." }, "reviewAndChangeAtRiskPassword": { - "message": "Revisar e alterar uma senha vulnerável" + "message": "Revisar e alterar uma senha em risco" }, "reviewAndChangeAtRiskPasswordsPlural": { - "message": "Revisar e alterar $COUNT$ senhas vulneráveis", + "message": "Revisar e alterar $COUNT$ senhas em risco", "placeholders": { "count": { "content": "$1", @@ -2677,30 +2757,30 @@ } }, "changeAtRiskPasswordsFaster": { - "message": "Mude senhas vulneráveis rapidamente" + "message": "Altere senhas em risco mais rápido" }, "changeAtRiskPasswordsFasterDesc": { - "message": "Atualize suas configurações para poder autopreencher ou gerar novas senhas" + "message": "Atualize suas configurações para poder preencher senhas automaticamente ou gerá-las automaticamente" }, "reviewAtRiskLogins": { - "message": "Revisar logins em risco" + "message": "Revisar credenciais em risco" }, "reviewAtRiskPasswords": { - "message": "Revisar senhas vulneráveis" + "message": "Revisar senhas em risco" }, "reviewAtRiskLoginsSlideDesc": { - "message": "As senhas da sua organização estão vulneráveis, pois são fracas, reutilizadas e/ou comprometidas.", + "message": "As senhas da sua organização estão em risco, pois são fracas, foram reutilizadas e/ou comprometidas.", "description": "Description of the review at-risk login slide on the at-risk password page carousel" }, "reviewAtRiskLoginSlideImgAltPeriod": { - "message": "Ilustração de uma lista de logins em risco." + "message": "Ilustração de uma lista de credenciais em risco." }, "generatePasswordSlideDesc": { - "message": "Gere rapidamente uma senha forte e única com a opção de autopreenchimento no site vulnerável.", + "message": "Gere uma senha forte e única com rapidez com o menu de preenchimento automático no site em risco.", "description": "Description of the generate password slide on the at-risk password page carousel" }, "generatePasswordSlideImgAltPeriod": { - "message": "Ilustração do menu de autopreenchimento do Bitwarden exibindo uma senha gerada." + "message": "Ilustração do menu de preenchimento automático do Bitwarden exibindo uma senha gerada." }, "updateInBitwarden": { "message": "Atualizar no Bitwarden" @@ -2710,16 +2790,16 @@ "description": "Description of the update in Bitwarden slide on the at-risk password page carousel" }, "updateInBitwardenSlideImgAltPeriod": { - "message": "Ilustração de umja notificação do Bitwarden solicitando que o usuário atualize o login." + "message": "Ilustração de uma notificação do Bitwarden solicitando que o usuário atualize a credencial." }, "turnOnAutofill": { "message": "Ativar preenchimento automático" }, "turnedOnAutofill": { - "message": "Desativar preenchimento automático" + "message": "Preenchimento automático ativado" }, "dismiss": { - "message": "Dispensar" + "message": "Descartar" }, "websiteItemLabel": { "message": "Site $number$ (URI)", @@ -2743,17 +2823,17 @@ "message": "Alterações de domínios bloqueados salvas" }, "excludedDomainsSavedSuccess": { - "message": "Mudanças de domínios excluídos salvas" + "message": "Alterações de domínios excluídos salvas" }, "limitSendViews": { - "message": "Limitar visualização" + "message": "Limitar visualizações" }, "limitSendViewsHint": { - "message": "Ninguém pode visualizar este envio depois que o limite foi atingido.", + "message": "Ninguém poderá visualizar este Send depois que o limite foi atingido.", "description": "Displayed under the limit views field on Send" }, "limitSendViewsCount": { - "message": "$ACCESSCOUNT$ Visualizações restantes", + "message": "$ACCESSCOUNT$ visualizações restantes", "description": "Displayed under the limit views field on Send", "placeholders": { "accessCount": { @@ -2767,7 +2847,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendDetails": { - "message": "Enviar detalhes", + "message": "Detalhes do Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTypeText": { @@ -2784,14 +2864,14 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "maxAccessCountReached": { - "message": "Max access count reached", + "message": "Número máximo de acessos atingido", "description": "This text will be displayed after a Send has been accessed the maximum amount of times." }, "hideTextByDefault": { "message": "Ocultar texto por padrão" }, "expired": { - "message": "Expirado" + "message": "Vencido" }, "passwordProtected": { "message": "Protegido por senha" @@ -2804,16 +2884,16 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "removePassword": { - "message": "Remover Senha" + "message": "Remover senha" }, "delete": { - "message": "Excluir" + "message": "Apagar" }, "removedPassword": { - "message": "Senha Removida" + "message": "Senha removida" }, "deletedSend": { - "message": "Send Excluído", + "message": "Send apagado", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendLink": { @@ -2827,15 +2907,15 @@ "message": "Você tem certeza que deseja remover a senha?" }, "deleteSend": { - "message": "Excluir Send", + "message": "Apagar Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "deleteSendConfirmation": { - "message": "Você tem certeza que deseja excluir este Send?", + "message": "Você tem certeza que deseja apagar este Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "deleteSendPermanentConfirmation": { - "message": "Tem certeza que deseja excluir este campo permanentemente?", + "message": "Tem certeza que deseja apagar este Send para sempre?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editSend": { @@ -2843,14 +2923,14 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "deletionDate": { - "message": "Data de Exclusão" + "message": "Data de apagamento" }, "deletionDateDescV2": { - "message": "O envio será eliminado permanentemente na data e hora especificadas.", + "message": "O Send será apagado para sempre nesta data.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "expirationDate": { - "message": "Data de Validade" + "message": "Data de validade" }, "oneDay": { "message": "1 dia" @@ -2868,38 +2948,38 @@ "message": "Personalizado" }, "sendPasswordDescV3": { - "message": "Adicione uma senha opcional para os destinatários para acessar este Envio.", + "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": "Criar Novo Send", + "message": "Novo Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "newPassword": { - "message": "Nova Senha" + "message": "Senha nova" }, "sendDisabled": { - "message": "Send Desativado", + "message": "Send removido", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendDisabledWarning": { - "message": "Devido a uma política corporativa, você só pode excluir um Send existente.", + "message": "Devido a uma política corporativa, você só pode apagar um Send existente.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "createdSend": { - "message": "Send Criado", + "message": "Send criado", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "createdSendSuccessfully": { - "message": "Envio criado com sucesso!", + "message": "Send criado com sucesso!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendExpiresInHoursSingle": { - "message": "O envio estará disponível para qualquer pessoa com o link para a próxima 1 hora.", + "message": "O Send estará disponível para qualquer pessoa com o link pela próxima 1 hora.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendExpiresInHours": { - "message": "O envio estará disponível para qualquer pessoa com o link para as próximas $HOURS$ horas.", + "message": "O Send estará disponível para qualquer pessoa com o link pelas próximas $HOURS$ horas.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "hours": { @@ -2909,11 +2989,11 @@ } }, "sendExpiresInDaysSingle": { - "message": "O envio estará disponível para qualquer pessoa com o link para o próximo 1 dia.", + "message": "O Send estará disponível para qualquer pessoa com o link pelo próximo 1 dia.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendExpiresInDays": { - "message": "O envio estará disponível para qualquer pessoa com o link para os próximos $DAYS$ dias.", + "message": "O Send estará disponível para qualquer pessoa com o link pelos próximos $DAYS$ dias.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "days": { @@ -2923,19 +3003,19 @@ } }, "sendLinkCopied": { - "message": "Enviar link copiado", + "message": "Link do Send copiado", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editedSend": { - "message": "Send Editado", + "message": "Send salvo", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendFilePopoutDialogText": { - "message": "Mostrar extensão?", + "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 arquivo enviado, você precisa colocar a extensão em uma nova janela.", + "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": { @@ -2948,7 +3028,7 @@ "message": "Para escolher um arquivo usando o Safari, abra uma nova janela clicando neste banner." }, "popOut": { - "message": "Separar da janela" + "message": "Mover para janela" }, "sendFileCalloutHeader": { "message": "Antes de começar" @@ -2957,31 +3037,31 @@ "message": "A data de validade fornecida não é válida." }, "deletionDateIsInvalid": { - "message": "A data de exclusão fornecida não é válida." + "message": "A data de apagamento fornecida não é válida." }, "expirationDateAndTimeRequired": { - "message": "Uma data e hora de expiração são obrigatórias." + "message": "Uma data e hora de validade são obrigatórias." }, "deletionDateAndTimeRequired": { - "message": "Uma data e hora de exclusão são obrigatórias." + "message": "Uma data e hora de apagamento são obrigatórias." }, "dateParsingError": { - "message": "Ocorreu um erro ao salvar as suas datas de exclusão e validade." + "message": "Ocorreu um erro ao salvar as suas datas de apagamento e validade." }, "hideYourEmail": { - "message": "Ocultar meu endereço de correio eletrônico dos destinatários." + "message": "Oculte seu endereço de e-mail dos visualizadores." }, "passwordPrompt": { - "message": "Solicitação nova de senha mestra" + "message": "Resolicitar senha principal" }, "passwordConfirmation": { - "message": "Confirmação de senha mestra" + "message": "Confirmação de senha principal" }, "passwordConfirmationDesc": { - "message": "Esta ação está protegida. Para continuar, por favor, reinsira a sua senha mestra para verificar sua identidade." + "message": "Esta ação está protegida. Para continuar, digite a sua senha principal novamente para verificar sua identidade." }, "emailVerificationRequired": { - "message": "Verificação de E-mail Necessária" + "message": "Verificação de e-mail necessária" }, "emailVerifiedV2": { "message": "E-mail verificado" @@ -2990,28 +3070,28 @@ "message": "Você precisa verificar o seu e-mail para usar este recurso. Você pode verificar seu e-mail no cofre web." }, "masterPasswordSuccessfullySet": { - "message": "Master password successfully set" + "message": "Senha principal configurada com sucesso" }, "updatedMasterPassword": { - "message": "Senha mestra atualizada" + "message": "Senha principal atualizada" }, "updateMasterPassword": { - "message": "Atualizar senha mestra" + "message": "Atualizar senha principal" }, "updateMasterPasswordWarning": { - "message": "Sua senha mestra foi alterada recentemente por um administrador de sua organização. Para acessar o cofre, você precisa atualizá-la agora. O processo desconectará você da sessão atual, exigindo que você inicie a sessão novamente. Sessões ativas em outros dispositivos podem continuar ativas por até uma hora." + "message": "Sua senha principal foi alterada recentemente por um administrador de sua organização. Para acessar o cofre, você precisa atualizá-la agora. O processo desconectará você da sessão atual, exigindo que você se conecte novamente. Sessões ativas em outros dispositivos podem continuar ativas por até uma hora." }, "updateWeakMasterPasswordWarning": { - "message": "A sua senha mestra não atende a uma ou mais das políticas da sua organização. Para acessar o cofre, você deve atualizar a sua senha mestra agora. O processo desconectará você da sessão atual, exigindo que você inicie a sessão novamente. Sessões ativas em outros dispositivos podem continuar ativas por até uma hora." + "message": "A sua senha principal não atende a uma ou mais das políticas da sua organização. Para acessar o cofre, você deve atualizar a sua senha principal agora. O processo desconectará você da sessão atual, exigindo que você se conecte novamente. Sessões ativas em outros dispositivos podem continuar ativas por até uma hora." }, "tdeDisabledMasterPasswordRequired": { - "message": "Sua organização desativou a criptografia confiável do dispositivo. Por favor, defina uma senha mestra para acessar o seu cofre." + "message": "Sua organização desativou a criptografia de dispositivo confiado. Configure uma senha principal para acessar o seu cofre." }, "resetPasswordPolicyAutoEnroll": { - "message": "Inscrição Automática" + "message": "Inscrição automática" }, "resetPasswordAutoEnrollInviteWarning": { - "message": "Esta organização possui uma política empresarial que irá inscrevê-lo automaticamente na redefinição de senha. A inscrição permitirá que os administradores da organização alterem sua senha mestra." + "message": "Esta organização possui uma política empresarial que irá inscrevê-lo automaticamente na reconfiguração de senha. A inscrição permitirá que os administradores da organização alterem sua senha principal." }, "selectFolder": { "message": "Selecionar pasta..." @@ -3021,15 +3101,15 @@ "description": "Used as a message within the notification bar when no folders are found" }, "orgPermissionsUpdatedMustSetPassword": { - "message": "As permissões da sua organização foram atualizadas, exigindo que você defina uma senha mestra.", + "message": "As permissões da sua organização foram atualizadas, exigindo que você configure uma senha principal.", "description": "Used as a card title description on the set password page to explain why the user is there" }, "orgRequiresYouToSetPassword": { - "message": "Sua organização requer que você defina uma senha mestra.", + "message": "Sua organização requer que você configure uma senha principal.", "description": "Used as a card title description on the set password page to explain why the user is there" }, "cardMetrics": { - "message": "fora do $TOTAL$", + "message": "dos $TOTAL$", "placeholders": { "total": { "content": "$1", @@ -3048,10 +3128,10 @@ "message": "Minutos" }, "vaultTimeoutPolicyAffectingOptions": { - "message": "Os requisitos de política empresarial foram aplicados nesta configuração" + "message": "Os requisitos de política empresarial foram aplicados às suas opções de tempo limite" }, "vaultTimeoutPolicyInEffect": { - "message": "As políticas da sua organização estão afetando o tempo limite do seu cofre. O Tempo Limite Máximo permitido do Cofre é $HOURS$ hora(s) e $MINUTES$ minuto(s)", + "message": "As políticas da sua organização configuraram o seu máximo permitido do tempo limite do cofre para $HOURS$ hora(s) e $MINUTES$ minuto(s).", "placeholders": { "hours": { "content": "$1", @@ -3090,7 +3170,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "As políticas da sua organização estão afetando seu cofre tempo limite. Tempo limite máximo permitido para cofre é $HOURS$ hora(s) e $MINUTES$ minuto(s). A ação de tempo limite do seu cofre é definida como $ACTION$.", + "message": "As políticas da sua organização estão afetando o tempo limite do seu cofre. \nO tempo limite máximo permitido para o cofre é $HOURS$ hora(s) e $MINUTES$ minuto(s). A ação de tempo limite do seu cofre está configurada como $ACTION$.", "placeholders": { "hours": { "content": "$1", @@ -3107,7 +3187,7 @@ } }, "vaultTimeoutActionPolicyInEffect": { - "message": "As políticas da sua organização definiram a ação tempo limite do seu cofre para $ACTION$.", + "message": "As políticas da sua organização configuraram a ação do tempo limite do seu cofre para $ACTION$.", "placeholders": { "action": { "content": "$1", @@ -3116,37 +3196,37 @@ } }, "vaultTimeoutTooLarge": { - "message": "Seu tempo de espera no cofre excede as restrições estabelecidas por sua organização." + "message": "O tempo limite do seu cofre excede as restrições estabelecidas pela sua organização." }, "vaultExportDisabled": { - "message": "Exportação de Cofre Desativada" + "message": "Exportação de cofre indisponível" }, "personalVaultExportPolicyInEffect": { - "message": "Uma ou mais políticas da organização impedem que você exporte seu cofre pessoal." + "message": "Uma ou mais políticas da organização impedem que você exporte seu cofre individual." }, "copyCustomFieldNameInvalidElement": { - "message": "Não foi possível identificar um elemento de formulário válido. Em vez disso, tente inspecionar o HTML." + "message": "Não é possível identificar um elemento de formulário válido. Em vez disso, tente inspecionar o HTML." }, "copyCustomFieldNameNotUnique": { - "message": "Nenhum identificador exclusivo encontrado." + "message": "Nenhum identificador único encontrado." }, "removeMasterPasswordForOrganizationUserKeyConnector": { - "message": "A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator." + "message": "Uma senha principal não é mais necessária para os membros da seguinte organização. Confirme o domínio abaixo com o administrador da sua organização." }, "organizationName": { - "message": "Organization name" + "message": "Nome da organização" }, "keyConnectorDomain": { - "message": "Key Connector domain" + "message": "Domínio do Key Connector" }, "leaveOrganization": { - "message": "Sair da Organização" + "message": "Sair da organização" }, "removeMasterPassword": { - "message": "Remover senha mestra" + "message": "Remover senha principal" }, "removedMasterPassword": { - "message": "Senha mestra removida." + "message": "Senha principal removida" }, "leaveOrganizationConfirmation": { "message": "Você tem certeza que deseja sair desta organização?" @@ -3155,16 +3235,16 @@ "message": "Você saiu da organização." }, "toggleCharacterCount": { - "message": "Alternar contagem de caracteres" + "message": "Habilitar contagem de caracteres" }, "sessionTimeout": { - "message": "Sua sessão expirou. Por favor, volte e tente iniciar a sessão novamente." + "message": "Sua sessão expirou. Volte e tente se conectar novamente." }, "exportingPersonalVaultTitle": { - "message": "Exportando o Cofre Pessoal" + "message": "Exportando cofre individual" }, "exportingIndividualVaultDescription": { - "message": "Apenas os itens individuais do cofre associados a $EMAIL$ serão exportados. Os itens do cofre da organização não serão incluídos. Apenas as informações de item do cofre serão exportadas e não incluirão anexos associados.", + "message": "Apenas os itens do cofre individual associados com $EMAIL$ serão exportados. Os itens do cofre da organização não serão incluídos. Apenas as informações dos itens do cofre serão exportadas e não incluirão anexos associados.", "placeholders": { "email": { "content": "$1", @@ -3173,7 +3253,7 @@ } }, "exportingIndividualVaultWithAttachmentsDescription": { - "message": "Apenas os itens individuais do cofre, incluindo anexos associados ao $EMAIL$ serão exportados. Os itens do cofre da organização não serão incluídos", + "message": "Apenas os itens do cofre individual, incluindo anexos associados com $EMAIL$ serão exportados. Os itens do cofre da organização não serão incluídos", "placeholders": { "email": { "content": "$1", @@ -3185,7 +3265,7 @@ "message": "Exportando cofre da organização" }, "exportingOrganizationVaultDesc": { - "message": "Apenas o cofre da organização associado com $ORGANIZATION$ será exportado. Itens do cofre pessoal e itens de outras organizações não serão incluídos.", + "message": "Apenas o cofre da organização associado com $ORGANIZATION$ será exportado. Itens do cofre individual e itens de outras organizações não serão incluídos.", "placeholders": { "organization": { "content": "$1", @@ -3193,17 +3273,38 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Somente o cofre da organização associada com $ORGANIZATION$ será exportado.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Apenas o cofre da organização associado com $ORGANIZATION$ será exportado. Os itens dos meus conjuntos não serão incluídos.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Erro" }, "decryptionError": { - "message": "Erro ao descriptografar" + "message": "Erro de descriptografia" + }, + "errorGettingAutoFillData": { + "message": "Erro ao obter dados de preenchimento automático" }, "couldNotDecryptVaultItemsBelow": { - "message": "O Bitwarden não pode descriptografar o(s) item(ns) do cofre listado abaixo." + "message": "O Bitwarden não conseguiu descriptografar o(s) item(ns) do cofre listado abaixo." }, "contactCSToAvoidDataLossPart1": { - "message": "Contato com o cliente feito com sucesso", + "message": "Contate o costumer success", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "contactCSToAvoidDataLossPart2": { @@ -3211,7 +3312,7 @@ "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "generateUsername": { - "message": "Gerar Usuário" + "message": "Gerar nome de usuário" }, "generateEmail": { "message": "Gerar e-mail" @@ -3231,7 +3332,7 @@ } }, "passwordLengthRecommendationHint": { - "message": " Use $RECOMMENDED$ caracteres ou mais para gerar uma senha forte.", + "message": " Utilize $RECOMMENDED$ ou mais caracteres para gerar um senha forte.", "description": "Appended to `spinboxBoundariesHint` to recommend a length to the user. This must include any language-specific 'sentence' separator characters (e.g. a space in english).", "placeholders": { "recommended": { @@ -3241,7 +3342,7 @@ } }, "passphraseNumWordsRecommendationHint": { - "message": " Use palavras $RECOMMENDED$ ou mais para gerar uma frase secreta forte.", + "message": " Utilize $RECOMMENDED$ ou mais palavras para gerar uma frase secreta forte.", "description": "Appended to `spinboxBoundariesHint` to recommend a number of words to the user. This must include any language-specific 'sentence' separator characters (e.g. a space in english).", "placeholders": { "recommended": { @@ -3251,17 +3352,17 @@ } }, "plusAddressedEmail": { - "message": "E-mail alternativo (com um +)", + "message": "E-mail endereçado com +", "description": "Username generator option that appends a random sub-address to the username. For example: address+subaddress@email.com" }, "plusAddressedEmailDesc": { "message": "Use as capacidades de sub-endereçamento do seu provedor de e-mail." }, "catchallEmail": { - "message": "E-mail catch-all" + "message": "E-mail pega-tudo" }, "catchallEmailDesc": { - "message": "Use o catch-all configurado no seu domínio." + "message": "Use a caixa de entrada pega-tudo configurada no seu domínio." }, "random": { "message": "Aleatório" @@ -3270,27 +3371,27 @@ "message": "Palavra aleatória" }, "websiteName": { - "message": "Nome do Site" + "message": "Nome do site" }, "service": { "message": "Serviço" }, "forwardedEmail": { - "message": "Apelido (alias) de E-mail Encaminhado" + "message": "Alias de encaminhamento de e-mail" }, "forwardedEmailDesc": { - "message": "Gere um apelido de e-mail com um serviço de encaminhamento externo." + "message": "Gere um alias de e-mail com um serviço externo de encaminhamento." }, "forwarderDomainName": { "message": "Domínio de e-mail", "description": "Labels the domain name email forwarder service option" }, "forwarderDomainNameHint": { - "message": "Escolha um domínio que seja suportado pelo serviço selecionado", + "message": "Escolha um domínio suportado pelo serviço selecionado", "description": "Guidance provided for email forwarding services that support multiple email domains." }, "forwarderError": { - "message": "Erro $SERVICENAME$: $ERRORMESSAGE$", + "message": "Erro do $SERVICENAME$: $ERRORMESSAGE$", "description": "Reports an error returned by a forwarding service to the user.", "placeholders": { "servicename": { @@ -3318,7 +3419,7 @@ } }, "forwaderInvalidToken": { - "message": "Token de API $SERVICENAME$ inválido", + "message": "Token de API do $SERVICENAME$ inválido", "description": "Displayed when the user's API token is empty or rejected by the forwarding service.", "placeholders": { "servicename": { @@ -3328,7 +3429,7 @@ } }, "forwaderInvalidTokenWithMessage": { - "message": "Token de API $SERVICENAME$ inválido: $ERRORMESSAGE$", + "message": "Token de API da $SERVICENAME$ inválido: $ERRORMESSAGE$", "description": "Displayed when the user's API token is rejected by the forwarding service with an error message.", "placeholders": { "servicename": { @@ -3366,7 +3467,7 @@ } }, "forwarderNoAccountId": { - "message": "Não foi possível obter a máscara do ID da conta de email $SERVICENAME$.", + "message": "Não foi possível obter o ID da conta de e-mail mascarado $SERVICENAME$.", "description": "Displayed when the forwarding service fails to return an account ID.", "placeholders": { "servicename": { @@ -3376,7 +3477,7 @@ } }, "forwarderNoDomain": { - "message": "Domínio $SERVICENAME$ inválido.", + "message": "Domínio inválido do $SERVICENAME$.", "description": "Displayed when the domain is empty or domain authorization failed at the forwarding service.", "placeholders": { "servicename": { @@ -3386,7 +3487,7 @@ } }, "forwarderNoUrl": { - "message": "URL $SERVICENAME$ inválida.", + "message": "URL inválido do $SERVICENAME$.", "description": "Displayed when the url of the forwarding service wasn't supplied.", "placeholders": { "servicename": { @@ -3396,7 +3497,7 @@ } }, "forwarderUnknownError": { - "message": "Ocorreu um erro $SERVICENAME$ desconhecido.", + "message": "Ocorreu um erro desconhecido do $SERVICENAME$.", "description": "Displayed when the forwarding service failed due to an unknown error.", "placeholders": { "servicename": { @@ -3416,11 +3517,11 @@ } }, "hostname": { - "message": "Nome do host", + "message": "Nome do servidor", "description": "Part of a URL." }, "apiAccessToken": { - "message": "Token de Acesso à API" + "message": "Token de acesso à API" }, "apiKey": { "message": "Chave da API" @@ -3429,16 +3530,16 @@ "message": "Erro de Key Connector: certifique-se de que a Key Connector está disponível e funcionando corretamente." }, "premiumSubcriptionRequired": { - "message": "Assinatura Premium necessária" + "message": "Plano Premium necessário" }, "organizationIsDisabled": { - "message": "Organização está desabilitada." + "message": "Organização suspensa." }, "disabledOrganizationFilterError": { - "message": "Itens em Organizações Desativadas não podem ser acessados. Entre em contato com o proprietário da sua Organização para obter assistência." + "message": "Itens em organizações suspensas não podem ser acessados. Entre em contato com o proprietário da sua Organização para obter assistência." }, "loggingInTo": { - "message": "Fazendo login em $DOMAIN$", + "message": "Conectando-se a $DOMAIN$", "placeholders": { "domain": { "content": "$1", @@ -3456,7 +3557,7 @@ "message": "Terceiros" }, "thirdPartyServerMessage": { - "message": "Conectado a implementação de servidores terceiros, $SERVERNAME$. Por favor, verifique as falhas usando o servidor oficial ou reporte-os ao servidor de terceiros.", + "message": "Conectado à uma implementação de terceiros do servidor, $SERVERNAME$. Verifique bugs usando o servidor oficial ou reporte-os ao servidor de terceiros.", "placeholders": { "servername": { "content": "$1", @@ -3474,7 +3575,7 @@ } }, "loginWithMasterPassword": { - "message": "Entrar com a senha mestra" + "message": "Conectar-se com senha principal" }, "newAroundHere": { "message": "Novo por aqui?" @@ -3483,49 +3584,49 @@ "message": "Lembrar e-mail" }, "loginWithDevice": { - "message": "Fazer login com dispositivo" + "message": "Conectar-se com dispositivo" }, "fingerprintPhraseHeader": { - "message": "Frase de impressão digital" + "message": "Frase biométrica" }, "fingerprintMatchInfo": { - "message": "Certifique-se que o cofre esteja desbloqueado e que a frase de impressão digital corresponda à do outro dispositivo." + "message": "Certifique-se que o cofre esteja desbloqueado e que a frase biométrica corresponda à do outro dispositivo." }, "resendNotification": { "message": "Reenviar notificação" }, "viewAllLogInOptions": { - "message": "Visualizar todas as opções de login" + "message": "Ver todas as opções de autenticação" }, "notificationSentDevice": { "message": "Uma notificação foi enviada para seu dispositivo." }, "notificationSentDevicePart1": { - "message": "Desbloqueie o Bitwarden em seu dispositivo ou na" + "message": "Desbloqueie o Bitwarden no seu dispositivo ou no " }, "notificationSentDeviceAnchor": { "message": "aplicativo web" }, "notificationSentDevicePart2": { - "message": "Certifique-se de que a frase de biometria corresponde a frase abaixo antes de aprovar." + "message": "Certifique-se de que a frase biométrica corresponde a frase abaixo antes de aprovar." }, "aNotificationWasSentToYourDevice": { "message": "Uma notificação foi enviada para o seu dispositivo" }, "youWillBeNotifiedOnceTheRequestIsApproved": { - "message": "Você será notificado assim que a requisição for aprovada" + "message": "Você será notificado assim que a solicitação for aprovada" }, "needAnotherOptionV1": { "message": "Precisa de outra opção?" }, "loginInitiated": { - "message": "Login iniciado" + "message": "Autenticação iniciada" }, "logInRequestSent": { - "message": "Pedido enviado" + "message": "Solicitação enviada" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "Solicitação de autenticação aprovada para $EMAIL$ em $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3538,40 +3639,40 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Você negou uma tentativa de autenticação de outro dispositivo. Se era você, tente se conectar com o dispositivo novamente." }, "device": { - "message": "Device" + "message": "Dispositivo" }, "loginStatus": { - "message": "Login status" + "message": "Estado de autenticação" }, "masterPasswordChanged": { - "message": "Master password saved" + "message": "Senha principal salva" }, "exposedMasterPassword": { - "message": "Senha mestra comprometida" + "message": "Senha principal comprometida" }, "exposedMasterPasswordDesc": { "message": "A senha foi encontrada em um vazamento de dados. Use uma senha única para proteger sua conta. Tem certeza de que deseja usar uma senha já exposta?" }, "weakAndExposedMasterPassword": { - "message": "Senha mestra fraca e comprometida" + "message": "Senha principal fraca e comprometida" }, "weakAndBreachedMasterPasswordDesc": { "message": "Senha fraca identificada e encontrada em um vazamento de dados. Use uma senha forte e única para proteger a sua conta. Tem certeza de que deseja usar essa senha?" }, "checkForBreaches": { - "message": "Verificar vazamento de dados conhecidos para esta senha" + "message": "Conferir vazamentos de dados conhecidos por esta senha" }, "important": { "message": "Importante:" }, "masterPasswordHint": { - "message": "Sua senha mestra não pode ser recuperada se você a esquecer!" + "message": "Sua senha principal não pode ser recuperada se você a esquecer!" }, "characterMinimum": { - "message": "$LENGTH$ caracteres mínimos", + "message": "Mínimo de $LENGTH$ caracteres", "placeholders": { "length": { "content": "$1", @@ -3613,7 +3714,7 @@ "message": "Atalho de teclado para preenchimento automático" }, "autofillLoginShortcutNotSet": { - "message": "O atalho do preenchimento automático não está definido. Altere isso nas configurações do navegador." + "message": "O atalho do preenchimento automático não está configurado. Altere isso nas configurações do navegador." }, "autofillLoginShortcutText": { "message": "O atalho de preenchimento automático é $COMMAND$. Gerencie todos os atalhos nas configurações do navegador.", @@ -3634,34 +3735,34 @@ } }, "opensInANewWindow": { - "message": "Abrir em uma nova janela" + "message": "Abre em uma nova janela" }, "rememberThisDeviceToMakeFutureLoginsSeamless": { - "message": "Lembrar deste dispositivo para permanecer conectado" + "message": "Lembrar deste dispositivo para tornar futuras autenticações simples" }, "manageDevices": { - "message": "Manage devices" + "message": "Gerenciar dispositivos" }, "currentSession": { - "message": "Current session" + "message": "Sessão atual" }, "mobile": { - "message": "Mobile", + "message": "Móvel", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "Extensão", "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "Computador", "description": "Desktop app" }, "webVault": { - "message": "Web vault" + "message": "Cofre web" }, "webApp": { - "message": "Web app" + "message": "Aplicativo web" }, "cli": { "message": "CLI" @@ -3671,22 +3772,22 @@ "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "Solicitação pendente" }, "firstLogin": { - "message": "First login" + "message": "Primeiro acesso" }, "trusted": { - "message": "Trusted" + "message": "Confiado" }, "needsApproval": { - "message": "Needs approval" + "message": "Precisa de aprovação" }, "devices": { - "message": "Devices" + "message": "Dispositivos" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "Tentativa de acesso por $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -3695,31 +3796,31 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "Confirmar acesso" }, "denyAccess": { - "message": "Deny access" + "message": "Negar acesso" }, "time": { - "message": "Time" + "message": "Horário" }, "deviceType": { - "message": "Device Type" + "message": "Tipo do dispositivo" }, "loginRequest": { - "message": "Login request" + "message": "Solicitação de autenticação" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Esta solicitação não é mais válida." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "A solicitação de autenticação já expirou." }, "justNow": { - "message": "Just now" + "message": "Agora há pouco" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "Solicitado há $MINUTES$ minutos", "placeholders": { "minutes": { "content": "$1", @@ -3749,37 +3850,37 @@ "message": "Solicitar aprovação do administrador" }, "unableToCompleteLogin": { - "message": "Unable to complete login" + "message": "Não é possível concluir a autenticação" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { - "message": "You need to log in on a trusted device or ask your administrator to assign you a password." + "message": "Você precisa se conectar com um dispositivo confiado ou solicitar ao administrador que lhe atribua uma senha." }, "ssoIdentifierRequired": { - "message": "Identificador SSO da organização é necessário." + "message": "O identificador de SSO da organização é necessário." }, "creatingAccountOn": { "message": "Criando conta em" }, "checkYourEmail": { - "message": "Verifique seu e-mail" + "message": "Confira seu e-mail" }, "followTheLinkInTheEmailSentTo": { - "message": "Siga o link no e-mail enviado para" + "message": "Abra o link no e-mail enviado para" }, "andContinueCreatingYourAccount": { "message": "e continue criando a sua conta." }, "noEmail": { - "message": "Sem e-mail?" + "message": "Nenhum e-mail?" }, "goBack": { - "message": "Voltar" + "message": "Volte" }, "toEditYourEmailAddress": { "message": "para editar o seu endereço de e-mail." }, "eu": { - "message": "Europa", + "message": "União Europeia", "description": "European Union" }, "accessDenied": { @@ -3789,7 +3890,7 @@ "message": "Gerais" }, "display": { - "message": "Exibir" + "message": "Exibição" }, "accountSuccessfullyCreated": { "message": "Conta criada com sucesso!" @@ -3798,53 +3899,53 @@ "message": "Aprovação do administrador necessária" }, "adminApprovalRequestSentToAdmins": { - "message": "Seu pedido foi enviado para seu administrador." + "message": "Sua solicitação foi enviada para seu administrador." }, "troubleLoggingIn": { - "message": "Problemas em efetuar login?" + "message": "Problemas para acessar?" }, "loginApproved": { - "message": "Login aprovado" + "message": "Autenticação aprovada" }, "userEmailMissing": { "message": "E-mail do usuário ausente" }, "activeUserEmailNotFoundLoggingYouOut": { - "message": "E-mail de usuário ativo não encontrado. Desconectando." + "message": "E-mail do usuário ativo não encontrado. Desconectando você." }, "deviceTrusted": { - "message": "Dispositivo confiável" + "message": "Dispositivo confiado" }, "trustOrganization": { - "message": "Trust organization" + "message": "Confiar organização" }, "trust": { - "message": "Trust" + "message": "Confiar" }, "doNotTrust": { - "message": "Do not trust" + "message": "Não confiar" }, "organizationNotTrusted": { - "message": "Organization is not trusted" + "message": "Organização não foi confiada" }, "emergencyAccessTrustWarning": { - "message": "For the security of your account, only confirm if you have granted emergency access to this user and their fingerprint matches what is displayed in their account" + "message": "Para a segurança da sua conta, apenas confirme que você permitiu o acesso de emergência a esse usuário e se a frase biométrica dele coincide com a que é exibida na conta deles" }, "orgTrustWarning": { - "message": "For the security of your account, only proceed if you are a member of this organization, have account recovery enabled, and the fingerprint displayed below matches the organization's fingerprint." + "message": "Para a segurança da sua conta, prossiga apenas se você for um membro dessa organização, tem a recuperação de conta ativa, e a frase biométrica exibida abaixo corresponde com a da organização." }, "orgTrustWarning1": { - "message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint." + "message": "Esta organização tem uma política empresarial que lhe inscreverá na recuperação de conta. A inscrição permitirá que os administradores da organização alterem sua senha. Prossiga somente se você reconhecer esta organização e se a frase biométrica exibida abaixo corresponde com a da organização." }, "trustUser": { - "message": "Trust user" + "message": "Confiar no usuário" }, "sendsTitleNoItems": { "message": "Envie informações confidenciais com segurança", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsBodyNoItems": { - "message": "Compartilhe arquivos e dados de forma segura com qualquer pessoa, em qualquer plataforma. Suas informações permanecerão criptografadas de ponta a ponta enquanto limitam a exposição.", + "message": "Compartilhe dados e arquivos com segurança com qualquer pessoa, em qualquer plataforma. Suas informações permanecerão criptografadas de ponta a ponta, limitando a exposição.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "inputRequired": { @@ -3854,7 +3955,7 @@ "message": "obrigatório" }, "search": { - "message": "Pesquisar" + "message": "Buscar" }, "inputMinLength": { "message": "A entrada deve ter pelo menos $COUNT$ caracteres.", @@ -3924,7 +4025,7 @@ "message": "1 campo precisa de sua atenção." }, "multipleFieldsNeedAttention": { - "message": "Campos $COUNT$ precisam de sua atenção.", + "message": "$COUNT$ campos precisam da sua atenção.", "placeholders": { "count": { "content": "$1", @@ -3939,16 +4040,16 @@ "message": "-- Digite para filtrar --" }, "multiSelectLoading": { - "message": "Carrgando Opções..." + "message": "Carrgando opções..." }, "multiSelectNotFound": { "message": "Nenhum item encontrado" }, "multiSelectClearAll": { - "message": "Limpar todos" + "message": "Limpar tudo" }, "plusNMore": { - "message": "+ $QUANTITY$ mais", + "message": "+ mais $QUANTITY$", "placeholders": { "quantity": { "content": "$1", @@ -3960,18 +4061,27 @@ "message": "Submenu" }, "toggleCollapse": { - "message": "Alternar colapso", + "message": "Guardar/mostrar", "description": "Toggling an expand/collapse state." }, "aliasDomain": { - "message": "Alias do domínio" + "message": "Domínio de alias" }, "autofillOnPageLoadSetToDefault": { "message": "O preenchimento automático ao carregar a página está usando a configuração padrão.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Não é possível preencher automaticamente" + }, + "cannotAutofillExactMatch": { + "message": "A correspondência padrão está configurada como 'Correspondência exata'. O site atual não corresponde exatamente aos detalhes salvos de credencial para este item." + }, + "okay": { + "message": "Ok" + }, "toggleSideNavigation": { - "message": "Ativar/desativar navegação lateral" + "message": "Habilitar navegação lateral" }, "skipToContent": { "message": "Ir para o conteúdo" @@ -3981,7 +4091,7 @@ "description": "Page title for the iframe containing the overlay button" }, "toggleBitwardenVaultOverlay": { - "message": "Ativar menu de preenchimento automático do Bitwarden", + "message": "Habilitar menu de preenchimento automático do Bitwarden", "description": "Screen reader and tool tip label for the overlay button" }, "bitwardenVault": { @@ -3989,7 +4099,7 @@ "description": "Page title in overlay" }, "unlockYourAccountToViewMatchingLogins": { - "message": "Desbloqueie sua conta para ver os logins correspondentes", + "message": "Desbloqueie sua conta para ver as credenciais correspondentes", "description": "Text to display in overlay when the account is locked." }, "unlockYourAccountToViewAutofillSuggestions": { @@ -4001,7 +4111,7 @@ "description": "Button text to display in overlay when the account is locked." }, "unlockAccountAria": { - "message": "Desbloqueie sua conta, abra em uma nova janela", + "message": "Desbloquear sua conta, abre em uma nova janela", "description": "Screen reader text (aria-label) for unlock account button in overlay" }, "totpCodeAria": { @@ -4009,15 +4119,15 @@ "description": "Aria label for the totp code displayed in the inline menu for autofill" }, "totpSecondsSpanAria": { - "message": "Tempo até o código expirar", + "message": "Tempo até o código TOTP atual expirar", "description": "Aria label for the totp seconds displayed in the inline menu for autofill" }, "fillCredentialsFor": { - "message": "Preencha as credenciais para", + "message": "Preencher credenciais para", "description": "Screen reader text for when overlay item is in focused" }, "partialUsername": { - "message": "Nome parcial", + "message": "Nome de usuário parcial", "description": "Screen reader text for when a login item is focused where a partial username is displayed. SR will announce this phrase before reading the text of the partial username" }, "noItemsToShow": { @@ -4029,15 +4139,15 @@ "description": "Button text to display in overlay when there are no matching items" }, "addNewVaultItem": { - "message": "Adicionar novo item do cofre", + "message": "Adicionar novo item no cofre", "description": "Screen reader text (aria-label) for new item button in overlay" }, "newLogin": { - "message": "Novo login", + "message": "Nova credencial", "description": "Button text to display within inline menu when there are no matching items on a login field" }, "addNewLoginItemAria": { - "message": "Adicionar novo item de login no cofre, abre em uma nova janela", + "message": "Adicionar novo item de credencial no cofre, abre em uma nova janela", "description": "Screen reader text (aria-label) for new login button within inline menu" }, "newCard": { @@ -4045,7 +4155,7 @@ "description": "Button text to display within inline menu when there are no matching items on a credit card field" }, "addNewCardItemAria": { - "message": "Adicione um novo item do cartão do cofre, abre em uma nova janela", + "message": "Adicionar um novo item de cartão no cofre, abre em uma nova janela", "description": "Screen reader text (aria-label) for new card button within inline menu" }, "newIdentity": { @@ -4053,7 +4163,7 @@ "description": "Button text to display within inline menu when there are no matching items on an identity field" }, "addNewIdentityItemAria": { - "message": "Adicionar novo item de identidade do cofre, abre em uma nova janela", + "message": "Adicionar novo item de identidade no cofre, abre em uma nova janela", "description": "Screen reader text (aria-label) for new identity button within inline menu" }, "bitwardenOverlayMenuAvailable": { @@ -4061,7 +4171,7 @@ "description": "Screen reader text for announcing when the overlay opens on the page" }, "turnOn": { - "message": "Ligar" + "message": "Ativar" }, "ignore": { "message": "Ignorar" @@ -4074,7 +4184,7 @@ "message": "Erro ao importar" }, "importErrorDesc": { - "message": "Houve um problema com os dados que você tentou importar. Por favor, resolva os erros listados abaixo em seu arquivo de origem e tente novamente." + "message": "Houve um problema com os dados que você tentou importar. Resolva os erros listados abaixo em seu arquivo de origem e tente novamente." }, "resolveTheErrorsBelowAndTryAgain": { "message": "Resolva os erros abaixo e tente novamente." @@ -4098,25 +4208,25 @@ "message": "Tentar novamente" }, "verificationRequiredForActionSetPinToContinue": { - "message": "Verificação necessária para esta ação. Defina um PIN para continuar." + "message": "Verificação necessária para esta ação. Configure um PIN para continuar." }, "setPin": { - "message": "Definir PIN" + "message": "Configurar PIN" }, "verifyWithBiometrics": { - "message": "Verificiar com biometria" + "message": "Verificar com biometria" }, "awaitingConfirmation": { "message": "Aguardando confirmação" }, "couldNotCompleteBiometrics": { - "message": "Não foi possível completar a biometria." + "message": "Não foi possível concluir a biometria." }, "needADifferentMethod": { "message": "Precisa de um método diferente?" }, "useMasterPassword": { - "message": "Usar a senha mestra" + "message": "Usar senha principal" }, "usePin": { "message": "Usar PIN" @@ -4149,13 +4259,13 @@ "message": "A autenticação em duas etapas do Duo é necessária para sua conta." }, "popoutExtension": { - "message": "Extensão pop-out" + "message": "Criar janela da extensão" }, "launchDuo": { - "message": "Abrir o Duo" + "message": "Abrir Duo" }, "importFormatError": { - "message": "Os dados não estão formatados corretamente. Por favor, verifique o seu arquivo de importação e tente novamente." + "message": "Os dados não estão formatados corretamente. Verifique o seu arquivo de importação e tente novamente." }, "importNothingError": { "message": "Nada foi importado." @@ -4164,7 +4274,7 @@ "message": "Erro ao descriptografar o arquivo exportado. Sua chave de criptografia não corresponde à chave de criptografia usada para exportar os dados." }, "invalidFilePassword": { - "message": "Senha do arquivo inválida, por favor informe a senha utilizada quando criou o arquivo de exportação." + "message": "Senha do arquivo inválida, informe a senha utilizada quando criou o arquivo de exportação." }, "destination": { "message": "Destino" @@ -4176,13 +4286,13 @@ "message": "Selecione uma pasta" }, "selectImportCollection": { - "message": "Selecione uma coleção" + "message": "Selecione um conjunto" }, "importTargetHintCollection": { - "message": "Select this option if you want the imported file contents moved to a collection" + "message": "Selecione esta opção caso queira importar os conteúdos do arquivo para um conjunto" }, "importTargetHintFolder": { - "message": "Select this option if you want the imported file contents moved to a folder" + "message": "Selecione esta opção caso queira importar os conteúdos do arquivo para uma pasta" }, "importUnassignedItemsError": { "message": "Arquivo contém itens não atribuídos." @@ -4194,7 +4304,7 @@ "message": "Selecione o arquivo de importação" }, "chooseFile": { - "message": "Selecionar Arquivo" + "message": "Selecionar arquivo" }, "noFileChosen": { "message": "Nenhum arquivo escolhido" @@ -4203,7 +4313,7 @@ "message": "ou copie/cole o conteúdo do arquivo de importação" }, "instructionsFor": { - "message": "$NAME$ instruções", + "message": "Instruções para $NAME$", "description": "The title for the import tool instructions.", "placeholders": { "name": { @@ -4216,7 +4326,7 @@ "message": "Confirmar importação do cofre" }, "confirmVaultImportDesc": { - "message": "Este arquivo é protegido por senha. Por favor, digite a senha do arquivo para importar os dados." + "message": "Este arquivo é protegido por senha. Digite a senha do arquivo para importar os dados." }, "confirmFilePassword": { "message": "Confirmar senha do arquivo" @@ -4231,7 +4341,7 @@ "message": "Acessando" }, "loggedInExclamation": { - "message": "Sessão Iniciada!" + "message": "Conectado!" }, "passkeyNotCopied": { "message": "A chave de acesso não será copiada" @@ -4240,7 +4350,7 @@ "message": "A chave de acesso não será copiada para o item clonado. Deseja continuar clonando este item?" }, "logInWithPasskeyQuestion": { - "message": "Fazer ‘login’ com chave de acesso?" + "message": "Conectar-se com chave de acesso?" }, "passkeyAlreadyExists": { "message": "Uma chave de acesso já existe para este aplicativo." @@ -4249,13 +4359,13 @@ "message": "Nenhuma chave de acesso encontrada para este aplicativo." }, "noMatchingPasskeyLogin": { - "message": "Você não tem um login correspondente para este site." + "message": "Você não tem uma credencial correspondente para este site." }, "noMatchingLoginsForSite": { "message": "Sem credenciais correspondentes para este site" }, "searchSavePasskeyNewLogin": { - "message": "Pesquisar ou salvar senha como novo login" + "message": "Buscar ou salvar chave de acesso como nova credencial" }, "confirm": { "message": "Confirmar" @@ -4264,19 +4374,19 @@ "message": "Salvar chave de acesso" }, "savePasskeyNewLogin": { - "message": "Salvar chave de acesso como um novo login" + "message": "Salvar chave de acesso como nova credencial" }, "chooseCipherForPasskeySave": { - "message": "Escolha um ‘login’ para salvar com essa chave de acesso" + "message": "Escolha uma credencial para salvar essa chave de acesso" }, "chooseCipherForPasskeyAuth": { - "message": "Escolha uma senha para iniciar sessão" + "message": "Escolha uma chave de acesso para conectar-se" }, "passkeyItem": { "message": "Item de chave de acesso" }, "overwritePasskey": { - "message": "Sobrescrever chave de acesso?" + "message": "Substituir chave de acesso?" }, "overwritePasskeyAlert": { "message": "Este item já contém uma chave de acesso. Tem certeza que deseja substituir a atual?" @@ -4312,19 +4422,19 @@ "message": "Incluir pastas compartilhadas" }, "lastPassEmail": { - "message": "LastPass Email" + "message": "E-mail do LastPass" }, "importingYourAccount": { "message": "Importando sua conta..." }, "lastPassMFARequired": { - "message": "Requer autenticação multifatores do LastPass" + "message": "Autenticação multifatorial do LastPass é necessária" }, "lastPassMFADesc": { - "message": "Digite sua senha única do app de autenticação" + "message": "Digite sua senha única do app autenticador" }, "lastPassOOBDesc": { - "message": "Aprove a solicitação de login no seu aplicativo de autenticação ou insira código unico." + "message": "Aprove a solicitação de autenticação no seu aplicativo autenticador ou digite um código único." }, "passcode": { "message": "Código" @@ -4336,10 +4446,10 @@ "message": "Autenticação do LastPass necessária" }, "awaitingSSO": { - "message": "Aguardando autenticação SSO" + "message": "Aguardando autenticação do SSO" }, "awaitingSSODesc": { - "message": "Por favor, continue a iniciar a sessão usando as credenciais da sua empresa." + "message": "Continue conectando-se usando as credenciais da sua empresa." }, "seeDetailedInstructions": { "message": "Veja instruções detalhadas no nosso site de ajuda em", @@ -4355,31 +4465,31 @@ "message": "Tente novamente ou procure um e-mail do LastPass para verificar que é você." }, "collection": { - "message": "Coleção" + "message": "Conjunto" }, "lastPassYubikeyDesc": { - "message": "Insira a YubiKey associada com a sua conta do LastPass na porta USB do seu computador, e depois toque no botão dele." + "message": "Insira a YubiKey associada com a sua conta do LastPass na porta USB do seu computador, e depois toque no botão dela." }, "switchAccount": { - "message": "Trocar conta" + "message": "Trocar de conta" }, "switchAccounts": { - "message": "Alternar conta" + "message": "Trocar de conta" }, "switchToAccount": { - "message": "Mudar para conta" + "message": "Trocar para a conta" }, "activeAccount": { "message": "Conta ativa" }, "bitwardenAccount": { - "message": "Conta Bitwarden" + "message": "Conta do Bitwarden" }, "availableAccounts": { "message": "Contas disponíveis" }, "accountLimitReached": { - "message": "Limite de contas atingido. Termine a sessão de uma conta para adicionar outra." + "message": "Limite de contas atingido. Desconecte uma conta para adicionar outra." }, "active": { "message": "ativo" @@ -4397,7 +4507,7 @@ "message": "hospedado em" }, "useDeviceOrHardwareKey": { - "message": "Use o seu dispositivo ou chave de hardware" + "message": "Use a sua chave de dispositivo ou hardware" }, "justOnce": { "message": "Somente uma vez" @@ -4419,23 +4529,23 @@ "description": "Label indicating the most common import formats" }, "uriMatchDefaultStrategyHint": { - "message": "URI match detection is how Bitwarden identifies autofill suggestions.", + "message": "Detecção de correspondência de URI é como o Bitwarden identifica sugestões de preenchimento automático.", "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": { - "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", + "message": "\"Expressão regular\" é uma opção avançada com maior risco de exposição das credenciais.", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, "startsWithAdvancedOptionWarning": { - "message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.", + "message": "\"Começa com\" é uma opção avançada com maior risco de exposição de credenciais.", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { - "message": "More about match detection", + "message": "Mais sobre a detecção de correspondência", "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "Opções avançadas", "description": "Advanced option placeholder for uri option component" }, "confirmContinueToBrowserSettingsTitle": { @@ -4443,7 +4553,7 @@ "description": "Title for dialog which asks if the user wants to proceed to a relevant browser settings page" }, "confirmContinueToHelpCenter": { - "message": "Continuar para o Centro de Ajuda?", + "message": "Continuar no Centro de Ajuda?", "description": "Title for dialog which asks if the user wants to proceed to a relevant Help Center page" }, "confirmContinueToHelpCenterPasswordManagementContent": { @@ -4451,7 +4561,7 @@ "description": "Body content for dialog which asks if the user wants to proceed to the Help Center's page about browser password management settings" }, "confirmContinueToHelpCenterKeyboardShortcutsContent": { - "message": "Você pode ver e definir atalhos de extensão nas configurações do seu navegador.", + "message": "Você pode ver e configurar atalhos de extensão nas configurações do seu navegador.", "description": "Body content for dialog which asks if the user wants to proceed to the Help Center's page about browser keyboard shortcut settings" }, "confirmContinueToBrowserPasswordManagementSettingsContent": { @@ -4459,7 +4569,7 @@ "description": "Body content for dialog which asks if the user wants to proceed to the browser's password management settings page" }, "confirmContinueToBrowserKeyboardShortcutSettingsContent": { - "message": "Você pode ver e definir atalhos de extensão nas configurações do seu navegador.", + "message": "Você pode ver e configurar atalhos de extensão nas configurações do seu navegador.", "description": "Body content for dialog which asks if the user wants to proceed to the browser's keyboard shortcut settings page" }, "overrideDefaultBrowserAutofillTitle": { @@ -4475,11 +4585,11 @@ "description": "Label for the setting that allows overriding the default browser autofill settings" }, "privacyPermissionAdditionNotGrantedTitle": { - "message": "Não é possível definir o Bitwarden como o gerenciador de senhas padrão", + "message": "Não é possível configurar o Bitwarden como o gerenciador de senhas padrão", "description": "Title for the dialog that appears when the user has not granted the extension permission to set privacy settings" }, "privacyPermissionAdditionNotGrantedDescription": { - "message": "Você deve conceder permissões de privacidade do navegador ao Bitwarden para defini-lo como o Gerenciador de Senhas padrão.", + "message": "Você deve conceder permissões de privacidade do navegador ao Bitwarden para configurá-lo como o gerenciador de senhas padrão.", "description": "Description for the dialog that appears when the user has not granted the extension permission to set privacy settings" }, "makeDefault": { @@ -4522,19 +4632,19 @@ "message": "Itens sugeridos" }, "autofillSuggestionsTip": { - "message": "Salvar um item de login para este site autopreenchimento" + "message": "Salve um item de credencial para este site para preencher automaticamente" }, "yourVaultIsEmpty": { "message": "Seu cofre está vazio" }, "noItemsMatchSearch": { - "message": "Nenhum item corresponde à sua pesquisa" + "message": "Nenhum item corresponde à sua busca" }, "clearFiltersOrTryAnother": { - "message": "Limpar filtros ou tentar outro termo de pesquisa" + "message": "Limpe os filtros ou tente outro termo de busca" }, "copyInfoTitle": { - "message": "Copiar informação - $ITEMNAME$", + "message": "Copiar informações - $ITEMNAME$", "description": "Title for a button that opens a menu with options to copy information from an item.", "placeholders": { "itemname": { @@ -4544,7 +4654,7 @@ } }, "copyNoteTitle": { - "message": "Copiar Nota - $ITEMNAME$", + "message": "Copiar anotação - $ITEMNAME$", "description": "Title for a button copies a note to the clipboard.", "placeholders": { "itemname": { @@ -4574,7 +4684,7 @@ } }, "viewItemTitle": { - "message": "Visualizar item - $ITEMNAME$", + "message": "Ver item - $ITEMNAME$", "description": "Title for a link that opens a view for an item.", "placeholders": { "itemname": { @@ -4584,7 +4694,7 @@ } }, "viewItemTitleWithField": { - "message": "Visualizar item - $ITEMNAME$ - $FIELD$", + "message": "Ver item - $ITEMNAME$ - $FIELD$", "description": "Title for a link that opens a view for an item.", "placeholders": { "itemname": { @@ -4622,7 +4732,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Copiar $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -4639,7 +4749,7 @@ "message": "Não há valores para copiar" }, "assignToCollections": { - "message": "Atribuir à coleções" + "message": "Atribuir a conjuntos" }, "copyEmail": { "message": "Copiar e-mail" @@ -4663,13 +4773,13 @@ "message": "Aparência" }, "errorAssigningTargetCollection": { - "message": "Erro ao atribuir coleção de destino." + "message": "Erro ao atribuir conjunto de destino." }, "errorAssigningTargetFolder": { "message": "Erro ao atribuir pasta de destino." }, "viewItemsIn": { - "message": "Visualizar itens em $NAME$", + "message": "Ver itens em $NAME$", "description": "Button to view the contents of a folder or collection", "placeholders": { "name": { @@ -4705,7 +4815,7 @@ "message": "Itens sem pasta" }, "itemDetails": { - "message": "Detalhes dos item" + "message": "Detalhes do item" }, "itemName": { "message": "Nome do item" @@ -4724,7 +4834,7 @@ "message": "Itens em organizações desativadas não podem ser acessados. Entre em contato com o proprietário da sua organização para obter assistência." }, "additionalInformation": { - "message": "Informação adicional" + "message": "Informações adicionais" }, "itemHistory": { "message": "Histórico do item" @@ -4739,10 +4849,10 @@ "message": "Vinculado" }, "copySuccessful": { - "message": "Copiado com Sucesso" + "message": "Copiado com sucesso" }, "upload": { - "message": "Fazer upload" + "message": "Enviar" }, "addAttachment": { "message": "Adicionar anexo" @@ -4751,7 +4861,7 @@ "message": "O tamanho máximo de arquivo é de 500 MB" }, "deleteAttachmentName": { - "message": "Excluir anexo $NAME$", + "message": "Apagar anexo $NAME$", "placeholders": { "name": { "content": "$1", @@ -4772,35 +4882,38 @@ "message": "Baixar o Bitwarden" }, "downloadBitwardenOnAllDevices": { - "message": "Baixar o Bitwarden em todos os dispositivos" + "message": "Baixar o Bitwarden em tudo" }, "getTheMobileApp": { - "message": "Baixar o aplicativo para dispositivos móveis" + "message": "Baixe o aplicativo para dispositivos móveis" }, "getTheMobileAppDesc": { - "message": "Acesse as suas senhas em qualquer lugar com o aplicativo móvel Bitwarden." + "message": "Acesse as suas senhas em qualquer lugar com o aplicativo móvel do Bitwarden." }, "getTheDesktopApp": { - "message": "Obter o aplicativo para desktop" + "message": "Baixe o aplicativo para computador" }, "getTheDesktopAppDesc": { - "message": "Acesse o seu cofre sem um navegador e, em seguida, configure o desbloqueio com dados biométricos para facilitar o desbloqueio tanto no aplicativo desktop quanto na extensão do navegador." + "message": "Acesse o seu cofre sem um navegador, configure o desbloqueio com biometria para facilitar o desbloqueio tanto no aplicativo de computador quanto na extensão do navegador." }, "downloadFromBitwardenNow": { "message": "Baixar em bitwarden.com agora" }, "getItOnGooglePlay": { - "message": "Get it on Google Play" + "message": "Baixar no Google Play" }, "downloadOnTheAppStore": { - "message": "Download on the App Store" + "message": "Baixar na App Store" }, "permanentlyDeleteAttachmentConfirmation": { - "message": "Tem certeza de que deseja excluir este anexo permanentemente?" + "message": "Tem certeza que quer apagar este anexo para sempre?" }, "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Desbloqueie relatórios, acesso de emergência, e mais recursos de segurança com o Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Organizações gratuitas não podem usar anexos" }, @@ -4814,7 +4927,7 @@ "message": "Um filtro aplicado" }, "filterAppliedPlural": { - "message": "Foram aplicados $COUNT$ filtros", + "message": "$COUNT$ filtros aplicados", "placeholders": { "count": { "content": "$1", @@ -4845,7 +4958,7 @@ "description": "Used within the inline menu to provide an aria description when users are attempting to fill a card cipher." }, "loginCredentials": { - "message": "Credenciais de login" + "message": "Credenciais de acesso" }, "authenticatorKey": { "message": "Chave do autenticador" @@ -4873,7 +4986,7 @@ "message": "Adicionar site" }, "deleteWebsite": { - "message": "Excluir site" + "message": "Apagar site" }, "defaultLabel": { "message": "Padrão ($VALUE$)", @@ -4885,8 +4998,18 @@ } } }, + "defaultLabelWithValue": { + "message": "Padrão ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { - "message": "Exibir detecção de correspondência $WEBSITE$", + "message": "Mostrar detecção de correspondência $WEBSITE$", "placeholders": { "website": { "content": "$1", @@ -4907,7 +5030,7 @@ "message": "Preencher automaticamente ao carregar a página?" }, "cardExpiredTitle": { - "message": "Cartão expirado" + "message": "Cartão vencido" }, "cardExpiredMessage": { "message": "Se você o renovou, atualize as informações do cartão" @@ -4916,7 +5039,7 @@ "message": "Detalhes do cartão" }, "cardBrandDetails": { - "message": "Detalhes $BRAND$", + "message": "Detalhes da $BRAND$", "placeholders": { "brand": { "content": "$1", @@ -4925,7 +5048,7 @@ } }, "showAnimations": { - "message": "Exibir animações" + "message": "Mostrar animações" }, "addAccount": { "message": "Adicionar conta" @@ -4934,10 +5057,10 @@ "message": "Carregando" }, "data": { - "message": "Dado" + "message": "Dados" }, "passkeys": { - "message": "Senhas", + "message": "Chaves de acesso", "description": "A section header for a list of passkeys." }, "passwords": { @@ -4945,17 +5068,17 @@ "description": "A section header for a list of passwords." }, "logInWithPasskeyAriaLabel": { - "message": "Iniciar sessão com a chave de acesso", + "message": "Conectar-se com chave de acesso", "description": "ARIA label for the inline menu button that logs in with a passkey." }, "assign": { "message": "Atribuir" }, "bulkCollectionAssignmentDialogDescriptionSingular": { - "message": "Apenas membros da organização com acesso a essas coleções poderão ver o item." + "message": "Apenas membros da organização com acesso a estes conjuntos poderão ver o item." }, "bulkCollectionAssignmentDialogDescriptionPlural": { - "message": "Apenas membros da organização com acesso à essas coleções poderão ver os itens." + "message": "Apenas membros da organização com acesso a estes conjuntos poderão ver os itens." }, "bulkCollectionAssignmentWarning": { "message": "Você selecionou $TOTAL_COUNT$ itens. Você não pode atualizar $READONLY_COUNT$ destes itens porque você não tem permissão de edição.", @@ -4982,19 +5105,19 @@ "message": "Rótulo do campo" }, "textHelpText": { - "message": "Utilize campos de texto para dados como questões de segurança" + "message": "Use campos de texto para dados como questões de segurança" }, "hiddenHelpText": { "message": "Use campos ocultos para dados confidenciais como uma senha" }, "checkBoxHelpText": { - "message": "Use caixas de seleção se gostaria de preencher automaticamente a caixa de seleção de um formulário, como um e-mail de lembrança" + "message": "Use caixas de seleção se gostaria de preencher automaticamente a caixa de seleção de um formulário, como um lembrar e-mail" }, "linkedHelpText": { "message": "Use um campo vinculado quando estiver enfrentando problemas com o preenchimento automático com um site específico." }, "linkedLabelHelpText": { - "message": "Digite o Id html do campo, nome, nome aria-label, ou espaço reservado." + "message": "Digite o ID html, nome, aria-label, ou placeholder do campo." }, "editField": { "message": "Editar campo" @@ -5009,7 +5132,7 @@ } }, "deleteCustomField": { - "message": "Excluir $LABEL$", + "message": "Apagar $LABEL$", "placeholders": { "label": { "content": "$1", @@ -5027,7 +5150,7 @@ } }, "reorderToggleButton": { - "message": "Reordene $LABEL$. Use a tecla de seta para mover o item para cima ou para baixo.", + "message": "Reordenar $LABEL$. Use a tecla de seta para mover o item para cima ou para baixo.", "placeholders": { "label": { "content": "$1", @@ -5036,10 +5159,10 @@ } }, "reorderWebsiteUriButton": { - "message": "Reorganize a URI do site. Use as setas para mover o item para cima ou para baixo." + "message": "Reordenar a URI do site. Use as setas para mover o item para cima ou para baixo." }, "reorderFieldUp": { - "message": "$LABEL$ se moveu para cima, posição $INDEX$ de $LENGTH$", + "message": "$LABEL$ movido para cima, posição $INDEX$ de $LENGTH$", "placeholders": { "label": { "content": "$1", @@ -5056,13 +5179,13 @@ } }, "selectCollectionsToAssign": { - "message": "Selecione as coleções para atribuir" + "message": "Selecione conjuntos a atribuir" }, "personalItemTransferWarningSingular": { - "message": "1 item será transferido permanentemente para a organização selecionada. Você não irá mais possuir este item." + "message": "1 item será transferido permanentemente para a organização selecionada. Você não irá mais ser o proprietário deste item." }, "personalItemsTransferWarningPlural": { - "message": "Itens $PERSONAL_ITEMS_COUNT$ serão transferidos permanentemente para a organização selecionada. Você não irá mais possuir esses itens.", + "message": "$PERSONAL_ITEMS_COUNT$ itens serão transferidos permanentemente para a organização selecionada. Você não irá mais ser o proprietário desses itens.", "placeholders": { "personal_items_count": { "content": "$1", @@ -5071,7 +5194,7 @@ } }, "personalItemWithOrgTransferWarningSingular": { - "message": "1 item será transferido permanentemente para $ORG$. Você não irá mais possuir este item.", + "message": "1 item será transferido permanentemente para $ORG$. Você não irá mais ser o proprietário deste item.", "placeholders": { "org": { "content": "$1", @@ -5080,7 +5203,7 @@ } }, "personalItemsWithOrgTransferWarningPlural": { - "message": "Os itens $PERSONAL_ITEMS_COUNT$ serão transferidos permanentemente para $ORG$. Você não irá mais possuir esses itens.", + "message": "$PERSONAL_ITEMS_COUNT$ itens serão transferidos permanentemente para $ORG$. Você não irá mais ser o proprietário desses itens.", "placeholders": { "personal_items_count": { "content": "$1", @@ -5093,10 +5216,10 @@ } }, "successfullyAssignedCollections": { - "message": "Coleções atribuídas com sucesso" + "message": "Conjuntos atribuídos com sucesso" }, "nothingSelected": { - "message": "Você selecionou nada." + "message": "Você não selecionou nada." }, "itemsMovedToOrg": { "message": "Itens movidos para $ORGNAME$", @@ -5117,7 +5240,7 @@ } }, "reorderFieldDown": { - "message": "$LABEL$ se moveu para baixo, posição $INDEX$ de $LENGTH$", + "message": "$LABEL$ movido para baixo, posição $INDEX$ de $LENGTH$", "placeholders": { "label": { "content": "$1", @@ -5134,25 +5257,25 @@ } }, "itemLocation": { - "message": "Localização do Item" + "message": "Localização do item" }, "fileSends": { - "message": "Arquivos enviados" + "message": "Sends de arquivo" }, "textSends": { - "message": "Texto enviado" + "message": "Sends de texto" }, "accountActions": { "message": "Ações da conta" }, "showNumberOfAutofillSuggestions": { - "message": "Mostrar o número de sugestões de preenchimento automático de login no ícone da extensão" + "message": "Mostrar número de sugestões de preenchimento automático de credenciais no ícone da extensão" }, "accountAccessRequested": { - "message": "Account access requested" + "message": "Acesso à conta solicitado" }, "confirmAccessAttempt": { - "message": "Confirm access attempt for $EMAIL$", + "message": "Confirmar tentativa de acesso para $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -5161,7 +5284,7 @@ } }, "showQuickCopyActions": { - "message": "Mostrar a opção de cópia rápida no Cofre" + "message": "Mostrar opções de cópia rápida no Cofre" }, "systemDefault": { "message": "Padrão do sistema" @@ -5185,28 +5308,28 @@ "message": "ED25519" }, "sshKeyAlgorithmRSA2048": { - "message": "RSA 2048-Bit" + "message": "RSA de 2048 bits" }, "sshKeyAlgorithmRSA3072": { - "message": "RSA 3072-Bit" + "message": "RSA de 3072 bits" }, "sshKeyAlgorithmRSA4096": { - "message": "RSA 4096-Bit" + "message": "RSA de 4096 bits" }, "retry": { - "message": "Tente novamente" + "message": "Tentar novamente" }, "vaultCustomTimeoutMinimum": { "message": "Tempo limite mínimo personalizado é 1 minuto." }, "fileSavedToDevice": { - "message": "Arquivo salvo no dispositivo. Gerencie a partir das transferências do seu dispositivo." + "message": "Arquivo salvo no dispositivo. Gerencie a partir dos downloads do seu dispositivo." }, "showCharacterCount": { - "message": "Mostrar contagem de caracteres" + "message": "Mostrar número de caracteres" }, "hideCharacterCount": { - "message": "Esconder contagem de caracteres" + "message": "Ocultar número de caracteres" }, "itemsInTrash": { "message": "Itens na lixeira" @@ -5215,16 +5338,16 @@ "message": "Nenhum item na lixeira" }, "noItemsInTrashDesc": { - "message": "Os itens que você excluir aparecerão aqui e serão excluídos permanentemente após 30 dias" + "message": "Os itens que você apagar aparecerão aqui, e serão apagados para sempre depois de 30 dias" }, "trashWarning": { - "message": "Os itens que ficarem na lixeira por mais de 30 dias serão excluídos automaticamente" + "message": "Os itens que ficarem na lixeira por mais de 30 dias serão apagados para sempre" }, "restore": { "message": "Restaurar" }, "deleteForever": { - "message": "Apagar permanentemente" + "message": "Apagar para sempre" }, "noEditPermissions": { "message": "Você não tem permissão para editar este arquivo" @@ -5236,16 +5359,16 @@ "message": "O desbloqueio por biometria está indisponível no momento." }, "biometricsStatusHelptextAutoSetupNeeded": { - "message": "O desbloqueio por biometria está indisponível devido a algum erro na configuração dos arquivos de sistema." + "message": "O desbloqueio por biometria está indisponível devido a algum erro na configuração dos arquivos do sistema." }, "biometricsStatusHelptextManualSetupNeeded": { - "message": "O desbloqueio por biometria está indisponível devido a algum erro na configuração dos arquivos de sistema." + "message": "O desbloqueio por biometria está indisponível devido a algum erro na configuração dos arquivos do sistema." }, "biometricsStatusHelptextDesktopDisconnected": { - "message": "O desbloqueio por biometria está indisponível porque o aplicativo de desktop não está aberto." + "message": "O desbloqueio por biometria está indisponível porque o aplicativo de computador não está aberto." }, "biometricsStatusHelptextNotEnabledInDesktop": { - "message": "O desbloqueio por biometria está indisponível, pois a opção não foi ativada no aplicativo de desktop de $EMAIL$.", + "message": "O desbloqueio por biometria está indisponível, pois a opção não foi ativada no aplicativo de computador de $EMAIL$.", "placeholders": { "email": { "content": "$1", @@ -5257,30 +5380,30 @@ "message": "O desbloqueio por biometria está indisponível por razões desconhecidas." }, "unlockVault": { - "message": "Unlock your vault in seconds" + "message": "Desbloqueie seu cofre em segundos" }, "unlockVaultDesc": { - "message": "You can customize your unlock and timeout settings to more quickly access your vault." + "message": "Você pode personalizar suas configurações de desbloqueio e tempo limite para acessar seu cofre mais rapidamente." }, "unlockPinSet": { - "message": "Unlock PIN set" + "message": "PIN de desbloqueio configurado" }, "unlockWithBiometricSet": { - "message": "Unlock with biometrics set" + "message": "Desbloqueio com biometria configurado" }, "authenticating": { "message": "Autenticando" }, "fillGeneratedPassword": { - "message": "Preencher a senha gerada", + "message": "Preencher senha gerada", "description": "Heading for the password generator within the inline menu" }, "passwordRegenerated": { - "message": "Senha regenerada", + "message": "Senha regerada", "description": "Notification message for when a password has been regenerated" }, "saveToBitwarden": { - "message": "Save to Bitwarden", + "message": "Salvar no Bitwarden", "description": "Confirmation message for saving a login to Bitwarden" }, "spaceCharacterDescriptor": { @@ -5288,11 +5411,11 @@ "description": "Represents the space key in screen reader content as a readable word" }, "tildeCharacterDescriptor": { - "message": "Linear", + "message": "Til", "description": "Represents the ~ key in screen reader content as a readable word" }, "backtickCharacterDescriptor": { - "message": "Bastão", + "message": "Grave", "description": "Represents the ` key in screen reader content as a readable word" }, "exclamationCharacterDescriptor": { @@ -5308,7 +5431,7 @@ "description": "Represents the # key in screen reader content as a readable word" }, "dollarSignCharacterDescriptor": { - "message": "cifrão", + "message": "Cifrão", "description": "Represents the $ key in screen reader content as a readable word" }, "percentSignCharacterDescriptor": { @@ -5320,19 +5443,19 @@ "description": "Represents the ^ key in screen reader content as a readable word" }, "ampersandCharacterDescriptor": { - "message": "E Comercial", + "message": "E", "description": "Represents the & key in screen reader content as a readable word" }, "asteriskCharacterDescriptor": { - "message": "Asterísco", + "message": "Asterisco", "description": "Represents the * key in screen reader content as a readable word" }, "parenLeftCharacterDescriptor": { - "message": "Parêntese esquerdo", + "message": "Abre parênteses", "description": "Represents the ( key in screen reader content as a readable word" }, "parenRightCharacterDescriptor": { - "message": "Parênteses direito", + "message": "Fecha parênteses", "description": "Represents the ) key in screen reader content as a readable word" }, "hyphenCharacterDescriptor": { @@ -5348,7 +5471,7 @@ "description": "Represents the + key in screen reader content as a readable word" }, "equalsCharacterDescriptor": { - "message": "iguais", + "message": "Igual", "description": "Represents the = key in screen reader content as a readable word" }, "braceLeftCharacterDescriptor": { @@ -5368,15 +5491,15 @@ "description": "Represents the ] key in screen reader content as a readable word" }, "pipeCharacterDescriptor": { - "message": "Pipe", + "message": "Barra vertical", "description": "Represents the | key in screen reader content as a readable word" }, "backSlashCharacterDescriptor": { - "message": "Barra Invertida", + "message": "Barra invertida", "description": "Represents the back slash key in screen reader content as a readable word" }, "colonCharacterDescriptor": { - "message": "Dois Pontos", + "message": "Dois pontos", "description": "Represents the : key in screen reader content as a readable word" }, "semicolonCharacterDescriptor": { @@ -5431,28 +5554,28 @@ "message": "Beta" }, "extensionWidth": { - "message": "Largura da janela" + "message": "Largura da extensão" }, "wide": { - "message": "Grande" + "message": "Larga" }, "extraWide": { - "message": "Extra Grande" + "message": "Extra larga" }, "sshKeyWrongPassword": { - "message": "A senha está incorreta." + "message": "A senha que você digitou está incorreta." }, "importSshKey": { "message": "Importar" }, "confirmSshKeyPassword": { - "message": "Confirme a senha" + "message": "Confirmar senha" }, "enterSshKeyPasswordDesc": { - "message": "Insira a senha da chave SSH." + "message": "Digite a senha da chave SSH." }, "enterSshKeyPassword": { - "message": "Insira a senha" + "message": "Digitar senha" }, "invalidSshKey": { "message": "A chave SSH é inválida" @@ -5467,7 +5590,7 @@ "message": "Chave SSH importada com sucesso" }, "cannotRemoveViewOnlyCollections": { - "message": "Você não pode remover coleções com permissões de Somente leitura: $COLLECTIONS$", + "message": "Você não pode remover conjuntos com permissões de Apenas ver: $COLLECTIONS$", "placeholders": { "collections": { "content": "$1", @@ -5476,43 +5599,43 @@ } }, "updateDesktopAppOrDisableFingerprintDialogTitle": { - "message": "Atualize seu aplicativo de desktop" + "message": "Atualize seu aplicativo de computador" }, "updateDesktopAppOrDisableFingerprintDialogMessage": { - "message": "Para usar o desbloqueio de biometria, atualize seu aplicativo de desktop ou desative a opção \"desbloqueio por biometria\"." + "message": "Para usar o desbloqueio biométrico, atualize seu aplicativo de computador ou desative o desbloqueio biométrico nas configurações do desktop." }, "changeAtRiskPassword": { - "message": "Alterar senhas vulneráveis" + "message": "Alterar senhas em risco" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "message": "Esta credencial está em risco e está sem um site. Adicione um site e altere a senha para segurança melhor." }, "missingWebsite": { - "message": "Missing website" + "message": "Site ausente" }, "settingsVaultOptions": { "message": "Opções do cofre" }, "emptyVaultDescription": { - "message": "The vault protects more than just your passwords. Store secure logins, IDs, cards and notes securely here." + "message": "O cofre protege mais do que só suas senhas. Armazene credenciais, identidades, cartões, e anotações com segurança aqui." }, "introCarouselLabel": { - "message": "Bem-vindo(a) ao Bitwarden" + "message": "Boas-vindas ao Bitwarden" }, "securityPrioritized": { - "message": "Segurança em primeiro lugar" + "message": "Segurança, priorizada" }, "securityPrioritizedBody": { - "message": "Guarde suas senhas, cartões e documentos no seu cofre seguro. Bitwarden usa criptografia ponta-a-ponta de zero-knowledge, protegendo o que é valioso para você." + "message": "Guarde suas senhas, cartões e documentos no seu cofre seguro. Bitwarden usa criptografia de ponta a ponta de zero conhecimento, protegendo o que é valioso para você." }, "quickLogin": { - "message": "Login rápido e fácil" + "message": "Autenticação rápida e fácil" }, "quickLoginBody": { - "message": "Ative o desbloqueio por biometria e o preenchimento automático para acessar suas contas sem digitar uma única letra." + "message": "Configure o desbloqueio biométrico e o preenchimento automático para acessar suas contas sem digitar uma única letra." }, "secureUser": { - "message": "Melhore seus logins de nível" + "message": "Dê um up nas suas credenciais" }, "secureUserBody": { "message": "Use o gerador de senhas para criar e salvar senhas fortes e únicas para todas as suas contas." @@ -5524,134 +5647,209 @@ "message": "Guarde quantas senhas quiser e acesse de qualquer lugar com o Bitwarden. No seu celular, navegador e computador." }, "nudgeBadgeAria": { - "message": "1 notification" + "message": "1 notificação" }, "emptyVaultNudgeTitle": { - "message": "Import existing passwords" + "message": "Importar senhas existentes" }, "emptyVaultNudgeBody": { - "message": "Use the importer to quickly transfer logins to Bitwarden without manually adding them." + "message": "Use o importador para transferir rapidamente as credenciais para o Bitwarden sem adicioná-las manualmente." }, "emptyVaultNudgeButton": { - "message": "Import now" + "message": "Importar agora" }, "hasItemsVaultNudgeTitle": { - "message": "Welcome to your vault!" + "message": "Boas-vindas ao seu cofre!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Tentativa de phishing detectada" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "O site que você está tentando visitar é um site malicioso conhecido e um risco à segurança." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Fechar esta aba" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continuar para este site (não recomendado)" + }, + "phishingPageExplanation1": { + "message": "Este site foi encontrado em ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", uma lista de código aberto de sites de phishing conhecidos usados por roubar informações pessoais e sensíveis.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Saiba mais sobre a detecção de phishing" + }, + "protectedBy": { + "message": "Protegido pelo $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { - "message": "Autofill items for the current page" + "message": "Preenche automaticamente itens para a página atual" }, "hasItemsVaultNudgeBodyTwo": { - "message": "Favorite items for easy access" + "message": "Favorite itens para acesso rápido" }, "hasItemsVaultNudgeBodyThree": { - "message": "Search your vault for something else" + "message": "Busque no cofre por outra coisa" }, "newLoginNudgeTitle": { - "message": "Seja mais rápido com o preenchimento automático" + "message": "Economize tempo com o preenchimento automático" }, "newLoginNudgeBodyOne": { - "message": "Include a", + "message": "Inclua um", "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." }, "newLoginNudgeBodyBold": { - "message": "Website", + "message": "Site", "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." }, "newLoginNudgeBodyTwo": { - "message": "so this login appears as an autofill suggestion.", + "message": "para que esta credencial apareça como uma sugestão de preenchimento automático.", "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." }, "newCardNudgeTitle": { - "message": "Seamless online checkout" + "message": "Pagamento on-line simplificado" }, "newCardNudgeBody": { "message": "Preencha automaticamente formulários de pagamento com cartões de forma segura e precisa." }, "newIdentityNudgeTitle": { - "message": "Simplify creating accounts" + "message": "Simplifique a criação de contas" }, "newIdentityNudgeBody": { - "message": "Preencha automaticamente formulários longos de registro ou contato de forma rápida." + "message": "Com identidades, preencha formulários de cadastro ou contato longos rapidamente, de forma automática." }, "newNoteNudgeTitle": { - "message": "Keep your sensitive data safe" + "message": "Mantenha seus dados sensíveis seguros" }, "newNoteNudgeBody": { - "message": "With notes, securely store sensitive data like banking or insurance details." + "message": "Com anotações, armazene com segurança dados sensíveis como detalhes de informações bancárias ou de seguro." }, "newSshNudgeTitle": { - "message": "Developer-friendly SSH access" + "message": "Acesso SSH amigável para desenvolvedores" }, "newSshNudgeBodyOne": { - "message": "Store your keys and connect with the SSH agent for fast, encrypted authentication.", + "message": "Armazene suas chaves e conecte com o agente SSH para uma autenticação rápida e criptografada.", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "newSshNudgeBodyTwo": { - "message": "Learn more about SSH agent", + "message": "Saiba mais sobre o agente SSH", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "generatorNudgeTitle": { - "message": "Quickly create passwords" + "message": "Crie senhas de forma rápida" }, "generatorNudgeBodyOne": { - "message": "Easily create strong and unique passwords by clicking on", + "message": "Crie senhas únicas e fortes com facilidade clicando em", "description": "Two part message", "example": "Easily create strong and unique passwords by clicking on {icon} to help you keep your logins secure." }, "generatorNudgeBodyTwo": { - "message": "to help you keep your logins secure.", + "message": "para ajudá-lo a manter suas credenciais seguras.", "description": "Two part message", "example": "Easily create strong and unique passwords by clicking on {icon} to help you keep your logins secure." }, "generatorNudgeBodyAria": { - "message": "Easily create strong and unique passwords by clicking on the Generate password button to help you keep your logins secure.", + "message": "Crie senhas únicas e fortes com facilidade clicando no botão de gerar senha para ajudá-lo a manter suas credenciais seguras.", "description": "Aria label for the body content of the generator nudge" }, "aboutThisSetting": { - "message": "About this setting" + "message": "Sobre esta configuração" }, "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." + "message": "O Bitwarden usará URIs de credenciais salvas para identificar qual ícone ou URL de alteração de senha deverá ser usado para melhorar sua experiência. Nenhuma informação é coletada ou salva quando você utiliza este serviço." }, "noPermissionsViewPage": { - "message": "You do not have permissions to view this page. Try logging in with a different account." + "message": "Você não tem permissão para ver esta página. Tente se conectar com uma conta diferente." }, "wasmNotSupported": { - "message": "WebAssembly is not supported on your browser or is not enabled. WebAssembly is required to use the Bitwarden app.", + "message": "O WebAssembly não é suportado no seu navegador ou não está ativado. O WebAssembly é necessário para utilizar o aplicativo do Bitwarden.", "description": "'WebAssembly' is a technical term and should not be translated." }, "showMore": { - "message": "Show more" + "message": "Mostrar mais" }, "showLess": { - "message": "Show less" + "message": "Mostrar menos" }, "next": { - "message": "Next" + "message": "Avançar" }, "moreBreadcrumbs": { - "message": "More breadcrumbs", + "message": "Mais trilhas", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." }, "confirmKeyConnectorDomain": { - "message": "Confirm Key Connector domain" + "message": "Confirmar domínio do Key Connector" + }, + "atRiskLoginsSecured": { + "message": "Ótimo trabalho protegendo suas credenciais em risco!" + }, + "upgradeNow": { + "message": "Faça upgrade agora" + }, + "builtInAuthenticator": { + "message": "Autenticador integrado" + }, + "secureFileStorage": { + "message": "Armazenamento seguro de arquivos" + }, + "emergencyAccess": { + "message": "Acesso de emergência" + }, + "breachMonitoring": { + "message": "Monitoramento de vazamentos" + }, + "andMoreFeatures": { + "message": "E mais!" + }, + "planDescPremium": { + "message": "Segurança on-line completa" + }, + "upgradeToPremium": { + "message": "Faça upgrade para o Premium" + }, + "unlockAdvancedSecurity": { + "message": "Desbloqueie recursos avançados de segurança" + }, + "unlockAdvancedSecurityDesc": { + "message": "Um plano Premium te dá mais ferramentas para se manter seguro e em controle" + }, + "explorePremium": { + "message": "Explorar o Premium" + }, + "loadingVault": { + "message": "Carregando cofre" + }, + "vaultLoaded": { + "message": "Cofre carregado" + }, + "settingDisabledByPolicy": { + "message": "Essa configuração está desativada pela política da sua organização.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "CEP / Código postal" + }, + "cardNumberLabel": { + "message": "Número do cartão" + }, + "sessionTimeoutSettingsAction": { + "message": "Ação do tempo limite" } } diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index acc5b5332f9..6b8568ddb1b 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Utilizar início de sessão único" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "A sua organização exige o início de sessão único." + }, "welcomeBack": { "message": "Bem-vindo de volta" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Repor pesquisa" }, - "archive": { - "message": "Arquivar" + "archiveNoun": { + "message": "Arquivo", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Arquivar", + "description": "Verb" + }, + "unArchive": { "message": "Desarquivar" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Os itens arquivados aparecerão aqui e serão excluídos dos resultados gerais da pesquisa e das sugestões de preenchimento automático." }, - "itemSentToArchive": { - "message": "Item movido para o arquivo" + "itemWasSentToArchive": { + "message": "O item foi movido para o arquivo" }, - "itemRemovedFromArchive": { - "message": "Item removido do arquivo" + "itemUnarchived": { + "message": "O item foi desarquivado" }, "archiveItem": { "message": "Arquivar item" @@ -577,12 +585,24 @@ "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?" }, + "upgradeToUseArchive": { + "message": "É necessária uma subscrição Premium para utilizar o Arquivo." + }, "edit": { "message": "Editar" }, "view": { "message": "Ver" }, + "viewAll": { + "message": "Ver tudo" + }, + "showAll": { + "message": "Mostrar tudo" + }, + "viewLess": { + "message": "Ver menos" + }, "viewLogin": { "message": "Ver credencial" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Palavra-passe mestra inválida" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Palavra-passe mestra inválida. Confirme se o seu e-mail está correto e se a sua conta foi criada em $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Tempo limite do cofre" }, @@ -774,7 +803,13 @@ "message": "4 horas" }, "onLocked": { - "message": "No bloqueio do sistema" + "message": "Ao bloquear o sistema" + }, + "onIdle": { + "message": "Na inatividade do sistema" + }, + "onSleep": { + "message": "Na suspensão do sistema" }, "onRestart": { "message": "Ao reiniciar o navegador" @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Item guardado" }, + "savedWebsite": { + "message": "Site guardado" + }, + "savedWebsites": { + "message": "Sites guardados ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Tem a certeza de que pretende eliminar este item?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Pedir biometria ao iniciar" }, - "premiumRequired": { - "message": "É necessária uma subscrição Premium" - }, - "premiumRequiredDesc": { - "message": "É necessária uma subscrição Premium para utilizar esta funcionalidade." - }, "authenticationTimeout": { "message": "Tempo limite de autenticação" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Ler chave de segurança" }, + "readingPasskeyLoading": { + "message": "A ler chave de acesso..." + }, + "passkeyAuthenticationFailed": { + "message": "Falha na autenticação da chave de acesso" + }, + "useADifferentLogInMethod": { + "message": "Utilizar um método de início de sessão diferente" + }, "awaitingSecurityKeyInteraction": { "message": "A aguardar interação da chave de segurança..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Deve adicionar o URL do servidor de base ou pelo menos um ambiente personalizado." }, + "selfHostedEnvMustUseHttps": { + "message": "Os URLs devem usar HTTPS." + }, "customEnvironment": { "message": "Ambiente personalizado" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Desativar o preenchimento automático" }, + "confirmAutofill": { + "message": "Confirmar preenchimento automático" + }, + "confirmAutofillDesc": { + "message": "Este site não corresponde às suas credenciais guardadas. Antes de preencher as suas credenciais, certifique-se de que se trata de um site fiável." + }, "showInlineMenuLabel": { "message": "Mostrar sugestões de preenchimento automático nos campos do formulário" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Como o Bitwarden protege os seus dados contra phishing?" + }, + "currentWebsite": { + "message": "Site atual" + }, + "autofillAndAddWebsite": { + "message": "Preencher automaticamente e adicionar este site" + }, + "autofillWithoutAdding": { + "message": "Preencher automaticamente sem adicionar" + }, + "doNotAutofill": { + "message": "Não preencher automaticamente" + }, "showInlineMenuIdentitiesLabel": { "message": "Apresentar as identidades como sugestões" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Ano de validade" }, + "monthly": { + "message": "mês" + }, "expiration": { "message": "Prazo de validade" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Esta página está a interferir com a experiência do Bitwarden. O menu em linha do Bitwarden foi temporariamente desativado como medida de segurança." + }, "setMasterPassword": { "message": "Definir a palavra-passe mestra" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Apenas o cofre da organização associado a $ORGANIZATION$ será exportado.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Apenas o cofre da organização associado a $ORGANIZATION$ será exportado. As coleções dos meus itens não serão incluídas.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Erro" }, "decryptionError": { "message": "Erro de desencriptação" }, + "errorGettingAutoFillData": { + "message": "Erro ao obter dados de preenchimento automático" + }, "couldNotDecryptVaultItemsBelow": { "message": "O Bitwarden não conseguiu desencriptar o(s) item(ns) do cofre listado(s) abaixo." }, @@ -3970,6 +4071,15 @@ "message": "Preencher automaticamente ao carregar a página definido para utilizar a predefinição.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Não é possível preencher automaticamente" + }, + "cannotAutofillExactMatch": { + "message": "A correspondência padrão está definida como \"Correspondência exata\". O site atual não corresponde exatamente às credenciais guardadas para este item." + }, + "okay": { + "message": "OK" + }, "toggleSideNavigation": { "message": "Ativar/desativar navegação lateral" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Desbloqueie relatórios, acesso de emergência e outras funcionalidades de segurança com o Premium." + }, "freeOrgsCannotUseAttachments": { "message": "As organizações gratuitas não podem utilizar anexos" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Predefinido ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Mostrar deteção de correspondência para $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Bem-vindo ao seu cofre!" }, - "phishingPageTitle": { - "message": "Site de phishing" + "phishingPageTitleV2": { + "message": "Tentativa de phishing detetada" }, - "phishingPageCloseTab": { - "message": "Fechar separador" + "phishingPageSummary": { + "message": "O site que está a tentar visitar é um site malicioso conhecido e um risco de segurança." }, - "phishingPageContinue": { - "message": "Continuar" + "phishingPageCloseTabV2": { + "message": "Fechar este separador" }, - "phishingPageLearnWhy": { - "message": "Porque é que está a ver isto?" + "phishingPageContinueV2": { + "message": "Continuar para este site (não recomendado)" + }, + "phishingPageExplanation1": { + "message": "Este site foi encontrado em ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", uma lista de fonte aberta de sites de phishing conhecidos, utilizados para roubar informações pessoais e sensíveis.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Saiba mais sobre a deteção de phishing" + }, + "protectedBy": { + "message": "Protegido por $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Preenchimento automático de itens para a página atual" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirmar o domínio do Key Connector" + }, + "atRiskLoginsSecured": { + "message": "Excelente trabalho ao proteger as suas credenciais em risco!" + }, + "upgradeNow": { + "message": "Atualizar agora" + }, + "builtInAuthenticator": { + "message": "Autenticador incorporado" + }, + "secureFileStorage": { + "message": "Armazenamento seguro de ficheiros" + }, + "emergencyAccess": { + "message": "Acesso de emergência" + }, + "breachMonitoring": { + "message": "Monitorização de violações" + }, + "andMoreFeatures": { + "message": "E muito mais!" + }, + "planDescPremium": { + "message": "Segurança total online" + }, + "upgradeToPremium": { + "message": "Atualizar para o Premium" + }, + "unlockAdvancedSecurity": { + "message": "Desbloqueie funcionalidades de segurança avançadas" + }, + "unlockAdvancedSecurityDesc": { + "message": "Uma subscrição Premium dá-lhe ferramentas adicionais para reforçar a sua segurança e manter o controlo" + }, + "explorePremium": { + "message": "Explorar o Premium" + }, + "loadingVault": { + "message": "A carregar o cofre" + }, + "vaultLoaded": { + "message": "Cofre carregado" + }, + "settingDisabledByPolicy": { + "message": "Esta configuração está desativada pela política da sua organização.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Código postal" + }, + "cardNumberLabel": { + "message": "Número do cartão" + }, + "sessionTimeoutSettingsAction": { + "message": "Ação de tempo limite" } } diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index d184460e293..db7a1b8c657 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Autentificare unică" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Bine ați revenit" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Editare" }, "view": { "message": "Afișare" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Parolă principală incorectă" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Expirare seif" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "La blocarea sistemului" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "La repornirea browserului" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Articol salvat" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Sigur doriți să trimiteți în coșul de reciclare?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Solicitați date biometrice la pornire" }, - "premiumRequired": { - "message": "Premium necesar" - }, - "premiumRequiredDesc": { - "message": "Pentru a utiliza această funcție este necesar un abonament Premium." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Mediu personalizat" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Anul expirării" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expirare" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Setare parolă principală" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Eroare" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Completarea automată la încărcarea paginii este setată la valoarea implicită.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 17133350e3f..0535027daa9 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Использовать единый вход" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Ваша организация требует единого входа." + }, "welcomeBack": { "message": "С возвращением" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Сбросить поиск" }, - "archive": { - "message": "Архив" + "archiveNoun": { + "message": "Архив", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Архивировать", + "description": "Verb" + }, + "unArchive": { "message": "Разархивировать" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Архивированные элементы появятся здесь и будут исключены из общих результатов поиска и предложений автозаполнения." }, - "itemSentToArchive": { - "message": "Элемент отправлен в архив" + "itemWasSentToArchive": { + "message": "Элемент был отправлен в архив" }, - "itemRemovedFromArchive": { - "message": "Элемент удален из архива" + "itemUnarchived": { + "message": "Элемент был разархивирован" }, "archiveItem": { "message": "Архивировать элемент" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Архивированные элементы исключены из общих результатов поиска и предложений автозаполнения. Вы уверены, что хотите архивировать этот элемент?" }, + "upgradeToUseArchive": { + "message": "Для использования архива требуется премиум-статус." + }, "edit": { "message": "Изменить" }, "view": { "message": "Просмотр" }, + "viewAll": { + "message": "Посмотреть все" + }, + "showAll": { + "message": "Показать все" + }, + "viewLess": { + "message": "Свернуть" + }, "viewLogin": { "message": "Просмотр логина" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Неверный мастер-пароль" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Неверный мастер-пароль. Подтвердите, что ваш адрес email указан верно и ваш аккаунт был создан на $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Тайм-аут хранилища" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Вместе с компьютером" }, + "onIdle": { + "message": "При бездействии" + }, + "onSleep": { + "message": "В режиме сна" + }, "onRestart": { "message": "При перезапуске браузера" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Элемент сохранен" }, + "savedWebsite": { + "message": "Сохраненный сайт" + }, + "savedWebsites": { + "message": "Сохраненные сайты ( $COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Вы действительно хотите отправить в корзину?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Запрашивать биометрию при запуске" }, - "premiumRequired": { - "message": "Требуется Премиум" - }, - "premiumRequiredDesc": { - "message": "Для использования этой функции необходим Премиум." - }, "authenticationTimeout": { "message": "Таймаут аутентификации" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Считать ключ безопасности" }, + "readingPasskeyLoading": { + "message": "Чтение passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Не удалось выполнить аутентификацию с помощью passkey" + }, + "useADifferentLogInMethod": { + "message": "Использовать другой способ авторизации" + }, "awaitingSecurityKeyInteraction": { "message": "Ожидание взаимодействия с ключом безопасности..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Вы должны добавить либо базовый URL сервера, либо хотя бы одно пользовательское окружение." }, + "selfHostedEnvMustUseHttps": { + "message": "URL должны использовать HTTPS." + }, "customEnvironment": { "message": "Пользовательское окружение" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Отключить автозаполнение" }, + "confirmAutofill": { + "message": "Подтвердите автозаполнение" + }, + "confirmAutofillDesc": { + "message": "Этот сайт не соответствует вашим сохраненным логинам. Прежде чем заполнять логин, убедитесь, что это надежный сайт." + }, "showInlineMenuLabel": { "message": "Показывать предположения автозаполнения в полях формы" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Как Bitwarden защищает ваши данные от фишинга?" + }, + "currentWebsite": { + "message": "Текущий сайт" + }, + "autofillAndAddWebsite": { + "message": "Заполнить и добавить этот сайт" + }, + "autofillWithoutAdding": { + "message": "Заполнить без добавления" + }, + "doNotAutofill": { + "message": "Не заполнять" + }, "showInlineMenuIdentitiesLabel": { "message": "Показывать Личную информацию как предложения" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Год" }, + "monthly": { + "message": "месяц" + }, "expiration": { "message": "Срок действия" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Эта страница мешает работе Bitwarden. Встроенное меню Bitwarden было временно отключено в целях безопасности." + }, "setMasterPassword": { "message": "Задать мастер-пароль" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Будет экспортировано только хранилище организации, связанное с $ORGANIZATION$.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Будет экспортировано только хранилище организации, связанное с $ORGANIZATION$. Коллекции Мои элементы включены не будут.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Ошибка" }, "decryptionError": { "message": "Ошибка расшифровки" }, + "errorGettingAutoFillData": { + "message": "Ошибка получения данных автозаполнения" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden не удалось расшифровать элемент(ы) хранилища, перечисленные ниже." }, @@ -3970,6 +4071,15 @@ "message": "Автозаполнение при загрузке страницы использует настройку по умолчанию.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Не удалось заполнить" + }, + "cannotAutofillExactMatch": { + "message": "По умолчанию установлено значение 'Точное соответствие'. Текущий сайт не полностью соответствует сохраненным для этого элемента логинам." + }, + "okay": { + "message": "OK" + }, "toggleSideNavigation": { "message": "Переключить боковую навигацию" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Премиум" }, + "unlockFeaturesWithPremium": { + "message": "Разблокируйте отчеты, экстренный доступ и другие функции безопасности с помощью Премиум." + }, "freeOrgsCannotUseAttachments": { "message": "Бесплатные организации не могут использовать вложения" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "По умолчанию ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Показать обнаружение совпадений $WEBSITE$", "placeholders": { @@ -5485,10 +5608,10 @@ "message": "Изменить пароль, подверженный риску" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "message": "Этот логин подвержен риску и у него отсутствует веб-сайт. Добавьте веб-сайт и смените пароль для большей безопасности." }, "missingWebsite": { - "message": "Missing website" + "message": "Отсутствует сайт" }, "settingsVaultOptions": { "message": "Настройки хранилища" @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Добро пожаловать в ваше хранилище!" }, - "phishingPageTitle": { - "message": "Фишинг-сайт" + "phishingPageTitleV2": { + "message": "Обнаружена попытка фишинга" }, - "phishingPageCloseTab": { - "message": "Закрыть вкладку" + "phishingPageSummary": { + "message": "Сайт, который вы пытаетесь посетить, является заведомо вредоносным сайтом и представляет угрозу безопасности." }, - "phishingPageContinue": { - "message": "Продолжить" + "phishingPageCloseTabV2": { + "message": "Закрыть эту вкладку" }, - "phishingPageLearnWhy": { - "message": "Почему вы это видите?" + "phishingPageContinueV2": { + "message": "Перейти на этот сайт (не рекомендуется)" + }, + "phishingPageExplanation1": { + "message": "Этот сайт был найден в ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", списке известных фишинговых сайтов, используемых для кражи персональной и конфиденциальной информации.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Узнайте больше об обнаружении фишинга" + }, + "protectedBy": { + "message": "Защищено $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Автозаполнение элементов на текущей странице" @@ -5648,10 +5791,65 @@ "message": "Далее" }, "moreBreadcrumbs": { - "message": "More breadcrumbs", + "message": "Дополнительная навигация", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." }, "confirmKeyConnectorDomain": { "message": "Подтвердите домен соединителя ключей" + }, + "atRiskLoginsSecured": { + "message": "Отличная работа по защите ваших логинов, подверженных риску!" + }, + "upgradeNow": { + "message": "Изменить сейчас" + }, + "builtInAuthenticator": { + "message": "Встроенный аутентификатор" + }, + "secureFileStorage": { + "message": "Защищенное хранилище файлов" + }, + "emergencyAccess": { + "message": "Экстренный доступ" + }, + "breachMonitoring": { + "message": "Мониторинг нарушений" + }, + "andMoreFeatures": { + "message": "И многое другое!" + }, + "planDescPremium": { + "message": "Полная онлайн-защищенность" + }, + "upgradeToPremium": { + "message": "Обновить до Премиум" + }, + "unlockAdvancedSecurity": { + "message": "Разблокировать дополнительные функции безопасности" + }, + "unlockAdvancedSecurityDesc": { + "message": "Премиум-подписка дает вам больше возможностей для обеспечения безопасности и контроля" + }, + "explorePremium": { + "message": "Познакомиться с Премиум" + }, + "loadingVault": { + "message": "Загрузка хранилища" + }, + "vaultLoaded": { + "message": "Хранилище загружено" + }, + "settingDisabledByPolicy": { + "message": "Этот параметр отключен политикой вашей организации.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Почтовый индекс" + }, + "cardNumberLabel": { + "message": "Номер карты" + }, + "sessionTimeoutSettingsAction": { + "message": "Тайм-аут действия" } } diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index 2fd8f53e148..bb46b283322 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "සංස්කරණය" }, "view": { "message": "දකින්න" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "වලංගු නොවන ප්රධාන මුරපදය" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "සුරක්ෂිතාගාරය වේලාව" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "පද්ධතිය ලොක් මත" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "බ්රව්සරය නැවත ආරම්භ" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "සංස්කරණය කරන ලද අයිතමය" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "ඔබට ඇත්තටම කුණු කූඩයට යැවීමට අවශ්යද?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "වාරික අවශ්ය" - }, - "premiumRequiredDesc": { - "message": "මෙම අංගය භාවිතා කිරීම සඳහා වාරික සාමාජිකත්වයක් අවශ්ය වේ." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "අභිරුචි පරිසරය" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "කල් ඉකුත්වන වසර" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "කල් ඉකුත්" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "මාස්ටර් මුරපදය සකසන්න" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index d0e143cce4a..283442d95da 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -6,7 +6,7 @@ "message": "Logo Bitwarden" }, "extName": { - "message": "Bitwarden – Bezplatný správca hesiel", + "message": "Bitwarden – správca hesiel", "description": "Extension name, MUST be less than 40 characters (Safari restriction)" }, "extDesc": { @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Použiť jednotné prihlásenie" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Vaša organizácia vyžaduje jednotné prihlasovanie." + }, "welcomeBack": { "message": "Vitajte späť" }, @@ -53,7 +56,7 @@ "message": "Potvrdiť" }, "emailAddress": { - "message": "Emailová adresa" + "message": "E-mailová adresa" }, "masterPass": { "message": "Hlavné heslo" @@ -550,10 +553,15 @@ "resetSearch": { "message": "Resetovať vyhľadávanie" }, - "archive": { - "message": "Archivovať" + "archiveNoun": { + "message": "Archív", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archivovať", + "description": "Verb" + }, + "unArchive": { "message": "Zrušiť archiváciu" }, "itemsInArchive": { @@ -565,10 +573,10 @@ "noItemsInArchiveDesc": { "message": "Tu sa zobrazia archivované položky, ktoré budú vylúčené zo všeobecného vyhľadávania a z návrhov automatického vypĺňania." }, - "itemSentToArchive": { + "itemWasSentToArchive": { "message": "Položka bola archivovaná" }, - "itemRemovedFromArchive": { + "itemUnarchived": { "message": "Položka bola odobraná z archívu" }, "archiveItem": { @@ -577,12 +585,24 @@ "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?" }, + "upgradeToUseArchive": { + "message": "Na použitie archívu je potrebné prémiové členstvo." + }, "edit": { "message": "Upraviť" }, "view": { "message": "Zobraziť" }, + "viewAll": { + "message": "Zobraziť všetky" + }, + "showAll": { + "message": "Zobraziť všetko" + }, + "viewLess": { + "message": "Zobraziť menej" + }, "viewLogin": { "message": "Zobraziť prihlásenie" }, @@ -671,7 +691,7 @@ "message": "Nastavte metódu odomknutia, aby ste zmenili akciu pri vypršaní času trezoru." }, "unlockMethodNeeded": { - "message": "Nastavte metódu odomknutia v Nastaveniach" + "message": "Nastavte metódu odomknutia v nastaveniach" }, "sessionTimeoutHeader": { "message": "Časový limit relácie" @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Neplatné hlavné heslo" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Neplatné hlavné heslo. Potvrďte, že váš e-mail je správny a účet bol vytvorený na $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Časový limit pre trezor" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Keď je systém uzamknutý" }, + "onIdle": { + "message": "Pri nečinnosti systému" + }, + "onSleep": { + "message": "V režime spánku" + }, "onRestart": { "message": "Po reštarte prehliadača" }, @@ -863,7 +898,7 @@ } }, "autofillError": { - "message": "Na tejto stránke sa nedajú automaticky vyplniť prihlasovacie údaje. Namiesto toho skopírujte/vložte prihlasovacie údaje manuálne." + "message": "Nie je možné automaticky vyplniť vybranú položku na tejto stránke. Namiesto toho skopírujte a vložte prihlasovacie údaje." }, "totpCaptureError": { "message": "Nie je možné naskenovať QR kód z aktuálnej webovej stránky" @@ -953,10 +988,10 @@ "message": "Meno je povinné." }, "addedFolder": { - "message": "Pridaný priečinok" + "message": "Priečinok bol pridaný" }, "twoStepLoginConfirmation": { - "message": "Dvojstupňové prihlasovanie robí váš účet bezpečnejším vďaka vyžadovaniu bezpečnostného kódu z overovacej aplikácie vždy, keď sa prihlásite. Dvojstupňové prihlasovanie môžete povoliť vo webovom trezore bitwarden.com. Chcete navštíviť túto stránku teraz?" + "message": "Dvojstupňové prihlásenie zvyšuje bezpečnosť vášho účtu tým, že vyžaduje overenie prihlásenia pomocou iného zariadenia, napríklad bezpečnostného kľúča, overovacej aplikácie, SMS, telefonického hovoru alebo e-mailu. Dvojstupňové prihlásenie môžete nastaviť na bitwarden.com. Chcete stránku navštíviť teraz?" }, "twoStepLoginConfirmationContent": { "message": "Zabezpečte svoj účet nastavením dvojstupňového prihlasovania vo webovej aplikácii Bitwarden." @@ -965,22 +1000,22 @@ "message": "Pokračovať vo webovej aplikácii?" }, "editedFolder": { - "message": "Priečinok upravený" + "message": "Priečinok bol upravený" }, "deleteFolderConfirmation": { "message": "Naozaj chcete odstrániť tento priečinok?" }, "deletedFolder": { - "message": "Odstránený priečinok" + "message": "Priečinok bol odstránený" }, "gettingStartedTutorial": { - "message": "Začiatočnícka príručka" + "message": "Úvodná príručka" }, "gettingStartedTutorialVideo": { "message": "Pozrite našu príručku pre začiatočníkov, v ktorej sa dozviete, ako získať maximum z nášho rozšírenia pre prehliadač." }, "syncingComplete": { - "message": "Synchronizácia kompletná" + "message": "Synchronizácia bola dokončená" }, "syncingFailed": { "message": "Synchronizácia zlyhala" @@ -1014,11 +1049,23 @@ "editedItem": { "message": "Položka upravená" }, + "savedWebsite": { + "message": "Uložená webstránka" + }, + "savedWebsites": { + "message": "Uložené webstránky ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Naozaj chcete odstrániť túto položku?" }, "deletedItem": { - "message": "Položka odstránená" + "message": "Položka bola presunutá do koša" }, "overwritePassword": { "message": "Prepísať heslo" @@ -1246,7 +1293,7 @@ "message": "Zobraziť možnosti kontextovej ponuky" }, "contextMenuItemDesc": { - "message": "Sekundárnym kliknutím získate prístup k vygenerovaniu hesiel a zodpovedajúcim prihláseniam pre webovú stránku. " + "message": "Kliknutím pravého tlačidla myši získate prístup k vygenerovaniu hesiel a zodpovedajúcim prihláseniam pre webovú stránku." }, "contextMenuItemDescAlt": { "message": "Sekundárnym kliknutím získate prístup k vygenerovaniu hesiel a zodpovedajúcim prihláseniam pre webovú stránku. Platí pre všetky prihlásené účty." @@ -1282,7 +1329,7 @@ "message": "Export trezoru" }, "fileFormat": { - "message": "Formát Súboru" + "message": "Formát súboru" }, "fileEncryptedExportWarningDesc": { "message": "Tento exportovaný súbor bude chránený heslom a na dešifrovanie bude potrebné heslo súboru." @@ -1360,7 +1407,7 @@ "message": "Zistiť viac" }, "authenticatorKeyTotp": { - "message": "Kľúč overovateľa (TOTP)" + "message": "Overovací kľúč (TOTP)" }, "verificationCodeTotp": { "message": "Overovací kód (TOTP)" @@ -1378,7 +1425,7 @@ "message": "Naozaj chcete odstrániť túto prílohu?" }, "deletedAttachment": { - "message": "Odstránená príloha" + "message": "Príloha bola odstránená" }, "newAttachment": { "message": "Pridať novú prílohu" @@ -1387,7 +1434,7 @@ "message": "Žiadne prílohy." }, "attachmentSaved": { - "message": "Príloha bola uložená." + "message": "Príloha bola uložená" }, "file": { "message": "Súbor" @@ -1423,13 +1470,13 @@ "message": "Momentálne nie ste prémiovým členom." }, "premiumSignUpAndGet": { - "message": "Zaregistrujte sa pre prémiové členstvo a získajte:" + "message": "Zaregistrujte sa na prémiové členstvo a získajte:" }, "ppremiumSignUpStorage": { "message": "1 GB šifrovaného úložiska na prílohy." }, "premiumSignUpEmergency": { - "message": "Núdzový prístup" + "message": "Núdzový prístup." }, "premiumSignUpTwoStepOptions": { "message": "Proprietárne možnosti dvojstupňového prihlásenia ako napríklad YubiKey a Duo." @@ -1459,7 +1506,7 @@ "message": "Ďakujeme, že podporujete Bitwarden." }, "premiumFeatures": { - "message": "Povýšte na premium a získajte:" + "message": "Inovujte na prémium a získajte:" }, "premiumPrice": { "message": "Všetko len za %price% /rok!", @@ -1480,23 +1527,17 @@ } }, "refreshComplete": { - "message": "Obnova kompletná" + "message": "Obnova bola dokončená" }, "enableAutoTotpCopy": { "message": "Automaticky kopírovať TOTP" }, "disableAutoTotpCopyDesc": { - "message": "Ak je kľúč overovateľa spojený s vašim prihlásením, TOTP verifikačný kód bude automaticky skopírovaný do schránky vždy, keď použijete automatické vypĺňanie." + "message": "Ak je overovací kľúč spojený s vašim prihlásením, overovací kód TOTP bude automaticky skopírovaný do schránky vždy, keď použijete automatické vypĺňanie." }, "enableAutoBiometricsPrompt": { "message": "Pri spustení požiadať o biometriu" }, - "premiumRequired": { - "message": "Vyžaduje prémiový účet" - }, - "premiumRequiredDesc": { - "message": "Pre použitie tejto funkcie je potrebné prémiové členstvo." - }, "authenticationTimeout": { "message": "Časový limit overenia" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Prečítať bezpečnostný kľúč" }, + "readingPasskeyLoading": { + "message": "Načítava sa prístupový kľúč…" + }, + "passkeyAuthenticationFailed": { + "message": "Overenie prístupovým kľúčom zlyhalo" + }, + "useADifferentLogInMethod": { + "message": "Použiť iný spôsob prihlásenia" + }, "awaitingSecurityKeyInteraction": { "message": "Čaká sa na interakciu s bezpečnostným kľúčom..." }, @@ -1553,7 +1603,7 @@ "message": "Vyberte metódu dvojstupňového prihlásenia" }, "recoveryCodeTitle": { - "message": "Záchranný kód" + "message": "Kód na obnovenie" }, "authenticatorAppTitle": { "message": "Overovacia aplikácia" @@ -1573,14 +1623,14 @@ "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "duoOrganizationDesc": { - "message": "Overiť s Duo Security vašej organizácie použitím Duo Mobile aplikácie, SMS, telefonátu alebo U2F bezpečnostným kľúčom.", + "message": "Overenie pomocou Duo Security pre vašu organizáciu pomocou aplikácie Duo Mobile, SMS, telefonického hovoru alebo bezpečnostného kľúča U2F.", "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "webAuthnTitle": { "message": "FIDO2 WebAuthn" }, "webAuthnDesc": { - "message": "Použiť akýkoľvek WebAuthn bezpečnostný kľúč pre prístup k vášmu účtu." + "message": "Použiť akýkoľvek kompatibilný bezpečnostný kľúč WebAuthn na prístup k svojmu účtu." }, "emailTitle": { "message": "Email" @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Musíte pridať buď základnú adresu URL servera, alebo aspoň jedno vlastné prostredie." }, + "selfHostedEnvMustUseHttps": { + "message": "Adresy URL musia používať HTTPS." + }, "customEnvironment": { "message": "Vlastné prostredie" }, @@ -1620,13 +1673,13 @@ "message": "URL servera identít" }, "notificationsUrl": { - "message": "URL adresa servera pre oznámenia" + "message": "URL servera pre upozornenia" }, "iconsUrl": { - "message": "URL servera ikôn" + "message": "URL servera ikon" }, "environmentSaved": { - "message": "URL prostredia boli uložené." + "message": "URL adresy prostredia boli uložené" }, "showAutoFillMenuOnFormFields": { "message": "Zobraziť ponuku automatického vypĺňania na poliach formulára", @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Vypnúť automatické vypĺňanie" }, + "confirmAutofill": { + "message": "Potvrdenie automatického vypĺňania" + }, + "confirmAutofillDesc": { + "message": "Táto stránka nezodpovedá vašim uloženým prihlasovacím údajom. Pred vyplnením prihlasovacích údajov sa uistite, že ide o dôveryhodnú stránku." + }, "showInlineMenuLabel": { "message": "Zobraziť návrhy automatického vypĺňania v poliach formulára" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Ako Bitwarden chráni vaše údaje pred phishingom?" + }, + "currentWebsite": { + "message": "Aktuálna webstránka" + }, + "autofillAndAddWebsite": { + "message": "Automaticky vyplniť a pridať túto webstránku" + }, + "autofillWithoutAdding": { + "message": "Automaticky vyplniť bez pridania" + }, + "doNotAutofill": { + "message": "Nevyplniť automaticky" + }, "showInlineMenuIdentitiesLabel": { "message": "Zobrazovať identity ako návrhy" }, @@ -1683,32 +1757,32 @@ "description": "Overlay appearance select option for showing the field on focus of the input element" }, "autofillOverlayVisibilityOnButtonClick": { - "message": "Keď je vybratá ikona automatického vypĺňania", + "message": "Keď je vybraná ikona automatického vypĺňania", "description": "Overlay appearance select option for showing the field on click of the overlay icon" }, "enableAutoFillOnPageLoadSectionTitle": { "message": "Povoliť automatické vypĺňanie pri načítaní stránky" }, "enableAutoFillOnPageLoad": { - "message": "Povoliť automatické vypĺňanie pri načítaní stránky" + "message": "Automaticky vyplniť pri načítaní stránky" }, "enableAutoFillOnPageLoadDesc": { - "message": "Ak je detekovaný prihlasovací formulár, automaticky vykonať vypĺňanie pri načítaní stránky." + "message": "Ak sa zistí prihlasovací formulár, pri načítaní webovej stránky sa automaticky vyplní." }, "experimentalFeature": { - "message": "Skompromitované alebo nedôveryhodné stránky môžu pri svojom načítaní zneužiť automatické dopĺňanie." + "message": "Kompromitované alebo nedôveryhodné webové lokality môžu zneužiť automatické vypĺňanie pri načítaní stránky." }, "learnMoreAboutAutofillOnPageLoadLinkText": { "message": "Viac informácií o rizikách" }, "learnMoreAboutAutofill": { - "message": "Dozvedieť sa viac o automatickom dopĺňaní" + "message": "Viac informácií o automatickom vypĺňaní" }, "defaultAutoFillOnPageLoad": { "message": "Predvolené nastavenie automatického vypĺňania pre prihlasovacie položky" }, "defaultAutoFillOnPageLoadDesc": { - "message": "Pri úprave položky prihlásenia môžete individuálne zapnúť alebo vypnúť automatické vypĺňanie pri načítaní stránky pre danú položku." + "message": "Automatické vypĺňanie pri načítaní stránky môžete vypnúť, pre jednotlivé položky prihlásenia, pri úprave položky." }, "autoFillOnPageLoadUseDefault": { "message": "Pôvodné nastavenia" @@ -1785,7 +1859,7 @@ "message": "Zobraziť ikony webových stránok a načítať adresy URL na zmenu hesla" }, "cardholderName": { - "message": "Meno vlastníka karty" + "message": "Meno držiteľa karty" }, "number": { "message": "Číslo" @@ -1794,10 +1868,13 @@ "message": "Značka" }, "expirationMonth": { - "message": "Mesiac expirácie" + "message": "Mesiac exspirácie" }, "expirationYear": { - "message": "Rok expirácie" + "message": "Rok exspirácie" + }, + "monthly": { + "message": "mesačne" }, "expiration": { "message": "Expirácia" @@ -1869,7 +1946,7 @@ "message": "Krstné meno" }, "middleName": { - "message": "Druhé meno" + "message": "Stredné meno" }, "lastName": { "message": "Priezvisko" @@ -1884,13 +1961,13 @@ "message": "Spoločnosť" }, "ssn": { - "message": "Číslo poistenca sociálnej poisťovne" + "message": "Číslo sociálneho poistenia" }, "passportNumber": { "message": "Číslo pasu" }, "licenseNumber": { - "message": "Číslo vodičského preukazu" + "message": "Licenčné číslo" }, "email": { "message": "Email" @@ -2127,10 +2204,10 @@ "description": "Default URI match detection for autofill." }, "toggleOptions": { - "message": "Voľby prepínača" + "message": "Zobraziť/skryť možnosti" }, "toggleCurrentUris": { - "message": "Prepnúť zobrazovanie aktuálnej URI", + "message": "Prepnúť zobrazenie aktuálnej URI", "description": "Toggle the display of the URIs of the currently open tabs in the browser." }, "currentUri": { @@ -2321,13 +2398,13 @@ "message": "Naozaj chcete narvalo odstrániť túto položku?" }, "permanentlyDeletedItem": { - "message": "Natrvalo odstrániť položku" + "message": "Položka bola natrvalo odstránená" }, "restoreItem": { "message": "Obnoviť položku" }, "restoredItem": { - "message": "Obnovená položka" + "message": "Položka bola obnovená" }, "alreadyHaveAccount": { "message": "Už máte účet?" @@ -2339,13 +2416,13 @@ "message": "Potvrdenie akcie pri vypršaní časového limitu" }, "autoFillAndSave": { - "message": "Auto-vyplniť a Uložiť" + "message": "Automaticky vyplniť a uložiť" }, "fillAndSave": { "message": "Vyplniť a uložiť" }, "autoFillSuccessAndSavedUri": { - "message": "Automatické vypĺnenie a uloženie úspešné" + "message": "Položka bola automaticky vyplnená a URI uložený" }, "autoFillSuccess": { "message": "Automaticky vyplnené" @@ -2357,7 +2434,7 @@ "message": "Prajete si napriek tomu vyplniť prihlasovacie údaje?" }, "autofillIframeWarning": { - "message": "Formulár je hosťovaný inou doménou ako má URI uložených prihlasovacích údajov. Zvoľte OK ak chcete aj tak automaticky vyplniť údaje, alebo Zrušiť pre zastavenie." + "message": "Formulár je umiestnený na inej doméne ako URI vašich prihlasovacích údajov. Vyberte OK, ak chcete aj tak použiť automatické vyplnenie, alebo Zrušiť, ak chcete automatické vyplnenie zastaviť." }, "autofillIframeWarningTip": { "message": "Ak chcete tomuto upozorneniu v budúcnosti zabrániť, uložte URI, $HOSTNAME$, do položky prihlásenia Bitwardenu pre túto stránku.", @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Táto stránka narúša zážitok zo Bitwardenu. Inline ponuka Bitwardenu bola dočasne vypnutá ako bezpečnostné opatrenie." + }, "setMasterPassword": { "message": "Nastaviť hlavné heslo" }, @@ -2480,10 +2560,10 @@ "message": "Spustiť desktopovú aplikáciu Bitwarden Desktop" }, "startDesktopDesc": { - "message": "Aplikácia Bitwarden Desktop musí byť pred použitím odomknutia pomocou biometrických údajov spustená." + "message": "Aplikácia Bitwarden Desktop musí byť spustená pred použitím odomknutia pomocou biometrických údajov." }, "errorEnableBiometricTitle": { - "message": "Nie je môžné povoliť biometrické údaje" + "message": "Nie je možné povoliť biometrické údaje" }, "errorEnableBiometricDesc": { "message": "Akcia bola zrušená desktopovou aplikáciou" @@ -2534,7 +2614,7 @@ "message": "Biometria zlyhala" }, "biometricsFailedDesc": { - "message": "Biometria nebola vykonaná. Zvážte použitie hlavného hesla, alebo sa odhláste. Ak tento problém pretrváva, kontaktujte podporu Bitwarden." + "message": "Biometria nebola dokončená. Zvážte použitie hlavného hesla, alebo sa odhláste. Ak tento problém pretrváva, kontaktujte podporu Bitwarden." }, "nativeMessaginPermissionErrorTitle": { "message": "Povolenie nebolo udelené" @@ -2810,10 +2890,10 @@ "message": "Odstrániť" }, "removedPassword": { - "message": "Heslo odstránené" + "message": "Heslo bolo odstránené" }, "deletedSend": { - "message": "Odstrániť Send", + "message": "Send bol odstránený", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendLink": { @@ -2872,7 +2952,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "createSend": { - "message": "Vytvoriť nový Send", + "message": "Nový Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "newPassword": { @@ -2887,7 +2967,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "createdSend": { - "message": "Send vytvorený", + "message": "Send bol vytvorený", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "createdSendSuccessfully": { @@ -2927,7 +3007,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editedSend": { - "message": "Send upravený", + "message": "Send bol upravený", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendFilePopoutDialogText": { @@ -2993,7 +3073,7 @@ "message": "Hlavné heslo bolo úspešne nastavené" }, "updatedMasterPassword": { - "message": "Hlavné heslo aktualizované" + "message": "Hlavné heslo bolo aktualizované" }, "updateMasterPassword": { "message": "Aktualizovať hlavné heslo" @@ -3051,7 +3131,7 @@ "message": "Na možnosti pri vypršaní časového limitu boli uplatnené požiadavky pravidiel spoločnosti" }, "vaultTimeoutPolicyInEffect": { - "message": "Zásady vašej organizácie ovplyvňujú časový limit trezoru. Maximálny povolený časový limit trezoru je $HOURS$ h a $MINUTES$ m", + "message": "Zásady vašej organizácie ovplyvňujú časový limit trezoru. Maximálny povolený časový limit trezoru je $HOURS$ h a $MINUTES$ m.", "placeholders": { "hours": { "content": "$1", @@ -3119,10 +3199,10 @@ "message": "Časový limit vášho trezora prekračuje obmedzenia nastavené vašou organizáciou." }, "vaultExportDisabled": { - "message": "Export trezoru je zakázaný" + "message": "Export trezoru nie je dostupný" }, "personalVaultExportPolicyInEffect": { - "message": "Jedna alebo viacero zásad organizácie vám bráni exportovať váš osobný trezor." + "message": "Jedno alebo viacero pravidiel organizácie vám bráni exportovať váš osobný trezor." }, "copyCustomFieldNameInvalidElement": { "message": "Nie je možné identifikovať platný prvok formulára. Skúste namiesto toho preskúmať HTML." @@ -3146,7 +3226,7 @@ "message": "Odstrániť hlavné heslo" }, "removedMasterPassword": { - "message": "Hlavné heslo bolo odstránené." + "message": "Hlavné heslo bolo odstránené" }, "leaveOrganizationConfirmation": { "message": "Naozaj chcete opustiť túto organizáciu?" @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Exportuje sa len trezor organizácie spojený s $ORGANIZATION$.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Exportuje sa len trezor organizácie spojený s $ORGANIZATION$. Moje zbierky položiek nebudú zahrnuté.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Chyba" }, "decryptionError": { "message": "Chyba dešifrovania" }, + "errorGettingAutoFillData": { + "message": "Chyba pri získavaní údajov automatického vypĺňania" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden nedokázal dešifrovať nižšie uvedené položky trezoru." }, @@ -3290,7 +3391,7 @@ "description": "Guidance provided for email forwarding services that support multiple email domains." }, "forwarderError": { - "message": "$SERVICENAME$ chyba: $ERRORMESSAGE$", + "message": "Chyba $SERVICENAME$: $ERRORMESSAGE$", "description": "Reports an error returned by a forwarding service to the user.", "placeholders": { "servicename": { @@ -3432,10 +3533,10 @@ "message": "Vyžaduje sa Premiové predplatné" }, "organizationIsDisabled": { - "message": "Organizácia je vypnutá." + "message": "Organizácia je pozastavená." }, "disabledOrganizationFilterError": { - "message": "K položkám vo vypnutej organizácii nie je možné pristupovať. Požiadajte o pomoc vlastníka organizácie." + "message": "K položkám v pozastavenej organizácii nie je možné pristupovať. Požiadajte o pomoc vlastníka organizácie." }, "loggingInTo": { "message": "Prihlásenie do $DOMAIN$", @@ -3465,7 +3566,7 @@ } }, "lastSeenOn": { - "message": "naposledy videné $DATE$", + "message": "naposledy videný: $DATE$", "placeholders": { "date": { "content": "$1", @@ -3483,10 +3584,10 @@ "message": "Zapamätať si e-mail" }, "loginWithDevice": { - "message": "Prihlásiť pomocou zariadenia" + "message": "Prihlásiť sa pomocou zariadenia" }, "fingerprintPhraseHeader": { - "message": "Fráza odtlačku prsta" + "message": "Jedinečný identifikátor" }, "fingerprintMatchInfo": { "message": "Uistite sa, že je váš trezor odomknutý a fráza odtlačku prsta sa zhoduje s frázou na druhom zariadení." @@ -3550,16 +3651,16 @@ "message": "Hlavné heslo uložené" }, "exposedMasterPassword": { - "message": "Odhalené hlavné heslo" + "message": "Uniknuté hlavné heslo" }, "exposedMasterPasswordDesc": { - "message": "Nájdené heslo v uniknuných údajoch. Na ochranu svojho účtu používajte jedinečné heslo. Naozaj chcete používať odhalené heslo?" + "message": "Heslo bolo nájdené v uniknutých údajoch. Na ochranu svojho účtu používajte jedinečné heslo. Naozaj chcete používať odhalené heslo?" }, "weakAndExposedMasterPassword": { - "message": "Slabé a odhalené hlavné heslo" + "message": "Slabé a uniknuté hlavné heslo" }, "weakAndBreachedMasterPasswordDesc": { - "message": "Nájdené slabé heslo v uniknuných údajoch. Na ochranu svojho účtu používajte silné a jedinečné heslo. Naozaj chcete používať toto heslo?" + "message": "Nájdené slabé heslo v uniknutých údajoch. Na ochranu svojho účtu používajte silné a jedinečné heslo. Naozaj chcete používať toto heslo?" }, "checkForBreaches": { "message": "Skontrolovať známe úniky údajov pre toto heslo" @@ -3571,7 +3672,7 @@ "message": "Vaše hlavné heslo sa nebude dať obnoviť, ak ho zabudnete!" }, "characterMinimum": { - "message": "Minimálny počet znakov $LENGTH$", + "message": "Minimálny počet znakov: $LENGTH$", "placeholders": { "length": { "content": "$1", @@ -3592,7 +3693,7 @@ } }, "autofillSelectInfoWithoutCommand": { - "message": "Vyberte položku z ponuky alebo preskúmajte ďalšie možnosti nastavenia." + "message": "Vyberte položku z ponuky, alebo preskúmajte ďalšie možnosti nastavenia." }, "gotIt": { "message": "Chápem" @@ -3967,9 +4068,18 @@ "message": "Alias doména" }, "autofillOnPageLoadSetToDefault": { - "message": "Automatické vypĺňanie pri načítaní stránky nastavené na pôvodnú predvoľbu.", + "message": "Automatické vypĺňanie pri načítaní stránky nastavené na pôvodné nastavenie.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Automatické vypĺňanie nie je možné" + }, + "cannotAutofillExactMatch": { + "message": "Predvolená zhoda je nastavená na \"Presná zhoda\". Aktuálna webová stránka sa presne nezhoduje s uloženými prihlasovacími údajmi pre túto položku." + }, + "okay": { + "message": "OK" + }, "toggleSideNavigation": { "message": "Prepnúť bočnú navigáciu" }, @@ -4029,7 +4139,7 @@ "description": "Button text to display in overlay when there are no matching items" }, "addNewVaultItem": { - "message": "Pridať novú položku trezoru", + "message": "Pridať novú položku trezora", "description": "Screen reader text (aria-label) for new item button in overlay" }, "newLogin": { @@ -4234,7 +4344,7 @@ "message": "Prihlásený!" }, "passkeyNotCopied": { - "message": "Prístupový kód sa neskopíruje" + "message": "Prístupový kľúč sa neskopíruje" }, "passkeyNotCopiedAlert": { "message": "Prístupový kľúč sa do klonovanej položky neskopíruje. Chcete pokračovať v klonovaní tejto položky?" @@ -4264,7 +4374,7 @@ "message": "Uložiť prístupový kľúč" }, "savePasskeyNewLogin": { - "message": "Uložiť prístupový kľúč ako nové prihlasovacie údaje" + "message": "Uložiť prístupový kľúč ako nové prihlásenie" }, "chooseCipherForPasskeySave": { "message": "Vyberte prihlasovacie údaje, do ktorých chcete uložiť prístupový kľúč" @@ -4394,10 +4504,10 @@ "message": "server" }, "hostedAt": { - "message": "hotované na" + "message": "hostované na" }, "useDeviceOrHardwareKey": { - "message": "Použiť vaše zariadenie alebo hardvérový kľúč" + "message": "Použiť svoje zariadenie alebo hardvérový kľúč" }, "justOnce": { "message": "Iba raz" @@ -4471,7 +4581,7 @@ "description": "Dialog message facilitating the ability to override a chrome browser's default autofill behavior" }, "overrideDefaultBrowserAutoFillSettings": { - "message": "Nastaviť Bitwarden ako predvolený správca hesiel", + "message": "Nastaviť Bitwarden ako predvoleného správcu hesiel", "description": "Label for the setting that allows overriding the default browser autofill settings" }, "privacyPermissionAdditionNotGrantedTitle": { @@ -4775,7 +4885,7 @@ "message": "Stiahnuť Bitwarden na všetky zariadenia" }, "getTheMobileApp": { - "message": "Získajte mobilnú aplikáciu" + "message": "Získať mobilnú aplikáciu" }, "getTheMobileAppDesc": { "message": "Majte prístup k heslám na cestách pomocou mobilnej aplikácie Bitwarden." @@ -4801,6 +4911,9 @@ "premium": { "message": "Prémium" }, + "unlockFeaturesWithPremium": { + "message": "Odomknite reportovanie, núdzový prístup a ďalšie bezpečnostné funkcie s predplatným Prémium." + }, "freeOrgsCannotUseAttachments": { "message": "Bezplatné organizácie nemôžu používať prílohy" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Predvolené ($VALUE$)", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Zobraziť zisťovanie zhody $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Vitajte vo svojom trezore!" }, - "phishingPageTitle": { - "message": "Phishingová webová stránka" + "phishingPageTitleV2": { + "message": "Bol zaznamenaný pokus o phising" }, - "phishingPageCloseTab": { - "message": "Zavrieť kartu" + "phishingPageSummary": { + "message": "Stránka, ktorú sa pokúšate navštíviť je známa ako podvodná stránka a nesie bezpečnostné riziko." }, - "phishingPageContinue": { - "message": "Pokračovať" + "phishingPageCloseTabV2": { + "message": "Zatvoriť túto kartu" }, - "phishingPageLearnWhy": { - "message": "Prečo to vidíte?" + "phishingPageContinueV2": { + "message": "Pokračovať na stránku (neodporúča sa)" + }, + "phishingPageExplanation1": { + "message": "Táto stránka sa našla v ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", otvorený zoznam známych phisingových stránok, ktoré sa používajú na krádež osobných a citlivých informácií.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Viac informácií o detekcii phishingu" + }, + "protectedBy": { + "message": "Chráni $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Položky na automatické vypĺňanie pre aktuálnu stránku" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Potvrdiť doménu Key Connectora" + }, + "atRiskLoginsSecured": { + "message": "Skvelá práca pri zabezpečení vašich ohrozených prihlasovacích údajov!" + }, + "upgradeNow": { + "message": "Upgradovať teraz" + }, + "builtInAuthenticator": { + "message": "Zabudovaný autentifikátor" + }, + "secureFileStorage": { + "message": "Bezpečné ukladanie súborov" + }, + "emergencyAccess": { + "message": "Núdzový prístup" + }, + "breachMonitoring": { + "message": "Sledovanie únikov" + }, + "andMoreFeatures": { + "message": "A ešte viac!" + }, + "planDescPremium": { + "message": "Úplné online zabezpečenie" + }, + "upgradeToPremium": { + "message": "Upgradovať na Prémium" + }, + "unlockAdvancedSecurity": { + "message": "Odomknutie pokročilých funkcií zabezpečenia" + }, + "unlockAdvancedSecurityDesc": { + "message": "Predplatné Prémium vám poskytne viac nástrojov na zabezpečenie a kontrolu" + }, + "explorePremium": { + "message": "Preskúmať Prémium" + }, + "loadingVault": { + "message": "Načítava sa trezor" + }, + "vaultLoaded": { + "message": "Trezor sa načítal" + }, + "settingDisabledByPolicy": { + "message": "Politika organizácie vypla toto nastavenie.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "PSČ" + }, + "cardNumberLabel": { + "message": "Číslo karty" + }, + "sessionTimeoutSettingsAction": { + "message": "Akcia pri vypršaní časového limitu" } } diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index 81b1a6bb52c..2d20050d7f1 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Uredi" }, "view": { "message": "Pogled" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Napačno glavno geslo" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Zakleni trezor, ko preteče toliko časa:" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Ob zaklepu sistema" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Ob ponovnem zagonu brskalnika" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Element shranjen" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Ali ste prepričani, da želite to izbrisati?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ob zagonu zahtevaj biometrično preverjanje" }, - "premiumRequired": { - "message": "Potrebno je premium članstvo" - }, - "premiumRequiredDesc": { - "message": "Premium članstvo je potrebno za uporabo te funkcije." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Okolje po meri" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Leto poteka" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Veljavna do" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Nastavi glavno geslo" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Napaka" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 269ebd41bdd..0c7562987fc 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Употребити једнократну пријаву" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Ваша организација захтева јединствену пријаву." + }, "welcomeBack": { "message": "Добродошли назад" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Ресетовати претрагу" }, - "archive": { - "message": "Архива" + "archiveNoun": { + "message": "Архива", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Архива", + "description": "Verb" + }, + "unArchive": { "message": "Врати из архиве" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Архивиране ставке ће се овде појавити и бити искључени из општих резултата претраге и сугестија о ауто-пуњењу." }, - "itemSentToArchive": { + "itemWasSentToArchive": { "message": "Ставка је послата у архиву" }, - "itemRemovedFromArchive": { - "message": "Ставка је уклоњена из архиве" + "itemUnarchived": { + "message": "Ставка враћена из архиве" }, "archiveItem": { "message": "Архивирај ставку" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Архивиране ставке су искључене из општих резултата претраге и предлога за ауто попуњавање. Јесте ли сигурни да желите да архивирате ову ставку?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Уреди" }, "view": { "message": "Приказ" }, + "viewAll": { + "message": "Прегледај све" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "Преглед пријаве" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Погрешна главна лозинка" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Неважећа главна лозинка. Потврдите да је ваш имејл тачан и ваш рачун је креиран на $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Тајмаут сефа" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "На закључавање система" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "На покретање прегледача" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Ставка уређена" }, + "savedWebsite": { + "message": "Сачувана веб локација" + }, + "savedWebsites": { + "message": "Сачувана веб локација ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Сигурно послати ову ставку у отпад?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Захтевај биометрију при покретању" }, - "premiumRequired": { - "message": "Потребан Премијум" - }, - "premiumRequiredDesc": { - "message": "Премијум чланство је неопходно за употребу ове опције." - }, "authenticationTimeout": { "message": "Истекло је време аутентификације" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Читај сигурносни кључ" }, + "readingPasskeyLoading": { + "message": "Читање притупачног кључа..." + }, + "passkeyAuthenticationFailed": { + "message": "Потврда приступачног кључа" + }, + "useADifferentLogInMethod": { + "message": "Користи други начин пријављивања" + }, "awaitingSecurityKeyInteraction": { "message": "Чека се интеракција сигурносног кључа..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Морате додати или основни УРЛ сервера или бар једно прилагођено окружење." }, + "selfHostedEnvMustUseHttps": { + "message": "Везе морају да користе HTTPS." + }, "customEnvironment": { "message": "Прилагођено окружење" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Угасити ауто-пуњење" }, + "confirmAutofill": { + "message": "Потврди аутопуњење" + }, + "confirmAutofillDesc": { + "message": "Овај сајт се не подудара са вашим сачуваним подацима за пријаву. Пре него што унесете своје акредитиве за пријаву, уверите се да је то поуздан сајт." + }, "showInlineMenuLabel": { "message": "Прикажи предлоге за ауто-попуњавање у пољима обрасца" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Како Bitwarden штити ваше податке од фишинга?" + }, + "currentWebsite": { + "message": "Тренутни сајт" + }, + "autofillAndAddWebsite": { + "message": "Ауто-попуни и додај овај сајт" + }, + "autofillWithoutAdding": { + "message": "Ауто-попуни без додавања" + }, + "doNotAutofill": { + "message": "Не попуни" + }, "showInlineMenuIdentitiesLabel": { "message": "Приказати идентитете као предлоге" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Година истека" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Истек" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Постави Главну Лозинку" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Извешће се само сеф организације повезана са $ORGANIZATION$.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Извешће се само сеф организације повезан са $ORGANIZATION$. Колекције мојих предмета неће бити укључене.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Грешка" }, "decryptionError": { "message": "Грешка при декрипцији" }, + "errorGettingAutoFillData": { + "message": "Грешка при преузимању података за ауто-попуњавање" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden није могао да декриптује ставке из трезора наведене испод." }, @@ -3970,6 +4071,15 @@ "message": "Ауто-попуњавање при учитавању странице је подешено да користи подразумевано подешавање.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Не може да се ауто-попуни" + }, + "cannotAutofillExactMatch": { + "message": "Подразумевано подударање је подешено на „Тачно подударање“. Тренутна веб локација не одговара тачно сачуваним детаљима за пријаву за ову ставку." + }, + "okay": { + "message": "У реду" + }, "toggleSideNavigation": { "message": "Укључите бочну навигацију" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Премијум" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Бесплатне организације не могу да користе прилоге" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Подразумевано ($VALUE$)", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Прикажи откривање подударања $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Добродошли у ваш сеф!" }, - "phishingPageTitle": { - "message": "Пронађен злонамеран сајт" + "phishingPageTitleV2": { + "message": "Откривен је покушај „пецања“" }, - "phishingPageCloseTab": { - "message": "Затвори језичак" + "phishingPageSummary": { + "message": "Сајт који покушавате да посетите је позната злонамерна локација и представља безбедносни ризик." }, - "phishingPageContinue": { - "message": "Настави" + "phishingPageCloseTabV2": { + "message": "Затвори ову картицу" }, - "phishingPageLearnWhy": { - "message": "Зашто видите ово?" + "phishingPageContinueV2": { + "message": "Настави до овог сајта (не препоручује се)" + }, + "phishingPageExplanation1": { + "message": "Овај сајт је нађен у ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", листа отвореног кода познатих сајтова за крађу идентитета који се користе за крађу личних и осетљивих информација.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Сазнајте више о откривању „пецања“" + }, + "protectedBy": { + "message": "Заштићено са $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Ауто-пуњење предмета за тренутну страницу" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Потврдите домен конектора кључа" + }, + "atRiskLoginsSecured": { + "message": "Сјајан посао обезбеђивања ваших ризичних пријава!" + }, + "upgradeNow": { + "message": "Надогради сада" + }, + "builtInAuthenticator": { + "message": "Уграђени аутентификатор" + }, + "secureFileStorage": { + "message": "Сигурно складиштење датотека" + }, + "emergencyAccess": { + "message": "Хитан приступ" + }, + "breachMonitoring": { + "message": "Праћење повreda безбедности" + }, + "andMoreFeatures": { + "message": "И још више!" + }, + "planDescPremium": { + "message": "Потпуна онлајн безбедност" + }, + "upgradeToPremium": { + "message": "Надоградите на Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "Ово подешавање је онемогућено смерницама ваше организације.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP/Поштански број" + }, + "cardNumberLabel": { + "message": "Број картице" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index 8b0263bf15a..2c364f20590 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Använd Single Sign-On" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Din organisation kräver single sign-on." + }, "welcomeBack": { "message": "Välkommen tillbaka" }, @@ -84,7 +87,7 @@ "message": "Huvudlösenordsledtråd (valfri)" }, "passwordStrengthScore": { - "message": "Lösenordsstyrka $SCORE$ (score)", + "message": "Lösenordsstyrka $SCORE$", "placeholders": { "score": { "content": "$1", @@ -105,7 +108,7 @@ } }, "finishJoiningThisOrganizationBySettingAMasterPassword": { - "message": "Avsluta anslutningen till denna organisation genom att ange ett huvudlösenord." + "message": "Slutför anslutningen till den här organisationen genom att ställa in ett huvudlösenord." }, "tab": { "message": "Flik" @@ -550,11 +553,16 @@ "resetSearch": { "message": "Nollställ sökning" }, - "archive": { - "message": "Arkivera" + "archiveNoun": { + "message": "Arkiv", + "description": "Noun" }, - "unarchive": { - "message": "Packa upp" + "archiveVerb": { + "message": "Arkivera", + "description": "Verb" + }, + "unArchive": { + "message": "Avarkivera" }, "itemsInArchive": { "message": "Objekt i arkiv" @@ -563,19 +571,22 @@ "message": "Inga objekt i arkivet" }, "noItemsInArchiveDesc": { - "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + "message": "Arkiverade objekt kommer att visas här och kommer att uteslutas från allmänna sökresultat och förslag för autofyll." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Objektet skickades till arkivet" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Objektet har avarkiverats" }, "archiveItem": { "message": "Arkivera objekt" }, "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "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?" + }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." }, "edit": { "message": "Redigera" @@ -583,6 +594,15 @@ "view": { "message": "Visa" }, + "viewAll": { + "message": "Visa alla" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "Visa mindre" + }, "viewLogin": { "message": "Visa inloggning" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Ogiltigt huvudlösenord" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Ogiltigt huvudlösenord. Bekräfta att din e-postadress är korrekt och ditt konto skapades på $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Valvets tidsgräns" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Vid låsning av datorn" }, + "onIdle": { + "message": "När systemet är overksamt" + }, + "onSleep": { + "message": "När systemet är i strömsparläge" + }, "onRestart": { "message": "Vid omstart" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Objekt sparat" }, + "savedWebsite": { + "message": "Sparad webbplats" + }, + "savedWebsites": { + "message": "Sparade webbplatser ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Är du säker på att du vill radera detta objekt?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Be om biometri vid start" }, - "premiumRequired": { - "message": "Premium krävs" - }, - "premiumRequiredDesc": { - "message": "Ett premium-medlemskap krävs för att använda den här funktionen." - }, "authenticationTimeout": { "message": "Timeout för autentisering" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Läs säkerhetsnyckel" }, + "readingPasskeyLoading": { + "message": "Läser inloggningsnyckel..." + }, + "passkeyAuthenticationFailed": { + "message": "Autentisering med inloggningsnyckel misslyckades" + }, + "useADifferentLogInMethod": { + "message": "Använd en annan inloggningsmetod" + }, "awaitingSecurityKeyInteraction": { "message": "Väntar på interaktion med säkerhetsnyckel..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Du måste lägga till antingen serverns bas-URL eller minst en anpassad miljö." }, + "selfHostedEnvMustUseHttps": { + "message": "Webbadresser måste använda HTTPS." + }, "customEnvironment": { "message": "Anpassad miljö" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Stäng av autofyll" }, + "confirmAutofill": { + "message": "Bekräfta autofyll" + }, + "confirmAutofillDesc": { + "message": "Denna webbplats matchar inte dina sparade inloggningsuppgifter. Innan du fyller i dina inloggningsuppgifter, se till att det är en betrodd webbplats." + }, "showInlineMenuLabel": { "message": "Visa förslag för autofyll i formulärfält" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Hur skyddar Bitwarden dina data från nätfiske?" + }, + "currentWebsite": { + "message": "Aktuell webbplats" + }, + "autofillAndAddWebsite": { + "message": "Autofyll och lägg till denna webbplats" + }, + "autofillWithoutAdding": { + "message": "Autofyll utan att lägga till" + }, + "doNotAutofill": { + "message": "Autofyll inte" + }, "showInlineMenuIdentitiesLabel": { "message": "Visa identiteter som förslag" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Utgångsår" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Utgång" }, @@ -1967,11 +2044,11 @@ "description": "Header for new SSH key item type" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Ny textsändning", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Ny filsändning", "description": "Header for new file send" }, "editItemHeaderLogin": { @@ -1995,11 +2072,11 @@ "description": "Header for edit SSH key item type" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Redigera textsändning", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Redigera filsändning", "description": "Header for edit file send" }, "viewItemHeaderLogin": { @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Denna sida stör Bitwarden-upplevelsen. Bitwardens inbyggda meny har tillfälligt inaktiverats som en säkerhetsåtgärd." + }, "setMasterPassword": { "message": "Ange huvudlösenord" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Endast organisationsvalvet som är associerat med $ORGANIZATION$ kommer att exporteras.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Endast organisationsvalvet som associeras med $ORGANIZATION$ kommer att exporteras. Mina objektsamlingar kommer inte att inkluderas.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Fel" }, "decryptionError": { "message": "Dekrypteringsfel" }, + "errorGettingAutoFillData": { + "message": "Fel vid hämtning av autofylldata" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden kunde inte dekryptera valvföremålet/valvföremålen som listas nedan." }, @@ -3970,6 +4071,15 @@ "message": "Aktivera automatisk ifyllnad vid sidhämtning sattes till att använda standardinställningen.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Kan inte autofylla" + }, + "cannotAutofillExactMatch": { + "message": "Standardmatchning är satt till 'Exakt matchning'. Den aktuella webbplatsen matchar inte exakt de sparade inloggningsuppgifterna för detta objekt." + }, + "okay": { + "message": "Okej" + }, "toggleSideNavigation": { "message": "Växla sidonavigering" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Lås upp rapportering, nödåtkomst och fler säkerhetsfunktioner med Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Fria organisationer kan inte använda bilagor" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Standard ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Visa matchningsdetektering $WEBSITE", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Välkommen till ditt valv!" }, - "phishingPageTitle": { - "message": "Webbsida för nätfiske" + "phishingPageTitleV2": { + "message": "Försök till nätfiske upptäcktes" }, - "phishingPageCloseTab": { - "message": "Stäng flik" + "phishingPageSummary": { + "message": "Webbplatsen som du försöker besöka är en känd skadlig webbplats och en säkerhetsrisk." }, - "phishingPageContinue": { - "message": "Fortsätt" + "phishingPageCloseTabV2": { + "message": "Stäng denna flik" }, - "phishingPageLearnWhy": { - "message": "Varför ser du detta?" + "phishingPageContinueV2": { + "message": "Fortsätt till denna webbplats (rekommenderas inte)" + }, + "phishingPageExplanation1": { + "message": "Denna webbplats hittades i ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", en öppen källkodslista över kända nätfiskeplatser som används för att stjäla personlig och känslig information.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Läs mer om nätfiskedetektering" + }, + "protectedBy": { + "message": "Skyddad av $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofyll objekt för den aktuella sidan" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Bekräfta Key Connector-domän" + }, + "atRiskLoginsSecured": { + "message": "Bra jobbat med att säkra upp dina inloggninar i riskzonen!" + }, + "upgradeNow": { + "message": "Uppgradera nu" + }, + "builtInAuthenticator": { + "message": "Inbyggd autenticator" + }, + "secureFileStorage": { + "message": "Säker fillagring" + }, + "emergencyAccess": { + "message": "Nödåtkomst" + }, + "breachMonitoring": { + "message": "Intrångsmonitorering" + }, + "andMoreFeatures": { + "message": "och mer!" + }, + "planDescPremium": { + "message": "Komplett säkerhet online" + }, + "upgradeToPremium": { + "message": "Uppgradera till Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Utforska Premium" + }, + "loadingVault": { + "message": "Läser in valv" + }, + "vaultLoaded": { + "message": "Valvet lästes in" + }, + "settingDisabledByPolicy": { + "message": "Denna inställning är inaktiverad enligt din organisations policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Postnummer" + }, + "cardNumberLabel": { + "message": "Kortnummer" + }, + "sessionTimeoutSettingsAction": { + "message": "Tidsgränsåtgärd" } } diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index 8d2199db6ca..4d185501855 100644 --- a/apps/browser/src/_locales/ta/messages.json +++ b/apps/browser/src/_locales/ta/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "ஒற்றை உள்நுழைவைப் பயன்படுத்தவும்" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "மீண்டும் வருக" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "தேடலை மீட்டமை" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "திருத்து" }, "view": { "message": "காண்" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "உள்நுழைவைக் காண்க" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "தவறான முதன்மை கடவுச்சொல்" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "வால்ட் காலக்கெடு" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "சிஸ்டம் பூட்டப்பட்டவுடன்" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "உலாவி மறுதொடக்கம் செய்யப்பட்டவுடன்" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "உருப்படி சேமிக்கப்பட்டது" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "நீங்கள் உண்மையிலேயே குப்பைக்கு அனுப்ப விரும்புகிறீர்களா?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "தொடங்கும் போது பயோமெட்ரிக்ஸைக் கேட்கவும்" }, - "premiumRequired": { - "message": "பிரீமியம் தேவை" - }, - "premiumRequiredDesc": { - "message": "இந்த அம்சத்தைப் பயன்படுத்த ஒரு பிரீமியம் மெம்பர்ஷிப் தேவை." - }, "authenticationTimeout": { "message": "அங்கீகரிப்பு டைம் அவுட்" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "பாதுகாப்பு விசையைப் படி" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "பாதுகாப்பு விசை தொடர்புகொள்ளக் காத்திருக்கிறது..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "நீங்கள் பேஸ் சர்வர் URL-ஐ அல்லது குறைந்தது ஒரு தனிப்பயன் சூழலைச் சேர்க்க வேண்டும்." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "தனிப்பயன் சூழல்" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "ஆட்டோஃபில்லை முடக்கு" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "படிவப் புலங்களில் ஆட்டோஃபில் பரிந்துரைகளைக் காட்டு" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "பரிந்துரைகளாக அடையாளங்களைக் காட்டு" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "காலாவதி ஆண்டு" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "காலாவதி" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "முதன்மை கடவுச்சொல்லை அமை" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "பிழை" }, "decryptionError": { "message": "குறியாக்கம் நீக்கப் பிழை" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden கீழே பட்டியலிடப்பட்ட பெட்டக பொருளை குறியாக்கம் நீக்க முடியவில்லை." }, @@ -3970,6 +4071,15 @@ "message": "பக்க ஏற்றத்தில் தானியங்கு நிரப்புதல் இயல்புநிலை அமைப்பைப் பயன்படுத்த அமைக்கப்பட்டுள்ளது.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "பக்க வழிசெலுத்தலை மாற்று" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "பிரீமியம்" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "இலவச நிறுவனங்கள் இணைப்புகளைப் பயன்படுத்த முடியாது" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "பொருத்தமான கண்டறிதலைக் காட்டு $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "உங்கள் சேமிப்பு பெட்டகத்திற்கு நல்வரவு!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "தற்போதைய பக்கத்திற்கான தானாக நிரப்பு பொருள்கள்" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Key Connector டொமைனை உறுதிப்படுத்து" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index 78a49021a0c..f829937ac51 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Edit" }, "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Vault timeout" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "Premium required" - }, - "premiumRequiredDesc": { - "message": "A Premium membership is required to use this feature." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 61f97564f6a..ef6ba5b2077 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "ใช้การลงชื่อเพียงครั้งเดียว" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "ยินดีต้อนรับกลับมา" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Reset search" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "แก้ไข" }, "view": { "message": "แสดง" }, + "viewAll": { + "message": "View all" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "รหัสผ่านหลักไม่ถูกต้อง" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "ระยะเวลาล็อกตู้เซฟ" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "On Locked" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On Restart" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "แก้ไขรายการแล้ว" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "คุณต้องการส่งไปยังถังขยะใช่หรือไม่?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "Premium Required" - }, - "premiumRequiredDesc": { - "message": "A Premium membership is required to use this feature." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom Environment" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "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." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Expiration Year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "วันหมดอายุ" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "ตั้งรหัสผ่านหลัก" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -3970,6 +4071,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Show match detection $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index 0b65ae7d476..e6004ef387f 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Çoklu oturum açma kullan" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Kuruluşunuz çoklu oturum açma gerektiriyor." + }, "welcomeBack": { "message": "Tekrar hoş geldiniz" }, @@ -362,7 +365,7 @@ "message": "Ücretsiz Bitwarden Aile" }, "freeBitwardenFamiliesPageDesc": { - "message": "Ücretsiz Bitwarden Aile Paketi’nden faydalanmaya hak kazandınız. Bu teklifi bugün web uygulaması üzerinden kullanın." + "message": "Ücretsiz Bitwarden Aileleri için uygunsun. Bu teklifi bugün web uygulamasında kullan." }, "version": { "message": "Sürüm" @@ -550,32 +553,40 @@ "resetSearch": { "message": "Aramayı sıfırla" }, - "archive": { - "message": "Arşivle" + "archiveNoun": { + "message": "Arşiv", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Arşivle", + "description": "Verb" + }, + "unArchive": { "message": "Arşivden çıkar" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Arşivdeki kayıtlar" }, "noItemsInArchive": { - "message": "No items in archive" + "message": "Arşivde kayıt yok" }, "noItemsInArchiveDesc": { - "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + "message": "Arşivlenmiş kayıtlar burada görünecek ve genel arama sonuçlarından ile otomatik doldurma önerilerinden hariç tutulacaktır." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Kayıt arşive gönderildi" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Kayıt arşivden çıkarıldı" }, "archiveItem": { - "message": "Archive item" + "message": "Kaydı arşivle" }, "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "message": "Arşivlenmiş kayıtlar genel arama sonuçları ve otomatik doldurma önerilerinden hariç tutulur. Bu kaydı arşivlemek istediğine emin misin?" + }, + "upgradeToUseArchive": { + "message": "Arşivi kullanmak için premium üyelik gereklidir." }, "edit": { "message": "Düzenle" @@ -583,6 +594,15 @@ "view": { "message": "Görüntüle" }, + "viewAll": { + "message": "Tümünü göster" + }, + "showAll": { + "message": "Tümünü göster" + }, + "viewLess": { + "message": "Daha az göster" + }, "viewLogin": { "message": "Hesabı göster" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Geçersiz ana parola" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Ana parola geçersiz. E-posta adresinizin doğru olduğunu ve hesabınızın $HOST$ üzerinde oluşturulduğunu kontrol edin.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Kasa zaman aşımı" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Sistem kilitlenince" }, + "onIdle": { + "message": "Sistem boştayken" + }, + "onSleep": { + "message": "Sistem uyuyunca" + }, "onRestart": { "message": "Tarayıcı yeniden başlatılınca" }, @@ -875,13 +910,13 @@ "message": "Mevcut web sayfasındaki kimlik doğrulayıcı QR kodunu tarayın" }, "totpHelperTitle": { - "message": "2 adımlı doğrulamayı sorunsuz hale getirin" + "message": "2 adımlı doğrulamayı sorunsuz hale getir" }, "totpHelper": { - "message": "Bitwarden, 2 adımlı doğrulama kodlarını saklayabilir ve otomatik olarak doldurabilir. Anahtarı kopyalayıp bu alana yapıştırın." + "message": "Bitwarden 2 adımlı doğrulama kodlarını saklayabilir ve doldurabilir. Anahtarı bu alana kopyala ve yapıştır." }, "totpHelperWithCapture": { - "message": "Bitwarden, iki adımlı doğrulama kodlarını saklayabilir ve otomatik olarak doldurabilir. Bu web sitesinin doğrulayıcı QR kodunun ekran görüntüsünü almak için kamera simgesini seçin veya anahtarı bu alana kopyalayıp yapıştırın." + "message": "Bitwarden 2 adımlı doğrulama kodlarını saklayabilir ve doldurabilir. Bu web sitesinin kimlik doğrulayıcı QR kodunun ekran görüntüsünü almak için kamera simgesini seç veya anahtarı bu alana kopyala ve yapıştır." }, "learnMoreAboutAuthenticators": { "message": "Kimlik doğrulayıcılar hakkında bilgi alın" @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Hesap kaydedildi" }, + "savedWebsite": { + "message": "Kayıtlı web sitesi" + }, + "savedWebsites": { + "message": "Kayıtlı web siteleri ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Çöp kutusuna göndermek istediğinizden emin misiniz?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Açılışta biyometri doğrulaması iste" }, - "premiumRequired": { - "message": "Premium gerekli" - }, - "premiumRequiredDesc": { - "message": "Bu özelliği kullanmak için premium üyelik gereklidir." - }, "authenticationTimeout": { "message": "Kimlik doğrulama zaman aşımı" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Güvenlik anahtarını oku" }, + "readingPasskeyLoading": { + "message": "Geçiş anahtarı okunuyor..." + }, + "passkeyAuthenticationFailed": { + "message": "Geçiş anahtarı doğrulaması başarısız oldu" + }, + "useADifferentLogInMethod": { + "message": "Başka bir giriş yöntemi kullan" + }, "awaitingSecurityKeyInteraction": { "message": "Güvenlik anahtarı etkileşimi bekleniyor…" }, @@ -1592,7 +1642,7 @@ "message": "Şirket içinde barındırılan ortam" }, "selfHostedBaseUrlHint": { - "message": "Yerel sunucunuzda barındırılan Bitwarden kurulumunuzun temel URL’sini belirtin. Örnek: https://bitwarden.sirketiniz.com" + "message": "Şirket içinde barındırılan Bitwarden kurulumunun temel URL’sini belirt. Örnek: https://bitwarden.company.com" }, "selfHostedCustomEnvHeader": { "message": "İleri düzey yapılandırma için her hizmetin taban URL'sini bağımsız olarak belirleyebilirsiniz." @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Temel Sunucu URL’sini veya en az bir özel ortam eklemelisiniz." }, + "selfHostedEnvMustUseHttps": { + "message": "URL'ler HTTPS kullanmalıdır." + }, "customEnvironment": { "message": "Özel ortam" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Otomatik doldurmayı kapat" }, + "confirmAutofill": { + "message": "Otomatik doldurmayı onayla" + }, + "confirmAutofillDesc": { + "message": "Bu site kayıtlı hesap bilgilerinizle eşleşmiyor. Hesap bilgilerinizi doldurmadan önce sitenin güvenilir olduğundan emin olun." + }, "showInlineMenuLabel": { "message": "Form alanlarında otomatik doldurma önerilerini göster" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Bitwarden verilerinizi kimlik avı saldırılarından nasıl koruyor?" + }, + "currentWebsite": { + "message": "Geçerli web sitesi" + }, + "autofillAndAddWebsite": { + "message": "Otomatik doldur ve bu siteyi ekle" + }, + "autofillWithoutAdding": { + "message": "Eklemeden otomatik doldur" + }, + "doNotAutofill": { + "message": "Otomatik doldurma" + }, "showInlineMenuIdentitiesLabel": { "message": "Kimlikleri öneri olarak göster" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Son kullanma yılı" }, + "monthly": { + "message": "ay" + }, "expiration": { "message": "Son kullanma tarihi" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Bu sayfa Bitwarden deneyimiyle çakışıyor. Güvenlik önlemi olarak Bitwarden satır içi menüsü geçici olarak devre dışı bırakıldı." + }, "setMasterPassword": { "message": "Ana parolayı belirle" }, @@ -2558,7 +2638,7 @@ "message": "Bir kuruluş ilkesi, kayıtları kişisel kasanıza içe aktarmayı engelledi." }, "restrictCardTypeImport": { - "message": "Kart öge türleri içe aktarılamıyor" + "message": "Kart kayıt türleri içe aktarılamıyor" }, "restrictCardTypeImportDesc": { "message": "1 veya daha fazla kuruluş tarafından belirlenen bir ilke, kasalarınıza kart aktarmanızı engelliyor." @@ -2689,14 +2769,14 @@ "message": "Risk altındaki parolaları inceleyin" }, "reviewAtRiskLoginsSlideDesc": { - "message": "Organizasyonunuzun parolaları zayıf, tekrar kullanılmış ve/veya açığa çıkmış olduğu için risk altındadır.", + "message": "Kuruluş parolaların zayıf, yeniden kullanılmış ve/veya ele geçirilmiş olduğundan risk altındadır.", "description": "Description of the review at-risk login slide on the at-risk password page carousel" }, "reviewAtRiskLoginSlideImgAltPeriod": { "message": "Risk altındaki hesap listesinin illüstrasyonu." }, "generatePasswordSlideDesc": { - "message": "Risk altındaki sitede Bitwarden otomatik doldurma menüsü ile hızlıca güçlü ve benzersiz bir parola oluşturun.", + "message": "Riskli sitede Bitwarden otomatik doldurma menüsünü kullanarak hızlıca güçlü ve benzersiz bir parola oluştur.", "description": "Description of the generate password slide on the at-risk password page carousel" }, "generatePasswordSlideImgAltPeriod": { @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Yalnızca $ORGANIZATION$ ile ilişkili kuruluş kasası dışa aktarılacaktır.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Yalnızca $ORGANIZATION$ ile ilişkili kuruluş kasası dışa aktarılacaktır. Kayıt koleksiyonlarım dahil edilmeyecektir.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Hata" }, "decryptionError": { "message": "Şifre çözme sorunu" }, + "errorGettingAutoFillData": { + "message": "Otomatik doldurma verileri alınırken hata oluştu" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden aşağıdaki kasa öğelerini deşifre edemedi." }, @@ -3970,6 +4071,15 @@ "message": "Sayfa yüklenince otomatik doldurma, varsayılan ayarı kullanacak şekilde ayarlandı.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Otomatik doldurulamıyor" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Tamam" + }, "toggleSideNavigation": { "message": "Kenar menüsünü aç/kapat" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Ücretsiz kuruluşlar dosya eklerini kullanamaz" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Varsayılan ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "$WEBSITE$ eşleşme tespitini göster", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Kasanıza hoş geldiniz!" }, - "phishingPageTitle": { - "message": "Dolandırıcılık web sitesi" + "phishingPageTitleV2": { + "message": "Dolandırıcılık girişimi tespit edildi" }, - "phishingPageCloseTab": { - "message": "Sekmeyi kapat" + "phishingPageSummary": { + "message": "Girmeye çalıştığınız site kötü amaçlı ve güvenlik riski taşıyan bir sitedir." }, - "phishingPageContinue": { - "message": "Devam et" + "phishingPageCloseTabV2": { + "message": "Bu sekmeyi kapat" }, - "phishingPageLearnWhy": { - "message": "Bunu neden görüyorsunuz?" + "phishingPageContinueV2": { + "message": "Siteye devam et (önerilmez)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "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.", + "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" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Geçerli sayfada kayıtları otomatik doldurun" @@ -5629,7 +5772,7 @@ "message": "Bu ayar hakkında" }, "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." + "message": "Bitwarden, deneyimini iyileştirmek için hangi simgenin veya parola değiştirme URL’sinin kullanılacağını belirlemek amacıyla kaydedilmiş hesap URI’lerini kullanır. Bu hizmeti kullandığında hiçbir bilgi toplanmaz veya kaydedilmez." }, "noPermissionsViewPage": { "message": "Bu sayfayı görüntüleme izniniz yok. Farklı bir hesapla giriş yapmayı deneyin." @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Key Connector alan adını doğrulayın" + }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, + "upgradeNow": { + "message": "Şimdi yükselt" + }, + "builtInAuthenticator": { + "message": "Dahili kimlik doğrulayıcı" + }, + "secureFileStorage": { + "message": "Güvenli dosya depolama" + }, + "emergencyAccess": { + "message": "Acil durum erişimi" + }, + "breachMonitoring": { + "message": "İhlal izleme" + }, + "andMoreFeatures": { + "message": "Ve daha fazlası!" + }, + "planDescPremium": { + "message": "Eksiksiz çevrimiçi güvenlik" + }, + "upgradeToPremium": { + "message": "Premium'a yükselt" + }, + "unlockAdvancedSecurity": { + "message": "Gelişmiş güvenlik özelliklerinin kilidini açın" + }, + "unlockAdvancedSecurityDesc": { + "message": "Premium abonelik size daha fazla güvenlik ve kontrol olanağı sunan ek araçlara erişmenizi sağlar" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Kasa yükleniyor" + }, + "vaultLoaded": { + "message": "Kasa yüklendi" + }, + "settingDisabledByPolicy": { + "message": "This setting is disabled by your organization's policy.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / posta kodu" + }, + "cardNumberLabel": { + "message": "Kart numarası" + }, + "sessionTimeoutSettingsAction": { + "message": "Zaman aşımı eylemi" } } diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index f088e610051..a1922a5abd8 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Використати єдиний вхід" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Ваша організація вимагає єдиний вхід (SSO)." + }, "welcomeBack": { "message": "З поверненням" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Скинути пошук" }, - "archive": { - "message": "Архівувати" + "archiveNoun": { + "message": "Архів", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Архівувати", + "description": "Verb" + }, + "unArchive": { "message": "Видобути" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Архівовані записи з'являтимуться тут і будуть виключені з результатів звичайного пошуку та пропозицій автозаповнення." }, - "itemSentToArchive": { - "message": "Запис переміщено до архіву" + "itemWasSentToArchive": { + "message": "Запис архівовано" }, - "itemRemovedFromArchive": { - "message": "Запис вилучено з архіву" + "itemUnarchived": { + "message": "Запис розархівовано" }, "archiveItem": { "message": "Архівувати запис" @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Архівовані записи виключаються з результатів звичайного пошуку та пропозицій автозаповнення. Ви дійсно хочете архівувати цей запис?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Змінити" }, "view": { "message": "Переглянути" }, + "viewAll": { + "message": "Переглянути все" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "Переглянути запис" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Неправильний головний пароль" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Неправильний головний пароль. Перевірте правильність адреси електронної пошти та розміщення облікового запису на $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Час очікування сховища" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "З блокуванням системи" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "З перезапуском браузера" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Запис збережено" }, + "savedWebsite": { + "message": "Збережений вебсайт" + }, + "savedWebsites": { + "message": "Збережені вебсайти ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Ви дійсно хочете перенести до смітника?" }, @@ -1408,22 +1455,22 @@ "message": "Застаріле шифрування більше не підтримується. Зверніться до служби підтримки, щоб відновити обліковий запис." }, "premiumMembership": { - "message": "Преміум статус" + "message": "Передплата Premium" }, "premiumManage": { "message": "Керувати передплатою" }, "premiumManageAlert": { - "message": "Ви можете керувати своїм статусом у сховищі на bitwarden.com. Хочете перейти на вебсайт зараз?" + "message": "Ви можете керувати передплатою у сховищі на bitwarden.com. Хочете перейти на вебсайт зараз?" }, "premiumRefresh": { "message": "Оновити стан передплати" }, "premiumNotCurrentMember": { - "message": "Зараз у вас немає передплати преміум." + "message": "Зараз у вас немає передплати Premium." }, "premiumSignUpAndGet": { - "message": "Передплатіть преміум і отримайте:" + "message": "Передплатіть Premium і отримайте:" }, "ppremiumSignUpStorage": { "message": "1 ГБ зашифрованого сховища для файлів." @@ -1444,25 +1491,25 @@ "message": "Пріоритетну технічну підтримку." }, "ppremiumSignUpFuture": { - "message": "Усі майбутні преміумфункції. Їх буде більше!" + "message": "Усі майбутні функції Premium. Їх буде більше!" }, "premiumPurchase": { - "message": "Придбати преміум" + "message": "Придбати Premium" }, "premiumPurchaseAlertV2": { - "message": "Ви можете придбати Преміум у налаштуваннях облікового запису вебпрограмі Bitwarden." + "message": "Ви можете придбати Premium у налаштуваннях облікового запису вебпрограми Bitwarden." }, "premiumCurrentMember": { - "message": "Ви користуєтеся передплатою преміум!" + "message": "Ви користуєтеся передплатою Premium!" }, "premiumCurrentMemberThanks": { "message": "Дякуємо за підтримку Bitwarden." }, "premiumFeatures": { - "message": "Передплатіть преміум та отримайте:" + "message": "Передплатіть Premium та отримайте:" }, "premiumPrice": { - "message": "Всього лише $PRICE$ / за рік!", + "message": "Лише $PRICE$ / рік!", "placeholders": { "price": { "content": "$1", @@ -1471,7 +1518,7 @@ } }, "premiumPriceV2": { - "message": "Усе лише за $PRICE$ на рік!", + "message": "Лише за $PRICE$ на рік за все!", "placeholders": { "price": { "content": "$1", @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Запитувати біометрію під час запуску" }, - "premiumRequired": { - "message": "Необхідна передплата преміум" - }, - "premiumRequiredDesc": { - "message": "Для використання цієї функції необхідна передплата преміум." - }, "authenticationTimeout": { "message": "Час очікування автентифікації" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Зчитати ключ безпеки" }, + "readingPasskeyLoading": { + "message": "Читання ключа доступу..." + }, + "passkeyAuthenticationFailed": { + "message": "Збій автентифікації ключа доступу" + }, + "useADifferentLogInMethod": { + "message": "Використати інший спосіб входу" + }, "awaitingSecurityKeyInteraction": { "message": "Очікується взаємодія з ключем безпеки..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Необхідно додати URL-адресу основного сервера, або принаймні одне користувацьке середовище." }, + "selfHostedEnvMustUseHttps": { + "message": "URL-адреси повинні бути HTTPS." + }, "customEnvironment": { "message": "Власне середовище" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Вимкніть автозаповнення" }, + "confirmAutofill": { + "message": "Підтвердити автозаповнення" + }, + "confirmAutofillDesc": { + "message": "Адреса цього вебсайту відрізняється від збережених даних вашого запису. Перш ніж заповнити облікові дані, переконайтеся, що це надійний сайт." + }, "showInlineMenuLabel": { "message": "Пропозиції автозаповнення на полях форм" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Як Bitwarden захищає ваші дані від шахрайства?" + }, + "currentWebsite": { + "message": "Поточний вебсайт" + }, + "autofillAndAddWebsite": { + "message": "Автоматично заповнити й додати цей сайт" + }, + "autofillWithoutAdding": { + "message": "Автоматично заповнити без додавання" + }, + "doNotAutofill": { + "message": "Не заповнювати автоматично" + }, "showInlineMenuIdentitiesLabel": { "message": "Показувати посвідчення як пропозиції" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Рік завершення" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Термін дії" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Встановити головний пароль" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Буде експортовано лише сховище організації, пов'язане з $ORGANIZATION$.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Буде експортовано лише сховище організації, пов'язане з $ORGANIZATION$. Записи моїх збірок не будуть включені.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Помилка" }, "decryptionError": { "message": "Помилка розшифрування" }, + "errorGettingAutoFillData": { + "message": "Помилка отримання даних автозаповнення" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden не зміг розшифрувати вказані нижче елементи сховища." }, @@ -3429,7 +3530,7 @@ "message": "Помилка Key Connector: переконайтеся, що Key Connector доступний та працює правильно." }, "premiumSubcriptionRequired": { - "message": "Необхідна передплата преміум" + "message": "Необхідна передплата Premium" }, "organizationIsDisabled": { "message": "Організацію вимкнено." @@ -3970,6 +4071,15 @@ "message": "Автозаповнення на сторінці налаштовано з типовими параметрами.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Неможливо автоматично заповнити" + }, + "cannotAutofillExactMatch": { + "message": "Типово налаштовано \"Точну відповідність\". Адреса поточного вебсайту відрізняється від збережених даних для цього запису." + }, + "okay": { + "message": "Гаразд" + }, "toggleSideNavigation": { "message": "Перемкнути бічну навігацію" }, @@ -4799,7 +4909,10 @@ "message": "Ви дійсно хочете остаточно видалити це вкладення?" }, "premium": { - "message": "Преміум" + "message": "Premium" + }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." }, "freeOrgsCannotUseAttachments": { "message": "Організації без передплати не можуть використовувати вкладення" @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Показати виявлення збігів $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Вітаємо у вашому сховищі!" }, - "phishingPageTitle": { - "message": "Шахрайський вебсайт" + "phishingPageTitleV2": { + "message": "Виявлено спробу шахрайства" }, - "phishingPageCloseTab": { - "message": "Закрити вкладку" + "phishingPageSummary": { + "message": "Ви намагаєтеся відвідати відомий зловмисний вебсайт, який має ризики безпеки." }, - "phishingPageContinue": { - "message": "Продовжити" + "phishingPageCloseTabV2": { + "message": "Закрити цю вкладку" }, - "phishingPageLearnWhy": { - "message": "Чому ви це бачите?" + "phishingPageContinueV2": { + "message": "Перейти на цей сайт (не рекомендується)" + }, + "phishingPageExplanation1": { + "message": "Цей сайт знайдено в ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": "– відкритий список відомих шахрайських сайтів, що використовуються для викрадення особистої та конфіденційної інформації.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Докладніше про виявлення шахрайства" + }, + "protectedBy": { + "message": "Захищено $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Автозаповнення записів для поточної сторінки" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Підтвердити домен Key Connector" + }, + "atRiskLoginsSecured": { + "message": "Ви чудово впоралися із захистом своїх ризикованих записів!" + }, + "upgradeNow": { + "message": "Покращити" + }, + "builtInAuthenticator": { + "message": "Вбудований автентифікатор" + }, + "secureFileStorage": { + "message": "Захищене сховище файлів" + }, + "emergencyAccess": { + "message": "Екстрений доступ" + }, + "breachMonitoring": { + "message": "Моніторинг витоків даних" + }, + "andMoreFeatures": { + "message": "Інші можливості!" + }, + "planDescPremium": { + "message": "Повна онлайн-безпека" + }, + "upgradeToPremium": { + "message": "Покращити до Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "Цей параметр вимкнено політикою вашої організації.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Поштовий індекс" + }, + "cardNumberLabel": { + "message": "Номер картки" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index 06920433037..415cf474af0 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Dùng đăng nhập một lần" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Tổ chức của bạn yêu cầu đăng nhập một lần." + }, "welcomeBack": { "message": "Chào mừng bạn trở lại" }, @@ -550,10 +553,15 @@ "resetSearch": { "message": "Đặt lại tìm kiếm" }, - "archive": { - "message": "Lưu trữ" + "archiveNoun": { + "message": "Lưu trữ", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Lưu trữ", + "description": "Verb" + }, + "unArchive": { "message": "Hủy lưu trữ" }, "itemsInArchive": { @@ -565,11 +573,11 @@ "noItemsInArchiveDesc": { "message": "Các mục đã lưu trữ sẽ hiển thị ở đây và sẽ bị loại khỏi kết quả tìm kiếm và gợi ý tự động điền." }, - "itemSentToArchive": { - "message": "Mục đã được gửi đến kho lưu trữ" + "itemWasSentToArchive": { + "message": "Mục đã được chuyển vào kho lưu trữ" }, - "itemRemovedFromArchive": { - "message": "Mục đã được gỡ khỏi kho lưu trữ" + "itemUnarchived": { + "message": "Mục đã được bỏ lưu trữ" }, "archiveItem": { "message": "Lưu trữ mục" @@ -577,12 +585,24 @@ "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?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Sửa" }, "view": { "message": "Xem" }, + "viewAll": { + "message": "Xem tất cả" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "Xem đăng nhập" }, @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "Mật khẩu chính không hợp lệ" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Mật khẩu chính không hợp lệ. Xác nhận email của bạn là chính xác và tài khoản được tạo trên $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "Đóng kho sau" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "Mỗi khi khóa máy" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Mỗi khi khởi động lại trình duyệt" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "Đã lưu mục" }, + "savedWebsite": { + "message": "Đã lưu trang web" + }, + "savedWebsites": { + "message": "Đã lưu trang web ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Bạn có chắc muốn cho nó vào thùng rác?" }, @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "Yêu cầu sinh trắc học khi khởi chạy" }, - "premiumRequired": { - "message": "Cần có tài khoản Cao cấp" - }, - "premiumRequiredDesc": { - "message": "Cần là thành viên Cao cấp để sử dụng tính năng này." - }, "authenticationTimeout": { "message": "Thời gian chờ xác thực" }, @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "Đọc khóa bảo mật" }, + "readingPasskeyLoading": { + "message": "Đang đọc mã khoá..." + }, + "passkeyAuthenticationFailed": { + "message": "Xác thực mã khóa thất bại" + }, + "useADifferentLogInMethod": { + "message": "Dùng phương thức đăng nhập khác" + }, "awaitingSecurityKeyInteraction": { "message": "Đang chờ tương tác với khóa bảo mật..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "Bạn phải thêm URL máy chủ cơ sở hoặc ít nhất một môi trường tùy chỉnh." }, + "selfHostedEnvMustUseHttps": { + "message": "URL phải sử dụng HTTPS." + }, "customEnvironment": { "message": "Môi trường tùy chỉnh" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "Tắt tự động điền" }, + "confirmAutofill": { + "message": "Xác nhận tự động điền" + }, + "confirmAutofillDesc": { + "message": "Trang web này không khớp với đăng nhập đã lưu của bạn. Trước khi bạn điền thông tin đăng nhập, hãy đảm bảo đây là trang web đáng tin cậy." + }, "showInlineMenuLabel": { "message": "Hiển thị các gợi ý tự động điền trên các trường biểu mẫu" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Bitwarden bảo vệ dữ liệu của bạn khỏi lừa đảo như thế nào?" + }, + "currentWebsite": { + "message": "Trang web hiện tại" + }, + "autofillAndAddWebsite": { + "message": "Tự động điền và thêm trang web này" + }, + "autofillWithoutAdding": { + "message": "Tự động điền mà không thêm" + }, + "doNotAutofill": { + "message": "Không tự động điền" + }, "showInlineMenuIdentitiesLabel": { "message": "Hiển thị danh tính dưới dạng gợi ý" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "Năm hết hạn" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Hết hạn" }, @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Đặt mật khẩu chính" }, @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Chỉ kho lưu trữ tổ chức liên kết với $ORGANIZATION$ sẽ được xuất.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Chỉ kho lưu trữ tổ chức liên kết với $ORGANIZATION$ được xuất. Bộ sưu tập mục của tôi sẽ không được bao gồm.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Lỗi" }, "decryptionError": { "message": "Lỗi giải mã" }, + "errorGettingAutoFillData": { + "message": "Lỗi khi lấy dữ liệu tự động điền" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden không thể giải mã các mục trong kho lưu trữ được liệt kê bên dưới." }, @@ -3970,6 +4071,15 @@ "message": "Tự động điền khi tải trang được đặt thành mặc định trong cài đặt.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Không thể tự động điền" + }, + "cannotAutofillExactMatch": { + "message": "Phép so khớp mặc định được đặt thành \"Khớp chính xác\". Trang web hiện tại không khớp chính xác với đăng nhập đã lưu cho mục này." + }, + "okay": { + "message": "Đồng ý" + }, "toggleSideNavigation": { "message": "Ẩn/hiện thanh điều hướng bên" }, @@ -4801,6 +4911,9 @@ "premium": { "message": "Cao cấp" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Các tổ chức miễn phí không thể sử dụng tệp đính kèm" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Default ( $VALUE$ )", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "Hiện phát hiện trùng khớp $WEBSITE$", "placeholders": { @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "Chào mừng đến với kho lưu trữ của bạn!" }, - "phishingPageTitle": { - "message": "Trang web lừa đảo" + "phishingPageTitleV2": { + "message": "Đã phát hiện nỗ lực lừa đảo" }, - "phishingPageCloseTab": { - "message": "Đóng tab" + "phishingPageSummary": { + "message": "Trang web bạn đang cố gắng truy cập là một trang độc hại đã được báo cáo và có nguy cơ bảo mật." }, - "phishingPageContinue": { - "message": "Tiếp tục" + "phishingPageCloseTabV2": { + "message": "Đóng thẻ này" }, - "phishingPageLearnWhy": { - "message": "Tại sao bạn thấy điều này?" + "phishingPageContinueV2": { + "message": "Tiếp tục truy cập trang web này (không khuyến khích)" + }, + "phishingPageExplanation1": { + "message": "Trang web này được tìm thấy trong ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", một danh sách nguồn mở các trang lừa đảo đã biết được sử dụng để đánh cắp thông tin cá nhân và nhạy cảm.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "Tìm hiểu thêm về phát hiện lừa đảo" + }, + "protectedBy": { + "message": "Được bảo vệ bởi $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Tự động điền các mục cho trang hiện tại" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "Xác nhận tên miền Key Connector" + }, + "atRiskLoginsSecured": { + "message": "Thật tuyệt khi bảo vệ các đăng nhập có nguy cơ của bạn!" + }, + "upgradeNow": { + "message": "Nâng cấp ngay" + }, + "builtInAuthenticator": { + "message": "Trình xác thực tích hợp" + }, + "secureFileStorage": { + "message": "Lưu trữ tệp an toàn" + }, + "emergencyAccess": { + "message": "Truy cập khẩn cấp" + }, + "breachMonitoring": { + "message": "Giám sát vi phạm" + }, + "andMoreFeatures": { + "message": "Và nhiều hơn nữa!" + }, + "planDescPremium": { + "message": "Bảo mật trực tuyến toàn diện" + }, + "upgradeToPremium": { + "message": "Nâng cấp lên gói Cao cấp" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "Explore Premium" + }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, + "settingDisabledByPolicy": { + "message": "Cài đặt này bị vô hiệu hóa bởi chính sách tổ chức của bạn.", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Mã ZIP / Bưu điện" + }, + "cardNumberLabel": { + "message": "Số thẻ" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index eb3cf1aa901..92bcb8cb90f 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "使用单点登录" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "您的组织要求单点登录。" + }, "welcomeBack": { "message": "欢迎回来" }, @@ -306,7 +309,7 @@ "message": "前往浏览器扩展商店吗?" }, "continueToBrowserExtensionStoreDesc": { - "message": "帮助别人了解 Bitwarden 是否适合他们。立即访问浏览器的扩展程序商店并留下评分。" + "message": "帮助别人了解 Bitwarden 是否适合他们。立即访问浏览器扩展商店并留下评分。" }, "changeMasterPasswordOnWebConfirmation": { "message": "您可以在 Bitwarden 网页 App 上更改您的主密码。" @@ -359,10 +362,10 @@ "message": "使用 Passwordless.dev 摆脱传统密码束缚,打造流畅且安全的登录体验。访问 bitwarden.com 网站了解更多信息。" }, "freeBitwardenFamilies": { - "message": "免费 Bitwarden 家庭" + "message": "免费的 Bitwarden 家庭版" }, "freeBitwardenFamiliesPageDesc": { - "message": "您有资格获得免费的 Bitwarden 家庭。立即在网页 App 中兑换此优惠。" + "message": "您有资格获得免费的 Bitwarden 家庭版。立即在网页 App 中兑换此优惠。" }, "version": { "message": "版本" @@ -407,7 +410,7 @@ "message": "创建文件夹以整理您的密码库项目" }, "deleteFolderPermanently": { - "message": "您确定要永久删除这个文件夹吗?" + "message": "确定要永久删除此文件夹吗?" }, "deleteFolder": { "message": "删除文件夹" @@ -550,10 +553,15 @@ "resetSearch": { "message": "重置搜索" }, - "archive": { - "message": "归档" + "archiveNoun": { + "message": "归档", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "归档", + "description": "Verb" + }, + "unArchive": { "message": "取消归档" }, "itemsInArchive": { @@ -565,10 +573,10 @@ "noItemsInArchiveDesc": { "message": "已归档的项目将显示在此处,并将被排除在一般搜索结果和自动填充建议之外。" }, - "itemSentToArchive": { - "message": "项目已归档" + "itemWasSentToArchive": { + "message": "项目已发送到归档" }, - "itemRemovedFromArchive": { + "itemUnarchived": { "message": "项目已取消归档" }, "archiveItem": { @@ -577,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "已归档的项目将被排除在一般搜索结果和自动填充建议之外。确定要归档此项目吗?" }, + "upgradeToUseArchive": { + "message": "需要高级会员才能使用归档。" + }, "edit": { "message": "编辑" }, "view": { "message": "查看" }, + "viewAll": { + "message": "查看全部" + }, + "showAll": { + "message": "显示全部" + }, + "viewLess": { + "message": "查看更少" + }, "viewLogin": { "message": "查看登录" }, @@ -677,7 +697,7 @@ "message": "会话超时" }, "vaultTimeoutHeader": { - "message": "密码库超时时间" + "message": "密码库超时" }, "otherOptions": { "message": "其他选项" @@ -728,11 +748,20 @@ "invalidMasterPassword": { "message": "无效的主密码" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "无效的主密码。请确认您的电子邮箱正确无误,以及您的账户是在 $HOST$ 上创建的。", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { - "message": "密码库超时时间" + "message": "密码库超时" }, "vaultTimeout1": { - "message": "超时" + "message": "超时时间" }, "lockNow": { "message": "立即锁定" @@ -776,6 +805,12 @@ "onLocked": { "message": "系统锁定时" }, + "onIdle": { + "message": "系统空闲时" + }, + "onSleep": { + "message": "系统睡眠时" + }, "onRestart": { "message": "浏览器重启时" }, @@ -1014,6 +1049,18 @@ "editedItem": { "message": "项目已保存" }, + "savedWebsite": { + "message": "保存的网站" + }, + "savedWebsites": { + "message": "保存的网站 ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "您确定要将其发送到回收站吗?" }, @@ -1134,7 +1181,7 @@ "description": "Shown to user after item is updated." }, "selectItemAriaLabel": { - "message": "选择 $ITEMTYPE$,$ITEMNAME$", + "message": "选择 $ITEMNAME$ 中的 $ITEMTYPE$", "description": "Used by screen readers. $1 is the item type (like vault or folder), $2 is the selected item name.", "placeholders": { "itemType": { @@ -1207,7 +1254,7 @@ "description": "Detailed error message shown when saving login details fails." }, "changePasswordWarning": { - "message": "更改密码后,您需要使用新密码登录。 在其他设备上的活动会话将在一小时内注销。" + "message": "更改密码后,您需要使用新密码登录。在其他设备上的活动会话将在一小时内注销。" }, "accountRecoveryUpdateMasterPasswordSubtitle": { "message": "更改您的主密码以完成账户恢复。" @@ -1444,7 +1491,7 @@ "message": "优先客户支持。" }, "ppremiumSignUpFuture": { - "message": "未来的更多高级功能。敬请期待!" + "message": "未来的更多高级版功能。敬请期待!" }, "premiumPurchase": { "message": "购买高级版" @@ -1491,17 +1538,11 @@ "enableAutoBiometricsPrompt": { "message": "启动时提示生物识别" }, - "premiumRequired": { - "message": "需要高级会员" - }, - "premiumRequiredDesc": { - "message": "使用此功能需要高级会员资格。" - }, "authenticationTimeout": { "message": "身份验证超时" }, "authenticationSessionTimedOut": { - "message": "身份验证会话超时。请重新启动登录过程。" + "message": "身份验证会话超时。请重新开始登录过程。" }, "verificationCodeEmailSent": { "message": "验证邮件已发送到 $EMAIL$。", @@ -1534,6 +1575,15 @@ "readSecurityKey": { "message": "读取安全密钥" }, + "readingPasskeyLoading": { + "message": "正在读取通行密钥..." + }, + "passkeyAuthenticationFailed": { + "message": "通行密钥验证失败" + }, + "useADifferentLogInMethod": { + "message": "使用其他登录方式" + }, "awaitingSecurityKeyInteraction": { "message": "等待安全密钥交互..." }, @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "您必须添加基础服务器 URL 或至少添加一个自定义环境。" }, + "selfHostedEnvMustUseHttps": { + "message": "URL 必须使用 HTTPS。" + }, "customEnvironment": { "message": "自定义环境" }, @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "停用自动填充" }, + "confirmAutofill": { + "message": "确认自动填充" + }, + "confirmAutofillDesc": { + "message": "此网站与您保存的登录信息不匹配。在填写您的登录凭据之前,请确保它是一个可信的网站。" + }, "showInlineMenuLabel": { "message": "在表单字段中显示自动填充建议" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Bitwarden 如何保护您的数据免遭网络钓鱼?" + }, + "currentWebsite": { + "message": "当前网站" + }, + "autofillAndAddWebsite": { + "message": "自动填充并添加此网站" + }, + "autofillWithoutAdding": { + "message": "自动填充但不添加" + }, + "doNotAutofill": { + "message": "不自动填充" + }, "showInlineMenuIdentitiesLabel": { "message": "将身份显示为建议" }, @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "过期年份" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "有效期" }, @@ -1917,7 +1994,7 @@ "message": "州 / 省" }, "zipPostalCode": { - "message": "邮政编码" + "message": "ZIP / 邮政编码" }, "country": { "message": "国家" @@ -2035,7 +2112,7 @@ "message": "若继续,所有条目将从生成器历史记录中永久删除。确定要继续吗?" }, "back": { - "message": "后退" + "message": "返回" }, "collections": { "message": "集合" @@ -2333,7 +2410,7 @@ "message": "已经有账户了吗?" }, "vaultTimeoutLogOutConfirmation": { - "message": "超时后注销账户将解除对密码库的所有访问权限,并需要进行在线身份验证。确定使用此设置吗?" + "message": "超时后注销账户将解除对密码库的所有访问权限,并需要进行在线身份验证。确定要使用此设置吗?" }, "vaultTimeoutLogOutConfirmationTitle": { "message": "超时动作确认" @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "此页面正在干扰 Bitwarden 的使用体验。出于安全考虑,Bitwarden 内嵌菜单已被暂时禁用。" + }, "setMasterPassword": { "message": "设置主密码" }, @@ -2835,7 +2915,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": { @@ -2931,7 +3011,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendFilePopoutDialogText": { - "message": "弹出扩展?", + "message": "弹出扩展吗?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendFilePopoutDialogDesc": { @@ -3051,7 +3131,7 @@ "message": "企业策略要求已应用到您的超时选项中" }, "vaultTimeoutPolicyInEffect": { - "message": "您的组织策略已将您最大允许的密码库超时时间设置为 $HOURS$ 小时 $MINUTES$ 分钟。", + "message": "您的组织策略已将您最大允许的密码库超时设置为 $HOURS$ 小时 $MINUTES$ 分钟。", "placeholders": { "hours": { "content": "$1", @@ -3077,7 +3157,7 @@ } }, "vaultTimeoutPolicyMaximumError": { - "message": "超时时间超出了您组织设置的限制:最多 $HOURS$ 小时 $MINUTES$ 分钟", + "message": "超时超出了您组织设置的限制:最多 $HOURS$ 小时 $MINUTES$ 分钟", "placeholders": { "hours": { "content": "$1", @@ -3090,7 +3170,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "您的组织策略正在影响您的密码库超时时间。最大允许的密码库超时时间是 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", + "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", "placeholders": { "hours": { "content": "$1", @@ -3107,7 +3187,7 @@ } }, "vaultTimeoutActionPolicyInEffect": { - "message": "您的组织策略已将您的密码库超时动作设置为 $ACTION$。", + "message": "您的组织策略已将您的密码库超时动作设置为「$ACTION$」。", "placeholders": { "action": { "content": "$1", @@ -3116,7 +3196,7 @@ } }, "vaultTimeoutTooLarge": { - "message": "您的密码库超时时间超出了组织设置的限制。" + "message": "您的密码库超时超出了您组织设置的限制。" }, "vaultExportDisabled": { "message": "密码库导出已禁用" @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "仅会导出与 $ORGANIZATION$ 关联的组织密码库。", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "仅会导出与 $ORGANIZATION$ 关联的组织密码库。不包括「我的项目」集合。", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "错误" }, "decryptionError": { "message": "解密错误" }, + "errorGettingAutoFillData": { + "message": "获取自动填充数据时出错" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden 无法解密下列密码库项目。" }, @@ -3646,7 +3747,7 @@ "message": "当前会话" }, "mobile": { - "message": "移动", + "message": "移动端", "description": "Mobile app" }, "extension": { @@ -3970,6 +4071,15 @@ "message": "页面加载时自动填充设置为使用默认设置。", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "无法自动填充" + }, + "cannotAutofillExactMatch": { + "message": "默认匹配被设置为「精确匹配」。当前网站与此项目保存的登录信息不完全匹配。" + }, + "okay": { + "message": "确定" + }, "toggleSideNavigation": { "message": "切换侧边导航" }, @@ -4622,7 +4732,7 @@ } }, "copyFieldCipherName": { - "message": "复制 $CIPHERNAME$ 的 $FIELD$", + "message": "复制 $CIPHERNAME$ 中的 $FIELD$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -4796,10 +4906,13 @@ "message": "从 App Store 下载" }, "permanentlyDeleteAttachmentConfirmation": { - "message": "您确定要永久删除此附件吗?" + "message": "确定要永久删除此附件吗?" }, "premium": { - "message": "高级会员" + "message": "高级版" + }, + "unlockFeaturesWithPremium": { + "message": "使用高级版解锁报告、紧急访问以及更多安全功能。" }, "freeOrgsCannotUseAttachments": { "message": "免费组织无法使用附件" @@ -4876,7 +4989,17 @@ "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": { + "content": "$1", + "example": "Base domain" + } + } + }, + "defaultLabelWithValue": { + "message": "默认($VALUE$)", "description": "A label that indicates the default value for a field with the current default value in parentheses.", "placeholders": { "value": { @@ -5197,7 +5320,7 @@ "message": "重试" }, "vaultCustomTimeoutMinimum": { - "message": "自定义超时时间最小为 1 分钟。" + "message": "自定义超时最少为 1 分钟。" }, "fileSavedToDevice": { "message": "文件已保存到设备。可以在设备下载中进行管理。" @@ -5500,7 +5623,7 @@ "message": "欢迎使用 Bitwarden" }, "securityPrioritized": { - "message": "安全优先" + "message": "以安全为首要" }, "securityPrioritizedBody": { "message": "将登录、支付卡和身份保存到您的安全密码库。Bitwarden 使用零知识、端到端的加密来保护您的重要信息。" @@ -5538,17 +5661,37 @@ "hasItemsVaultNudgeTitle": { "message": "欢迎来到您的密码库!" }, - "phishingPageTitle": { - "message": "钓鱼网站" + "phishingPageTitleV2": { + "message": "检测到钓鱼尝试" }, - "phishingPageCloseTab": { - "message": "关闭标签页" + "phishingPageSummary": { + "message": "您尝试访问的网站是一个已知的恶意网站,存在安全风险。" }, - "phishingPageContinue": { - "message": "继续" + "phishingPageCloseTabV2": { + "message": "关闭此标签页" }, - "phishingPageLearnWhy": { - "message": "为什么您会看到这个?" + "phishingPageContinueV2": { + "message": "继续访问此网站(不推荐)" + }, + "phishingPageExplanation1": { + "message": "该网站被发现于 ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ",一个已知的用于窃取个人和敏感信息的钓鱼网站的开源列表。", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "进一步了解钓鱼检测" + }, + "protectedBy": { + "message": "受 $PRODUCT$ 保护", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "为当前页面自动填充项目" @@ -5653,5 +5796,60 @@ }, "confirmKeyConnectorDomain": { "message": "确认 Key Connector 域名" + }, + "atRiskLoginsSecured": { + "message": "很好地保护了存在风险的登录!" + }, + "upgradeNow": { + "message": "立即升级" + }, + "builtInAuthenticator": { + "message": "内置身份验证器" + }, + "secureFileStorage": { + "message": "安全文件存储" + }, + "emergencyAccess": { + "message": "紧急访问" + }, + "breachMonitoring": { + "message": "数据泄露监测" + }, + "andMoreFeatures": { + "message": "以及更多!" + }, + "planDescPremium": { + "message": "全面的在线安全防护" + }, + "upgradeToPremium": { + "message": "升级为高级版" + }, + "unlockAdvancedSecurity": { + "message": "解锁高级安全功能" + }, + "unlockAdvancedSecurityDesc": { + "message": "高级版订阅为您提供更多工具,助您保持安全并掌控一切" + }, + "explorePremium": { + "message": "探索高级版" + }, + "loadingVault": { + "message": "正在加载密码库" + }, + "vaultLoaded": { + "message": "密码库已加载" + }, + "settingDisabledByPolicy": { + "message": "此设置被您组织的策略禁用了。", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / 邮政编码" + }, + "cardNumberLabel": { + "message": "卡号" + }, + "sessionTimeoutSettingsAction": { + "message": "超时动作" } } diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index f5801fb2c7d..38fb50d6c8b 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": { @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "使用單一登入" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "您的組織需要單一登入。" + }, "welcomeBack": { "message": "歡迎回來" }, @@ -84,7 +87,7 @@ "message": "主密碼提示(選用)" }, "passwordStrengthScore": { - "message": "Password strength score $SCORE$", + "message": "密碼強度分數 $SCORE$", "placeholders": { "score": { "content": "$1", @@ -255,7 +258,7 @@ "message": "新增項目" }, "accountEmail": { - "message": "帳戶電子郵件" + "message": "帳號電子郵件" }, "requestHint": { "message": "請求提示" @@ -294,7 +297,7 @@ "message": "接下來造訪網頁 App 嗎?" }, "continueToWebAppDesc": { - "message": "在 Web 應用程式上探索 Bitwarden 帳戶的更多功能。" + "message": "在 Web 應用程式上探索 Bitwarden 帳號的更多功能。" }, "continueToHelpCenter": { "message": "接下來前往說明中心嗎?" @@ -383,7 +386,7 @@ "message": "編輯資料夾" }, "editFolderWithName": { - "message": "Edit folder: $FOLDERNAME$", + "message": "編輯資料夾:$FOLDERNAME$", "placeholders": { "foldername": { "content": "$1", @@ -468,7 +471,7 @@ "message": "已產生密碼" }, "passphraseGenerated": { - "message": "Passphrase generated" + "message": "已產生密碼" }, "usernameGenerated": { "message": "已產生使用者名稱" @@ -550,32 +553,40 @@ "resetSearch": { "message": "重設搜尋" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "封存", + "description": "Noun" }, - "unarchive": { - "message": "Unarchive" + "archiveVerb": { + "message": "封存", + "description": "Verb" + }, + "unArchive": { + "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": "封存的項目會顯示在此處,且不會出現在一般搜尋結果或自動填入建議中。" }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "項目已移至封存" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "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?" + "message": "封存的項目將不會出現在一般搜尋結果或自動填入建議中。確定要封存此項目嗎?" + }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." }, "edit": { "message": "編輯" @@ -583,8 +594,17 @@ "view": { "message": "檢視" }, + "viewAll": { + "message": "檢視全部" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "顯示較少" + }, "viewLogin": { - "message": "View login" + "message": "檢視登入" }, "noItemsInList": { "message": "沒有可列出的項目。" @@ -728,6 +748,15 @@ "invalidMasterPassword": { "message": "無效的主密碼" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "主密碼無效。請確認你的電子郵件正確,且帳號是於 $HOST$ 建立的。", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "vaultTimeout": { "message": "密碼庫逾時時間" }, @@ -776,6 +805,12 @@ "onLocked": { "message": "於系統鎖定時" }, + "onIdle": { + "message": "系統閒置時" + }, + "onSleep": { + "message": "系統睡眠時" + }, "onRestart": { "message": "於瀏覽器重新啟動時" }, @@ -908,19 +943,19 @@ "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 兩步驟登入。請依照以下步驟完成登入。" }, "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": "重新啟動註冊" @@ -1014,6 +1049,18 @@ "editedItem": { "message": "項目已儲存" }, + "savedWebsite": { + "message": "已儲存的網站" + }, + "savedWebsites": { + "message": "已儲存的網站($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "確定要刪除此項目嗎?" }, @@ -1096,7 +1143,7 @@ "message": "儲存" }, "notificationViewAria": { - "message": "View $ITEMNAME$, opens in new window", + "message": "檢視 $ITEMNAME$(於新視窗中開啟)", "placeholders": { "itemName": { "content": "$1" @@ -1105,18 +1152,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": "新通知" }, "labelWithNotification": { - "message": "$LABEL$: New notification", + "message": "$LABEL$:新通知", "description": "Label for the notification with a new login suggestion.", "placeholders": { "label": { @@ -1134,7 +1181,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": { @@ -1146,7 +1193,7 @@ } }, "saveAsNewLoginAction": { - "message": "Save as new login", + "message": "儲存為新的登入資訊", "description": "Button text for saving login details as a new entry." }, "updateLoginAction": { @@ -1154,7 +1201,7 @@ "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": { @@ -1162,19 +1209,19 @@ "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" @@ -1183,7 +1230,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" @@ -1195,22 +1242,22 @@ "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": { - "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": "詢問更新現有的登入資料" @@ -1405,7 +1452,7 @@ "message": "功能不可用" }, "legacyEncryptionUnsupported": { - "message": "Legacy encryption is no longer supported. Please contact support to recover your account." + "message": "不再支援舊版加密。請聯繫支援團隊以恢復您的帳號。" }, "premiumMembership": { "message": "進階會員" @@ -1491,12 +1538,6 @@ "enableAutoBiometricsPrompt": { "message": "啟動時要求生物特徵辨識" }, - "premiumRequired": { - "message": "需要進階會員資格" - }, - "premiumRequiredDesc": { - "message": "進階會員才可使用此功能。" - }, "authenticationTimeout": { "message": "驗證逾時" }, @@ -1513,7 +1554,7 @@ } }, "dontAskAgainOnThisDeviceFor30Days": { - "message": "Don't ask again on this device for 30 days" + "message": "30 天內不要再於這部裝置上詢問" }, "selectAnotherMethod": { "message": "選擇其他方式", @@ -1534,8 +1575,17 @@ "readSecurityKey": { "message": "讀取安全金鑰" }, + "readingPasskeyLoading": { + "message": "正在讀取通行金鑰..." + }, + "passkeyAuthenticationFailed": { + "message": "通行金鑰驗證失敗" + }, + "useADifferentLogInMethod": { + "message": "使用其他登入方式" + }, "awaitingSecurityKeyInteraction": { - "message": "Awaiting security key interaction..." + "message": "等待安全金鑰操作中……" }, "loginUnavailable": { "message": "登入無法使用" @@ -1600,6 +1650,9 @@ "selfHostedEnvFormInvalid": { "message": "您必須新增伺服器網域 URL 或至少一個自訂環境。" }, + "selfHostedEnvMustUseHttps": { + "message": "URL 必須使用 HTTPS。" + }, "customEnvironment": { "message": "自訂環境" }, @@ -1636,13 +1689,13 @@ "message": "自動填入建議" }, "autofillSpotlightTitle": { - "message": "Easily find autofill suggestions" + "message": "輕鬆找到自動填入建議" }, "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", @@ -1653,9 +1706,30 @@ "turnOffAutofill": { "message": "停用自動填入" }, + "confirmAutofill": { + "message": "確認自動填入" + }, + "confirmAutofillDesc": { + "message": "此網站與您儲存的登入資料不相符。在填入登入憑證前,請確認這是受信任的網站。" + }, "showInlineMenuLabel": { "message": "在表單欄位上顯示自動填入選單" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Bitwarden 如何保護您的資料免於網路釣魚攻擊?" + }, + "currentWebsite": { + "message": "目網站" + }, + "autofillAndAddWebsite": { + "message": "自動填充並新增此網站" + }, + "autofillWithoutAdding": { + "message": "自動填入但不新增" + }, + "doNotAutofill": { + "message": "不要自動填入" + }, "showInlineMenuIdentitiesLabel": { "message": "顯示身分建議" }, @@ -1782,7 +1856,7 @@ "message": "如果您點選彈出式視窗外的任意區域,將導致彈出式視窗關閉。您想在新視窗中開啟此彈出式視窗,以讓它不關閉嗎?" }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "顯示網站圖示並取得變更密碼網址" }, "cardholderName": { "message": "持卡人姓名" @@ -1799,6 +1873,9 @@ "expirationYear": { "message": "逾期年份" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "逾期" }, @@ -1842,7 +1919,7 @@ "message": "安全代碼" }, "cardNumber": { - "message": "card number" + "message": "信用卡號碼" }, "ex": { "message": "例如" @@ -1944,82 +2021,82 @@ "message": "SSH 金鑰" }, "typeNote": { - "message": "Note" + "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": "新增文字 Send", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "新增檔案 Send", "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": "編輯文字 Send", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "編輯檔案 Send", "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" }, "passwordHistory": { @@ -2227,7 +2304,7 @@ "message": "設定您用來解鎖 Bitwarden 的 PIN 碼。您的 PIN 設定將在您完全登出本應用程式時被重設。" }, "setPinCode": { - "message": "You can use this PIN to unlock Bitwarden. Your PIN will be reset if you ever fully log out of the application." + "message": "你可以使用此 PIN 來解鎖 Bitwarden。若你完全登出應用程式,PIN 將會被重設。" }, "pinRequired": { "message": "必須填入 PIN 碼。" @@ -2278,7 +2355,7 @@ "message": "使用此密碼" }, "useThisPassphrase": { - "message": "Use this passphrase" + "message": "使用此密碼" }, "useThisUsername": { "message": "使用此使用者名稱" @@ -2368,6 +2445,9 @@ } } }, + "topLayerHijackWarning": { + "message": "此頁面正在干擾 Bitwarden 的使用體驗。為了安全起見,已暫時停用 Bitwarden 的內嵌選單。" + }, "setMasterPassword": { "message": "設定主密碼" }, @@ -2450,7 +2530,7 @@ "message": "隱私權政策" }, "yourNewPasswordCannotBeTheSameAsYourCurrentPassword": { - "message": "Your new password cannot be the same as your current password." + "message": "你的新密碼不能與目前的密碼相同。" }, "hintEqualsPassword": { "message": "密碼提示不能與您的密碼相同。" @@ -2558,10 +2638,10 @@ "message": "某個組織原則已禁止您將項目匯入至您的個人密碼庫。" }, "restrictCardTypeImport": { - "message": "Cannot import card item types" + "message": "無法匯入卡片項目類別" }, "restrictCardTypeImportDesc": { - "message": "A policy set by 1 or more organizations prevents you from importing cards to your vaults." + "message": "由於一或多個組織設有政策,您無法匯入支付卡至您的密碼庫。" }, "domainsTitle": { "message": "網域", @@ -2571,7 +2651,7 @@ "message": "已封鎖的網域" }, "learnMoreAboutBlockedDomains": { - "message": "Learn more about blocked domains" + "message": "瞭解更多關於被封鎖網域的資訊" }, "excludedDomains": { "message": "排除網域" @@ -2595,11 +2675,11 @@ "message": "變更" }, "changePassword": { - "message": "Change password", + "message": "變更密碼", "description": "Change password button for browser at risk notification on login." }, "changeButtonTitle": { - "message": "Change password - $ITEMNAME$", + "message": "變更密碼 - $ITEMNAME$", "placeholders": { "itemname": { "content": "$1", @@ -2608,13 +2688,13 @@ } }, "atRiskPassword": { - "message": "At-risk password" + "message": "具有風險的密碼" }, "atRiskPasswords": { - "message": "At-risk passwords" + "message": "具有風險的密碼" }, "atRiskPasswordDescSingleOrg": { - "message": "$ORGANIZATION$ is requesting you change one password because it is at-risk.", + "message": "$ORGANIZATION$ 要求你變更一組有風險的密碼。", "placeholders": { "organization": { "content": "$1", @@ -2623,7 +2703,7 @@ } }, "atRiskPasswordsDescSingleOrgPlural": { - "message": "$ORGANIZATION$ is requesting you change the $COUNT$ passwords because they are at-risk.", + "message": "$ORGANIZATION$ 要求你變更 $COUNT$ 組有風險的密碼。", "placeholders": { "organization": { "content": "$1", @@ -2636,7 +2716,7 @@ } }, "atRiskPasswordsDescMultiOrgPlural": { - "message": "Your organizations are requesting you change the $COUNT$ passwords because they are at-risk.", + "message": "你的組織要求你變更 $COUNT$ 組有風險的密碼。", "placeholders": { "count": { "content": "$1", @@ -2645,7 +2725,7 @@ } }, "atRiskChangePrompt": { - "message": "Your password for this site is at-risk. $ORGANIZATION$ has requested that you change it.", + "message": "你在此網站的密碼存在風險,$ORGANIZATION$ 已要求你變更該密碼。", "placeholders": { "organization": { "content": "$1", @@ -2655,7 +2735,7 @@ "description": "Notification body when a login triggers an at-risk password change request and the change password domain is known." }, "atRiskNavigatePrompt": { - "message": "$ORGANIZATION$ wants you to change this password because it is at-risk. Navigate to your account settings to change the password.", + "message": "$ORGANIZATION$ 要求你變更此密碼,因為它存在風險。請前往帳號設定以變更密碼。", "placeholders": { "organization": { "content": "$1", @@ -2665,10 +2745,10 @@ "description": "Notification body when a login triggers an at-risk password change request and no change password domain is provided." }, "reviewAndChangeAtRiskPassword": { - "message": "Review and change one at-risk password" + "message": "檢視並變更一組有風險的密碼" }, "reviewAndChangeAtRiskPasswordsPlural": { - "message": "Review and change $COUNT$ at-risk passwords", + "message": "檢視並變更 $COUNT$ 組有風險的密碼", "placeholders": { "count": { "content": "$1", @@ -2677,49 +2757,49 @@ } }, "changeAtRiskPasswordsFaster": { - "message": "Change at-risk passwords faster" + "message": "更快速地變更有風險的密碼" }, "changeAtRiskPasswordsFasterDesc": { - "message": "Update your settings so you can quickly autofill your passwords and generate new ones" + "message": "更新你的設定,以便能快速自動填入密碼並產生新密碼" }, "reviewAtRiskLogins": { - "message": "Review at-risk logins" + "message": "檢視有風險的登入資訊" }, "reviewAtRiskPasswords": { - "message": "Review at-risk passwords" + "message": "檢視有風險的密碼" }, "reviewAtRiskLoginsSlideDesc": { - "message": "Your organization passwords are at-risk because they are weak, reused, and/or exposed.", + "message": "你的組織密碼存在風險,原因可能是密碼過於簡弱、重複使用或已外洩。", "description": "Description of the review at-risk login slide on the at-risk password page carousel" }, "reviewAtRiskLoginSlideImgAltPeriod": { - "message": "Illustration of a list of logins that are at-risk." + "message": "有風險登入清單的示意圖。" }, "generatePasswordSlideDesc": { - "message": "Quickly generate a strong, unique password with the Bitwarden autofill menu on the at-risk site.", + "message": "在有風險的網站上,透過 Bitwarden 自動填入選單快速產生強且唯一的密碼。", "description": "Description of the generate password slide on the at-risk password page carousel" }, "generatePasswordSlideImgAltPeriod": { - "message": "Illustration of the Bitwarden autofill menu displaying a generated password." + "message": "Bitwarden 自動填入選單顯示產生密碼的示意圖。" }, "updateInBitwarden": { - "message": "Update in Bitwarden" + "message": "在 Bitwarden 中更新" }, "updateInBitwardenSlideDesc": { - "message": "Bitwarden will then prompt you to update the password in the password manager.", + "message": "之後 Bitwarden 會提示你在密碼管理器中更新該密碼。", "description": "Description of the update in Bitwarden slide on the at-risk password page carousel" }, "updateInBitwardenSlideImgAltPeriod": { - "message": "Illustration of a Bitwarden’s notification prompting the user to update the login." + "message": "Bitwarden 通知使用者更新登入資訊的示意圖。" }, "turnOnAutofill": { - "message": "Turn on autofill" + "message": "啟用自動填入" }, "turnedOnAutofill": { - "message": "Turned on autofill" + "message": "停用自動填入" }, "dismiss": { - "message": "Dismiss" + "message": "忽略" }, "websiteItemLabel": { "message": "網站 $number$ (URI)", @@ -2784,7 +2864,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "maxAccessCountReached": { - "message": "Max access count reached", + "message": "已達最大存取次數", "description": "This text will be displayed after a Send has been accessed the maximum amount of times." }, "hideTextByDefault": { @@ -2990,7 +3070,7 @@ "message": "您必須驗證您的電子郵件才能使用此功能。您可以在網頁密碼庫裡驗證您的電子郵件。" }, "masterPasswordSuccessfullySet": { - "message": "Master password successfully set" + "message": "主密碼設定成功" }, "updatedMasterPassword": { "message": "已更新主密碼" @@ -3131,13 +3211,13 @@ "message": "找不到唯一識別碼。" }, "removeMasterPasswordForOrganizationUserKeyConnector": { - "message": "A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator." + "message": "以下組織的成員已不再需要主密碼。請與你的組織管理員確認下方的網域。" }, "organizationName": { - "message": "Organization name" + "message": "組織名稱" }, "keyConnectorDomain": { - "message": "Key Connector domain" + "message": "Key Connector 網域" }, "leaveOrganization": { "message": "離開組織" @@ -3173,7 +3253,7 @@ } }, "exportingIndividualVaultWithAttachmentsDescription": { - "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "message": "只會匯出與 $EMAIL$ 關聯的個人密碼庫(包含附件)。組織密碼庫的項目不包含在內。", "placeholders": { "email": { "content": "$1", @@ -3193,12 +3273,33 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "只會匯出與 $ORGANIZATION$ 相關的組織密碼庫。", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "只會匯出與 $ORGANIZATION$ 相關的組織保險庫,「我的項目」集合將不會包含在內。", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "錯誤" }, "decryptionError": { "message": "解密發生錯誤" }, + "errorGettingAutoFillData": { + "message": "取得自動填入資料時發生錯誤" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden 無法解密您密碼庫中下面的項目。" }, @@ -3342,7 +3443,7 @@ } }, "forwaderInvalidOperation": { - "message": "$SERVICENAME$ refused your request. Please contact your service provider for assistance.", + "message": "$SERVICENAME$ 拒絕了你的請求。請聯絡你的服務提供者以取得協助。", "description": "Displayed when the user is forbidden from using the API by the forwarding service.", "placeholders": { "servicename": { @@ -3352,7 +3453,7 @@ } }, "forwaderInvalidOperationWithMessage": { - "message": "$SERVICENAME$ refused your request: $ERRORMESSAGE$", + "message": "$SERVICENAME$ 拒絕了你的請求:$ERRORMESSAGE$", "description": "Displayed when the user is forbidden from using the API by the forwarding service with an error message.", "placeholders": { "servicename": { @@ -3501,7 +3602,7 @@ "message": "已傳送通知至您的裝置。" }, "notificationSentDevicePart1": { - "message": "Unlock Bitwarden on your device or on the" + "message": "在你的裝置或其他裝置上解鎖 Bitwarden" }, "notificationSentDeviceAnchor": { "message": "網頁應用程式" @@ -3525,7 +3626,7 @@ "message": "已傳送請求" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "登入請求已由 $DEVICE$ 上的 $EMAIL$ 批准", "placeholders": { "email": { "content": "$1", @@ -3538,16 +3639,16 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "您拒絕了來自其他裝置的登入嘗試。如果這是您本人,請嘗試再次使用該裝置登入。" }, "device": { - "message": "Device" + "message": "裝置" }, "loginStatus": { - "message": "Login status" + "message": "登入狀態" }, "masterPasswordChanged": { - "message": "Master password saved" + "message": "主密碼已儲存" }, "exposedMasterPassword": { "message": "已洩露的主密碼" @@ -3640,28 +3741,28 @@ "message": "記住此裝置來讓未來的登入體驗更簡易" }, "manageDevices": { - "message": "Manage devices" + "message": "管理裝置" }, "currentSession": { - "message": "Current session" + "message": "目前工作階段" }, "mobile": { - "message": "Mobile", + "message": "行動裝置", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "擴充套件", "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "電腦版應用程式", "description": "Desktop app" }, "webVault": { - "message": "Web vault" + "message": "網頁版密碼庫" }, "webApp": { - "message": "Web app" + "message": "網路應用程式" }, "cli": { "message": "CLI" @@ -3671,22 +3772,22 @@ "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "等待請求" }, "firstLogin": { - "message": "First login" + "message": "首次登入" }, "trusted": { - "message": "Trusted" + "message": "已信任" }, "needsApproval": { - "message": "Needs approval" + "message": "需要批准" }, "devices": { - "message": "Devices" + "message": "裝置" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "來自 $EMAIL$ 的存取嘗試", "placeholders": { "email": { "content": "$1", @@ -3695,31 +3796,31 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "確認訪問" }, "denyAccess": { - "message": "Deny access" + "message": "拒絕訪問權限" }, "time": { - "message": "Time" + "message": "時間" }, "deviceType": { - "message": "Device Type" + "message": "裝置類型" }, "loginRequest": { - "message": "Login request" + "message": "已要求登入" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "此請求已失效。" }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "登入要求已逾期。" }, "justNow": { - "message": "Just now" + "message": "剛剛" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "$MINUTES$ 分鐘前已發起要求", "placeholders": { "minutes": { "content": "$1", @@ -3749,10 +3850,10 @@ "message": "要求管理員核准" }, "unableToCompleteLogin": { - "message": "Unable to complete login" + "message": "無法完成登入" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { - "message": "You need to log in on a trusted device or ask your administrator to assign you a password." + "message": "你需要在受信任的裝置上登入,或請管理員為你指派密碼。" }, "ssoIdentifierRequired": { "message": "需要組織 SSO 識別碼。" @@ -3816,35 +3917,35 @@ "message": "裝置已信任" }, "trustOrganization": { - "message": "Trust organization" + "message": "目前組織" }, "trust": { - "message": "Trust" + "message": "信任" }, "doNotTrust": { - "message": "Do not trust" + "message": "不信任" }, "organizationNotTrusted": { - "message": "Organization is not trusted" + "message": "機構不被信任" }, "emergencyAccessTrustWarning": { - "message": "For the security of your account, only confirm if you have granted emergency access to this user and their fingerprint matches what is displayed in their account" + "message": "為了保護你的帳號安全,僅在你已授予此使用者緊急存取權,且其指紋與其帳號中顯示的指紋相符時才確認。" }, "orgTrustWarning": { - "message": "For the security of your account, only proceed if you are a member of this organization, have account recovery enabled, and the fingerprint displayed below matches the organization's fingerprint." + "message": "為了保護你的帳號安全,僅在你是此組織的成員、已啟用帳號復原功能,且下方顯示的指紋與組織的指紋相符時才繼續。" }, "orgTrustWarning1": { - "message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint." + "message": "此組織有企業政策,會將你加入帳號復原功能。加入後,組織管理員可變更你的密碼。僅在你確認此組織身份,且下方顯示的指紋詞句與該組織的指紋相符時才繼續。" }, "trustUser": { - "message": "Trust user" + "message": "信任使用者" }, "sendsTitleNoItems": { - "message": "Send sensitive information safely", + "message": "安全傳送機密的資訊", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsBodyNoItems": { - "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", + "message": "安全的和任何人及任何平臺分享檔案及資料。您的資料會受到端對端加密的保護。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "inputRequired": { @@ -3970,6 +4071,15 @@ "message": "將頁面載入時使用自動填入功能設定為預設。", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "無法自動填入" + }, + "cannotAutofillExactMatch": { + "message": "預設比對方式為「完全相符」。目前的網站與此項目的已儲存登入資料不完全相符。" + }, + "okay": { + "message": "確定" + }, "toggleSideNavigation": { "message": "切換側邊欄" }, @@ -4179,10 +4289,10 @@ "message": "選擇一個分類" }, "importTargetHintCollection": { - "message": "Select this option if you want the imported file contents moved to a collection" + "message": "若你希望將匯入檔案的內容移至集合,請選擇此選項" }, "importTargetHintFolder": { - "message": "Select this option if you want the imported file contents moved to a folder" + "message": "若你希望將匯入檔案的內容移至資料夾,請選擇此選項" }, "importUnassignedItemsError": { "message": "檔案包含未指派項目。" @@ -4373,7 +4483,7 @@ "message": "目前帳戶" }, "bitwardenAccount": { - "message": "Bitwarden account" + "message": "Bitwarden 帳號" }, "availableAccounts": { "message": "可用帳戶" @@ -4419,23 +4529,23 @@ "description": "Label indicating the most common import formats" }, "uriMatchDefaultStrategyHint": { - "message": "URI match detection is how Bitwarden identifies autofill suggestions.", + "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": { - "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", + "message": "「正則表達式」是進階選項,可能會增加憑證外洩的風險。", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, "startsWithAdvancedOptionWarning": { - "message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.", + "message": "「開頭為」是進階選項,可能會增加憑證外洩的風險。", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { - "message": "More about match detection", + "message": "深入了解匹配偵測", "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "進階選項", "description": "Advanced option placeholder for uri option component" }, "confirmContinueToBrowserSettingsTitle": { @@ -4584,7 +4694,7 @@ } }, "viewItemTitleWithField": { - "message": "View item - $ITEMNAME$ - $FIELD$", + "message": "檢視項目 - $ITEMNAME$ - $FIELD$", "description": "Title for a link that opens a view for an item.", "placeholders": { "itemname": { @@ -4608,7 +4718,7 @@ } }, "autofillTitleWithField": { - "message": "Autofill - $ITEMNAME$ - $FIELD$", + "message": "自動填入 - $ITEMNAME$ - $FIELD$", "description": "Title for a button that autofills a login item.", "placeholders": { "itemname": { @@ -4622,7 +4732,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "複製 $FIELD$,$CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -4775,22 +4885,22 @@ "message": "在所有裝置中下載 Bitwarden" }, "getTheMobileApp": { - "message": "Get the mobile app" + "message": "取得手機應用程式" }, "getTheMobileAppDesc": { - "message": "Access your passwords on the go with the Bitwarden mobile app." + "message": "使用 Bitwarden 行動應用程式,隨時隨地存取你的密碼。" }, "getTheDesktopApp": { - "message": "Get the desktop app" + "message": "取得桌面應用程式" }, "getTheDesktopAppDesc": { - "message": "Access your vault without a browser, then set up unlock with biometrics to expedite unlocking in both the desktop app and browser extension." + "message": "在不使用瀏覽器的情況下存取你的密碼庫,然後設定生物辨識解鎖,以加快在桌面應用程式和瀏覽器擴充功能中的解鎖速度。" }, "downloadFromBitwardenNow": { "message": "立即從 bitwarden.com 下載" }, "getItOnGooglePlay": { - "message": "Get it on Google Play" + "message": "在 Google Play上取得" }, "downloadOnTheAppStore": { "message": "從 App Store 下載" @@ -4801,6 +4911,9 @@ "premium": { "message": "進階版" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "免費組織無法使用附檔" }, @@ -4885,6 +4998,16 @@ } } }, + "defaultLabelWithValue": { + "message": "預設 ($VALUE$)", + "description": "A label that indicates the default value for a field with the current default value in parentheses.", + "placeholders": { + "value": { + "content": "$1", + "example": "Base domain" + } + } + }, "showMatchDetection": { "message": "顯示偵測到的吻合 $WEBSITE$", "placeholders": { @@ -5036,7 +5159,7 @@ } }, "reorderWebsiteUriButton": { - "message": "Reorder website URI. Use arrow key to move item up or down." + "message": "重新排序網站 URI。使用方向鍵可將項目上移或下移。" }, "reorderFieldUp": { "message": "往上移動 $LABEL$,位置 $LENGTH$ 之 $INDEX$", @@ -5149,10 +5272,10 @@ "message": "在擴充套件圖示上顯示自動填入建議的數量" }, "accountAccessRequested": { - "message": "Account access requested" + "message": "帳號存取請求" }, "confirmAccessAttempt": { - "message": "Confirm access attempt for $EMAIL$", + "message": "確認 $EMAIL$ 的存取嘗試", "placeholders": { "email": { "content": "$1", @@ -5257,16 +5380,16 @@ "message": "基於不明原因,生物辨識解鎖無法使用。" }, "unlockVault": { - "message": "Unlock your vault in seconds" + "message": "幾秒內解鎖你的密碼庫" }, "unlockVaultDesc": { - "message": "You can customize your unlock and timeout settings to more quickly access your vault." + "message": "你可以自訂解鎖與逾時設定,以更快速地存取你的密碼庫。" }, "unlockPinSet": { - "message": "Unlock PIN set" + "message": "解鎖 PIN 已設定" }, "unlockWithBiometricSet": { - "message": "Unlock with biometrics set" + "message": "使用生物辨識解鎖" }, "authenticating": { "message": "驗證中" @@ -5280,7 +5403,7 @@ "description": "Notification message for when a password has been regenerated" }, "saveToBitwarden": { - "message": "Save to Bitwarden", + "message": "已儲存至 Bitwarden。", "description": "Confirmation message for saving a login to Bitwarden" }, "spaceCharacterDescriptor": { @@ -5434,37 +5557,37 @@ "message": "擴充套件寬度" }, "wide": { - "message": "寬度" + "message": "寬" }, "extraWide": { - "message": "更寬" + "message": "超寬" }, "sshKeyWrongPassword": { - "message": "The password you entered is incorrect." + "message": "您輸入的密碼錯誤。" }, "importSshKey": { - "message": "Import" + "message": "匯入" }, "confirmSshKeyPassword": { - "message": "Confirm password" + "message": "確認密碼" }, "enterSshKeyPasswordDesc": { - "message": "Enter the password for the SSH key." + "message": "輸入 SSH 金鑰的密碼" }, "enterSshKeyPassword": { - "message": "Enter password" + "message": "請輸入密碼" }, "invalidSshKey": { - "message": "The SSH key is invalid" + "message": "SSH 密鑰不正確" }, "sshKeyTypeUnsupported": { - "message": "The SSH key type is not supported" + "message": "SSH 密鑰類型不支援" }, "importSshKeyFromClipboard": { - "message": "Import key from clipboard" + "message": "從剪貼簿中匯入密鑰" }, "sshKeyImported": { - "message": "SSH key imported successfully" + "message": "SSH 密鑰成功匯入" }, "cannotRemoveViewOnlyCollections": { "message": "若您只有檢視權限,無法移除集合 $COLLECTIONS$。", @@ -5482,176 +5605,251 @@ "message": "為了使用生物辨識解鎖,請更新您的桌面應用程式,或在設定中停用指紋解鎖。" }, "changeAtRiskPassword": { - "message": "Change at-risk password" + "message": "變更有風險的密碼" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "message": "此登入資訊存在風險,且缺少網站。請新增網站並變更密碼以提升安全性。" }, "missingWebsite": { - "message": "Missing website" + "message": "缺少網站" }, "settingsVaultOptions": { "message": "密碼庫選項" }, "emptyVaultDescription": { - "message": "The vault protects more than just your passwords. Store secure logins, IDs, cards and notes securely here." + "message": "除了密碼之外,您也可以儲存安全的登入資訊、身分資訊、信用卡及筆記在您的密碼庫。" }, "introCarouselLabel": { - "message": "Welcome to Bitwarden" + "message": "歡迎使用 Bitwarden" }, "securityPrioritized": { - "message": "Security, prioritized" + "message": "安全,第一優先" }, "securityPrioritizedBody": { - "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you." + "message": "儲存登入資訊、信用卡和數位身分到您的安全密碼庫。Bitwarden 使用零知識、端對端的加密來保護您的重要資訊。" }, "quickLogin": { - "message": "Quick and easy login" + "message": "快速方便的登入" }, "quickLoginBody": { - "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter." + "message": "設定生物辨識解鎖及自動填入,不需要輸入任何字元就可以登入。" }, "secureUser": { - "message": "Level up your logins" + "message": "升級您的登入體驗" }, "secureUserBody": { - "message": "Use the generator to create and save strong, unique passwords for all your accounts." + "message": "使用密碼產生器來建立及儲存高強度、唯一的密碼,來保護您所有的帳號。" }, "secureDevices": { - "message": "Your data, when and where you need it" + "message": "您的資料,隨時隨地都垂手可得" }, "secureDevicesBody": { - "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps." + "message": "使用 Bitwarden 行動應用程式、瀏覽器及桌面應用程式在無限制的裝置來儲存無上限的密碼。" }, "nudgeBadgeAria": { - "message": "1 notification" + "message": "1 通知" }, "emptyVaultNudgeTitle": { - "message": "Import existing passwords" + "message": "匯入現有密碼" }, "emptyVaultNudgeBody": { - "message": "Use the importer to quickly transfer logins to Bitwarden without manually adding them." + "message": "使用匯入工具可快速將登入資訊轉移到 Bitwarden,而不需手動新增。" }, "emptyVaultNudgeButton": { - "message": "Import now" + "message": "立即匯入" }, "hasItemsVaultNudgeTitle": { - "message": "Welcome to your vault!" + "message": "歡迎來到你的密碼庫!" }, - "phishingPageTitle": { - "message": "Phishing website" + "phishingPageTitleV2": { + "message": "偵測到網路釣魚嘗試" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "你正要造訪的網站為已知惡意網站,存在安全風險。" }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "關閉此分頁" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "繼續前往此網站(不建議)" + }, + "phishingPageExplanation1": { + "message": "此網站被列於", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ",這是一份開源的已知網路釣魚網站清單,用於竊取個人與敏感資訊。", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore": { + "message": "進一步了解網路釣魚偵測" + }, + "protectedBy": { + "message": "由 $PRODUCT$ 保護", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { - "message": "Autofill items for the current page" + "message": "當前頁面的自動填入項目" }, "hasItemsVaultNudgeBodyTwo": { - "message": "Favorite items for easy access" + "message": "收藏項目,方便快速存取" }, "hasItemsVaultNudgeBodyThree": { - "message": "Search your vault for something else" + "message": "在密碼庫中搜尋其他內容" }, "newLoginNudgeTitle": { - "message": "Save time with autofill" + "message": "使用自動填入節省時間" }, "newLoginNudgeBodyOne": { - "message": "Include a", + "message": "包含", "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." }, "newLoginNudgeBodyBold": { - "message": "Website", + "message": "網頁", "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." }, "newLoginNudgeBodyTwo": { - "message": "so this login appears as an autofill suggestion.", + "message": "讓此登入顯示為自動填入建議。", "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." }, "newCardNudgeTitle": { - "message": "Seamless online checkout" + "message": "流暢的線上結帳體驗" }, "newCardNudgeBody": { - "message": "With cards, easily autofill payment forms securely and accurately." + "message": "使用卡片功能,安全且精準地自動填入付款表單。" }, "newIdentityNudgeTitle": { - "message": "Simplify creating accounts" + "message": "簡化帳號建立流程 " }, "newIdentityNudgeBody": { - "message": "With identities, quickly autofill long registration or contact forms." + "message": "使用身份資訊,快速自動填入冗長的註冊或聯絡表單。" }, "newNoteNudgeTitle": { - "message": "Keep your sensitive data safe" + "message": "保護你的敏感資料安全" }, "newNoteNudgeBody": { - "message": "With notes, securely store sensitive data like banking or insurance details." + "message": "使用備註功能,安全儲存銀行或保險等敏感資料。" }, "newSshNudgeTitle": { - "message": "Developer-friendly SSH access" + "message": "開發者友善的 SSH 存取" }, "newSshNudgeBodyOne": { - "message": "Store your keys and connect with the SSH agent for fast, encrypted authentication.", + "message": "儲存你的金鑰並透過 SSH 代理程式進行快速、加密的驗證。", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "newSshNudgeBodyTwo": { - "message": "Learn more about SSH agent", + "message": "深入了解 SSH 代理程式", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "generatorNudgeTitle": { - "message": "Quickly create passwords" + "message": "快速建立密碼" }, "generatorNudgeBodyOne": { - "message": "Easily create strong and unique passwords by clicking on", + "message": "點擊即可輕鬆產生強且唯一的密碼。", "description": "Two part message", "example": "Easily create strong and unique passwords by clicking on {icon} to help you keep your logins secure." }, "generatorNudgeBodyTwo": { - "message": "to help you keep your logins secure.", + "message": "協助你維持登入資訊的安全。", "description": "Two part message", "example": "Easily create strong and unique passwords by clicking on {icon} to help you keep your logins secure." }, "generatorNudgeBodyAria": { - "message": "Easily create strong and unique passwords by clicking on the Generate password button to help you keep your logins secure.", + "message": "點擊「產生密碼」按鈕即可輕鬆建立強且唯一的密碼,協助你確保登入資訊的安全。", "description": "Aria label for the body content of the generator nudge" }, "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." + "message": "Bitwarden 會使用已儲存的登入 URI 來判斷應顯示的圖示或變更密碼網址,以改善你的使用體驗。使用此服務時,不會收集或儲存任何資訊。" }, "noPermissionsViewPage": { - "message": "You do not have permissions to view this page. Try logging in with a different account." + "message": "你沒有檢視此頁面的權限。請嘗試使用其他帳號登入。" }, "wasmNotSupported": { - "message": "WebAssembly is not supported on your browser or is not enabled. WebAssembly is required to use the Bitwarden app.", + "message": "你的瀏覽器不支援或未啟用 WebAssembly。使用 Bitwarden 應用程式需要啟用 WebAssembly。", "description": "'WebAssembly' is a technical term and should not be translated." }, "showMore": { - "message": "Show more" + "message": "顯示更多" }, "showLess": { - "message": "Show less" + "message": "顯示較少" }, "next": { - "message": "Next" + "message": "下一步" }, "moreBreadcrumbs": { - "message": "More breadcrumbs", + "message": "更多導覽階層", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." }, "confirmKeyConnectorDomain": { - "message": "Confirm Key Connector domain" + "message": "確認 Key Connector 網域" + }, + "atRiskLoginsSecured": { + "message": "你已成功保護有風險的登入項目,做得好!" + }, + "upgradeNow": { + "message": "立即升級" + }, + "builtInAuthenticator": { + "message": "內建驗證器" + }, + "secureFileStorage": { + "message": "安全檔案儲存" + }, + "emergencyAccess": { + "message": "緊急存取" + }, + "breachMonitoring": { + "message": "外洩監控" + }, + "andMoreFeatures": { + "message": "以及其他功能功能!" + }, + "planDescPremium": { + "message": "完整的線上安全" + }, + "upgradeToPremium": { + "message": "升級到 Premium" + }, + "unlockAdvancedSecurity": { + "message": "Unlock advanced security features" + }, + "unlockAdvancedSecurityDesc": { + "message": "A Premium subscription gives you more tools to stay secure and in control" + }, + "explorePremium": { + "message": "探索進階版" + }, + "loadingVault": { + "message": "正在載入密碼庫" + }, + "vaultLoaded": { + "message": "已載入密碼庫" + }, + "settingDisabledByPolicy": { + "message": "此設定已被你的組織原則停用。", + "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "郵編 / 郵政代碼" + }, + "cardNumberLabel": { + "message": "支付卡號碼" + }, + "sessionTimeoutSettingsAction": { + "message": "逾時後動作" } } 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 b9f9b984c69..cef2a748d58 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 @@ -41,12 +41,10 @@ -
+
-

- {{ "options" | i18n }} -

+

{{ "options" | 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 48fd57431a2..d7d3c02ab14 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 @@ -33,6 +33,8 @@ import { AccountComponent } from "./account.component"; import { CurrentAccountComponent } from "./current-account.component"; import { AccountSwitcherService } from "./services/account-switcher.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({ templateUrl: "account-switcher.component.html", imports: [ @@ -120,10 +122,8 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { async lock(userId: string) { this.loading = true; - await this.vaultTimeoutService.lock(userId); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["lock"]); + await this.lockService.lock(userId as UserId); + await this.router.navigate(["lock"]); } async lockAll() { diff --git a/apps/browser/src/auth/popup/account-switching/account.component.html b/apps/browser/src/auth/popup/account-switching/account.component.html index d22ce9c9366..90770bb8d9b 100644 --- a/apps/browser/src/auth/popup/account-switching/account.component.html +++ b/apps/browser/src/auth/popup/account-switching/account.component.html @@ -25,7 +25,7 @@
( - {{ + {{ status.text }} ) diff --git a/apps/browser/src/auth/popup/account-switching/account.component.ts b/apps/browser/src/auth/popup/account-switching/account.component.ts index c060d9161ef..edfad2a54b3 100644 --- a/apps/browser/src/auth/popup/account-switching/account.component.ts +++ b/apps/browser/src/auth/popup/account-switching/account.component.ts @@ -13,13 +13,19 @@ import { BiometricsService } from "@bitwarden/key-management"; import { AccountSwitcherService, AvailableAccount } from "./services/account-switcher.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: "auth-account", templateUrl: "account.component.html", imports: [CommonModule, JslibModule, AvatarModule, ItemModule], }) export class AccountComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() account: AvailableAccount; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() loading = new EventEmitter(); constructor( diff --git a/apps/browser/src/auth/popup/account-switching/current-account.component.html b/apps/browser/src/auth/popup/account-switching/current-account.component.html index c16abdadf29..2e2440f6258 100644 --- a/apps/browser/src/auth/popup/account-switching/current-account.component.html +++ b/apps/browser/src/auth/popup/account-switching/current-account.component.html @@ -12,7 +12,6 @@ [color]="currentAccount.avatarColor" size="small" aria-hidden="true" - class="[&>img]:tw-block" > diff --git a/apps/browser/src/auth/popup/account-switching/current-account.component.ts b/apps/browser/src/auth/popup/account-switching/current-account.component.ts index 63e8481621a..2dde3b5a266 100644 --- a/apps/browser/src/auth/popup/account-switching/current-account.component.ts +++ b/apps/browser/src/auth/popup/account-switching/current-account.component.ts @@ -21,6 +21,8 @@ export type CurrentAccount = { avatarColor: string; }; +// 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-current-account", templateUrl: "current-account.component.html", 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 7bb12fc260d..99d2c83283e 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 @@ -160,7 +160,7 @@ export class AccountSwitcherService { throwError(() => new Error(AccountSwitcherService.incompleteAccountSwitchError)), }), ), - ).catch((err) => { + ).catch((err): any => { if ( err instanceof Error && err.message === AccountSwitcherService.incompleteAccountSwitchError diff --git a/apps/browser/src/auth/popup/accounts/foreground-lock.service.ts b/apps/browser/src/auth/popup/accounts/foreground-lock.service.ts index 20a52a90d8b..91adecd4a03 100644 --- a/apps/browser/src/auth/popup/accounts/foreground-lock.service.ts +++ b/apps/browser/src/auth/popup/accounts/foreground-lock.service.ts @@ -6,10 +6,13 @@ import { MessageListener, MessageSender, } from "@bitwarden/common/platform/messaging"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { newGuid } from "@bitwarden/guid"; +import { UserId } from "@bitwarden/user-core"; const LOCK_ALL_FINISHED = new CommandDefinition<{ requestId: string }>("lockAllFinished"); const LOCK_ALL = new CommandDefinition<{ requestId: string }>("lockAll"); +const LOCK_USER_FINISHED = new CommandDefinition<{ requestId: string }>("lockUserFinished"); +const LOCK_USER = new CommandDefinition<{ requestId: string; userId: UserId }>("lockUser"); export class ForegroundLockService implements LockService { constructor( @@ -18,7 +21,7 @@ export class ForegroundLockService implements LockService { ) {} async lockAll(): Promise { - const requestId = Utils.newGuid(); + const requestId = newGuid(); const finishMessage = firstValueFrom( this.messageListener .messages$(LOCK_ALL_FINISHED) @@ -29,4 +32,19 @@ export class ForegroundLockService implements LockService { await finishMessage; } + + async lock(userId: UserId): Promise { + const requestId = newGuid(); + const finishMessage = firstValueFrom( + this.messageListener + .messages$(LOCK_USER_FINISHED) + .pipe(filter((m) => m.requestId === requestId)), + ); + + this.messageSender.send(LOCK_USER, { requestId, userId }); + + await finishMessage; + } + + async runPlatformOnLockActions(): Promise {} } diff --git a/apps/browser/src/auth/popup/components/set-pin.component.html b/apps/browser/src/auth/popup/components/set-pin.component.html index d525f9378f1..c88274b2bf4 100644 --- a/apps/browser/src/auth/popup/components/set-pin.component.html +++ b/apps/browser/src/auth/popup/components/set-pin.component.html @@ -1,6 +1,6 @@
-
+
{{ "setYourPinTitle" | i18n }}
diff --git a/apps/browser/src/auth/popup/components/set-pin.component.ts b/apps/browser/src/auth/popup/components/set-pin.component.ts index a9e8e1b122f..dbb71ae3b07 100644 --- a/apps/browser/src/auth/popup/components/set-pin.component.ts +++ b/apps/browser/src/auth/popup/components/set-pin.component.ts @@ -13,6 +13,8 @@ import { IconButtonModule, } from "@bitwarden/components"; +// 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: "set-pin.component.html", imports: [ diff --git a/apps/browser/src/auth/popup/constants/auth-extension-route.constant.ts b/apps/browser/src/auth/popup/constants/auth-extension-route.constant.ts new file mode 100644 index 00000000000..5ea6fac7ebb --- /dev/null +++ b/apps/browser/src/auth/popup/constants/auth-extension-route.constant.ts @@ -0,0 +1,8 @@ +// Full routes that auth owns in the extension +export const AuthExtensionRoute = Object.freeze({ + AccountSecurity: "account-security", + DeviceManagement: "device-management", + AccountSwitcher: "account-switcher", +} as const); + +export type AuthExtensionRoute = (typeof AuthExtensionRoute)[keyof typeof AuthExtensionRoute]; diff --git a/apps/browser/src/auth/popup/constants/index.ts b/apps/browser/src/auth/popup/constants/index.ts new file mode 100644 index 00000000000..59855040fd3 --- /dev/null +++ b/apps/browser/src/auth/popup/constants/index.ts @@ -0,0 +1 @@ +export * from "./auth-extension-route.constant"; diff --git a/apps/browser/src/auth/popup/login/extension-login-component.service.ts b/apps/browser/src/auth/popup/login/extension-login-component.service.ts index 37d74616391..621c7d74876 100644 --- a/apps/browser/src/auth/popup/login/extension-login-component.service.ts +++ b/apps/browser/src/auth/popup/login/extension-login-component.service.ts @@ -68,4 +68,18 @@ export class ExtensionLoginComponentService showBackButton(showBackButton: boolean): void { this.extensionAnonLayoutWrapperDataService.setAnonLayoutWrapperData({ showBackButton }); } + + /** + * Enable passkey login support for chromium-based browsers only. + * Neither Firefox nor safari support overriding the relying party ID in an extension. + * + * https://github.com/w3c/webextensions/issues/238 + * + * Tracking links: + * https://bugzilla.mozilla.org/show_bug.cgi?id=1956484 + * https://developer.apple.com/forums/thread/774351 + */ + isLoginWithPasskeySupported(): boolean { + return this.platformUtilsService.isChromium(); + } } diff --git a/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html index 3de1cc81a69..37efcee9012 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -20,9 +20,9 @@ - {{ - "unlockWithBiometrics" | i18n - }} + + {{ "unlockWithBiometrics" | i18n }} + {{ biometricUnavailabilityReason }} @@ -38,9 +38,9 @@ type="checkbox" formControlName="enableAutoBiometricsPrompt" /> - {{ - "enableAutoBiometricsPrompt" | i18n - }} + + {{ "enableAutoBiometricsPrompt" | i18n }} + - {{ - "lockWithMasterPassOnRestart1" | i18n - }} + + {{ "lockWithMasterPassOnRestart1" | i18n }} + - -

{{ "vaultTimeoutHeader" | i18n }}

-
+ @if (consolidatedSessionTimeoutComponent$ | async) { + +

+ {{ "sessionTimeoutHeader" | i18n }} +

+
- - - + + + + } @else { + +

+ {{ "vaultTimeoutHeader" | i18n }} +

+
- - {{ "vaultTimeoutAction1" | i18n }} - - - - + + + - - {{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
+ + {{ "vaultTimeoutAction1" | i18n }} + + + + + + + {{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
+
+
+ + + {{ "vaultTimeoutPolicyAffectingOptions" | i18n }} -
- - - {{ "vaultTimeoutPolicyAffectingOptions" | i18n }} - -
+ + }
- +

{{ "manageDevices" | i18n }}

diff --git a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts index 63666440a76..d0ab4793301 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts @@ -1,10 +1,12 @@ import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; +import { ActivatedRoute } from "@angular/router"; import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; +import { LockService } from "@bitwarden/auth/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"; @@ -15,7 +17,6 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeoutSettingsService, - VaultTimeoutService, VaultTimeoutStringType, VaultTimeoutAction, } from "@bitwarden/common/key-management/vault-timeout"; @@ -41,6 +42,8 @@ import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popu import { AccountSecurityComponent } from "./account-security.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-pop-out", template: ` `, @@ -61,12 +64,15 @@ describe("AccountSecurityComponent", () => { const validationService = mock(); const dialogService = mock(); const platformUtilsService = mock(); + const lockService = mock(); + const configService = mock(); beforeEach(async () => { await TestBed.configureTestingModule({ providers: [ { provide: AccountService, useValue: accountService }, { provide: AccountSecurityComponent, useValue: mock() }, + { provide: ActivatedRoute, useValue: mock() }, { provide: BiometricsService, useValue: mock() }, { provide: BiometricStateService, useValue: biometricStateService }, { provide: DialogService, useValue: dialogService }, @@ -80,7 +86,6 @@ describe("AccountSecurityComponent", () => { { provide: PopupRouterCacheService, useValue: mock() }, { provide: ToastService, useValue: mock() }, { provide: UserVerificationService, useValue: mock() }, - { provide: VaultTimeoutService, useValue: mock() }, { provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService }, { provide: StateProvider, useValue: mock() }, { provide: CipherService, useValue: mock() }, @@ -88,8 +93,9 @@ describe("AccountSecurityComponent", () => { { provide: LogService, useValue: mock() }, { provide: OrganizationService, useValue: mock() }, { provide: CollectionService, useValue: mock() }, - { provide: ConfigService, useValue: mock() }, { provide: ValidationService, useValue: validationService }, + { provide: LockService, useValue: lockService }, + { provide: ConfigService, useValue: configService }, ], }) .overrideComponent(AccountSecurityComponent, { 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 72a389ecf71..e6e7be96c08 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -24,7 +24,8 @@ import { 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 { FingerprintDialogComponent, VaultTimeoutInputComponent } from "@bitwarden/auth/angular"; +import { FingerprintDialogComponent } from "@bitwarden/auth/angular"; +import { LockService } from "@bitwarden/auth/common"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; @@ -37,7 +38,6 @@ import { VaultTimeout, VaultTimeoutAction, VaultTimeoutOption, - VaultTimeoutService, VaultTimeoutSettingsService, VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; @@ -69,6 +69,10 @@ import { BiometricStateService, BiometricsStatus, } from "@bitwarden/key-management"; +import { + SessionTimeoutInputComponent, + SessionTimeoutSettingsComponent, +} from "@bitwarden/key-management-ui"; import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors"; import { BrowserApi } from "../../../platform/browser/browser-api"; @@ -80,6 +84,8 @@ import { SetPinComponent } from "../components/set-pin.component"; import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.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({ templateUrl: "account-security.component.html", imports: [ @@ -100,9 +106,10 @@ import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component"; SectionComponent, SectionHeaderComponent, SelectModule, + SessionTimeoutSettingsComponent, SpotlightComponent, TypographyModule, - VaultTimeoutInputComponent, + SessionTimeoutInputComponent, ], }) export class AccountSecurityComponent implements OnInit, OnDestroy { @@ -115,7 +122,6 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { biometricUnavailabilityReason: string; showChangeMasterPass = true; pinEnabled$: Observable = of(true); - extensionLoginApprovalFlagEnabled = false; form = this.formBuilder.group({ vaultTimeout: [null as VaultTimeout | null], @@ -134,17 +140,20 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { ), ); - private refreshTimeoutSettings$ = new BehaviorSubject(undefined); + protected readonly consolidatedSessionTimeoutComponent$: Observable; + + protected refreshTimeoutSettings$ = new BehaviorSubject(undefined); private destroy$ = new Subject(); constructor( private accountService: AccountService, + private configService: ConfigService, private pinService: PinServiceAbstraction, private policyService: PolicyService, private formBuilder: FormBuilder, private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, - private vaultTimeoutService: VaultTimeoutService, + private lockService: LockService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, public messagingService: MessagingService, private environmentService: EnvironmentService, @@ -157,9 +166,12 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { private biometricsService: BiometricsService, private vaultNudgesService: NudgesService, private validationService: ValidationService, - private configService: ConfigService, private logService: LogService, - ) {} + ) { + this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + ); + } async ngOnInit() { const hasMasterPassword = await this.userVerificationService.hasMasterPassword(); @@ -175,8 +187,11 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { this.hasVaultTimeoutPolicy = true; } + // Determine platform-specific timeout options const showOnLocked = - !this.platformUtilsService.isFirefox() && !this.platformUtilsService.isSafari(); + !this.platformUtilsService.isFirefox() && + !this.platformUtilsService.isSafari() && + !(this.platformUtilsService.isOpera() && navigator.platform === "MacIntel"); this.vaultTimeoutOptions = [ { name: this.i18nService.t("immediately"), value: 0 }, @@ -239,10 +254,6 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { }; this.form.patchValue(initialValues, { emitEvent: false }); - this.extensionLoginApprovalFlagEnabled = await this.configService.getFeatureFlag( - FeatureFlag.PM14938_BrowserExtensionLoginApproval, - ); - timer(0, 1000) .pipe( switchMap(async () => { @@ -317,12 +328,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { .pipe( concatMap(async (value) => { const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; - const pinKeyEncryptedUserKey = - (await this.pinService.getPinKeyEncryptedUserKeyPersistent(userId)) || - (await this.pinService.getPinKeyEncryptedUserKeyEphemeral(userId)); - await this.pinService.clearPinKeyEncryptedUserKeyPersistent(userId); - await this.pinService.clearPinKeyEncryptedUserKeyEphemeral(userId); - await this.pinService.storePinKeyEncryptedUserKey(pinKeyEncryptedUserKey, value, userId); + const pin = await this.pinService.getPin(userId); + await this.pinService.setPin(pin, value ? "EPHEMERAL" : "PERSISTENT", userId); this.refreshTimeoutSettings$.next(); }), takeUntil(this.destroy$), @@ -492,7 +499,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { } } else { const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - await this.vaultTimeoutSettingsService.clear(userId); + await this.pinService.unsetPin(userId); } } @@ -703,7 +710,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { } async lock() { - await this.vaultTimeoutService.lock(); + const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + await this.lockService.lock(activeUserId); } async logOut() { diff --git a/apps/browser/src/auth/popup/settings/await-desktop-dialog.component.ts b/apps/browser/src/auth/popup/settings/await-desktop-dialog.component.ts index 11bb9683bb9..12cf669d89b 100644 --- a/apps/browser/src/auth/popup/settings/await-desktop-dialog.component.ts +++ b/apps/browser/src/auth/popup/settings/await-desktop-dialog.component.ts @@ -1,14 +1,23 @@ import { Component } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; +import { + ButtonModule, + CenterPositionStrategy, + DialogModule, + DialogService, +} from "@bitwarden/components"; +// 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: "await-desktop-dialog.component.html", imports: [JslibModule, ButtonModule, DialogModule], }) export class AwaitDesktopDialogComponent { static open(dialogService: DialogService) { - return dialogService.open(AwaitDesktopDialogComponent); + return dialogService.open(AwaitDesktopDialogComponent, { + positionStrategy: new CenterPositionStrategy(), + }); } } diff --git a/apps/browser/src/auth/popup/settings/extension-device-management.component.ts b/apps/browser/src/auth/popup/settings/extension-device-management.component.ts index 793965db141..b431fc874dd 100644 --- a/apps/browser/src/auth/popup/settings/extension-device-management.component.ts +++ b/apps/browser/src/auth/popup/settings/extension-device-management.component.ts @@ -7,6 +7,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ standalone: true, selector: "extension-device-management", diff --git a/apps/browser/src/auth/services/auth-status-badge-updater.service.ts b/apps/browser/src/auth/services/auth-status-badge-updater.service.ts index 4205ebc665d..4f239e54939 100644 --- a/apps/browser/src/auth/services/auth-status-badge-updater.service.ts +++ b/apps/browser/src/auth/services/auth-status-badge-updater.service.ts @@ -17,8 +17,8 @@ export class AuthStatusBadgeUpdaterService { private accountService: AccountService, private authService: AuthService, ) { - this.accountService.activeAccount$ - .pipe( + this.badgeService.setState(StateName, (_tab) => + this.accountService.activeAccount$.pipe( switchMap((account) => account ? this.authService.authStatusFor$(account.id) @@ -27,30 +27,36 @@ export class AuthStatusBadgeUpdaterService { mergeMap(async (authStatus) => { switch (authStatus) { case AuthenticationStatus.LoggedOut: { - await this.badgeService.setState(StateName, BadgeStatePriority.High, { - icon: BadgeIcon.LoggedOut, - backgroundColor: Unset, - text: Unset, - }); - break; + return { + priority: BadgeStatePriority.High, + state: { + icon: BadgeIcon.LoggedOut, + backgroundColor: Unset, + text: Unset, + }, + }; } case AuthenticationStatus.Locked: { - await this.badgeService.setState(StateName, BadgeStatePriority.High, { - icon: BadgeIcon.Locked, - backgroundColor: Unset, - text: Unset, - }); - break; + return { + priority: BadgeStatePriority.High, + state: { + icon: BadgeIcon.Locked, + backgroundColor: Unset, + text: Unset, + }, + }; } case AuthenticationStatus.Unlocked: { - await this.badgeService.setState(StateName, BadgeStatePriority.Low, { - icon: BadgeIcon.Unlocked, - }); - break; + return { + priority: BadgeStatePriority.Low, + state: { + icon: BadgeIcon.Unlocked, + }, + }; } } }), - ) - .subscribe(); + ), + ); } } diff --git a/apps/browser/src/auth/services/extension-lock.service.ts b/apps/browser/src/auth/services/extension-lock.service.ts new file mode 100644 index 00000000000..7e01e8155e7 --- /dev/null +++ b/apps/browser/src/auth/services/extension-lock.service.ts @@ -0,0 +1,58 @@ +import { DefaultLockService, LogoutService } from "@bitwarden/auth/common"; +import MainBackground from "@bitwarden/browser/background/main.background"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { SystemService } from "@bitwarden/common/platform/abstractions/system.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; +import { BiometricsService, KeyService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; +import { StateEventRunnerService } from "@bitwarden/state"; + +export class ExtensionLockService extends DefaultLockService { + constructor( + accountService: AccountService, + biometricService: BiometricsService, + vaultTimeoutSettingsService: VaultTimeoutSettingsService, + logoutService: LogoutService, + messagingService: MessagingService, + searchService: SearchService, + folderService: FolderService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, + stateEventRunnerService: StateEventRunnerService, + cipherService: CipherService, + authService: AuthService, + systemService: SystemService, + processReloadService: ProcessReloadServiceAbstraction, + logService: LogService, + keyService: KeyService, + private readonly main: MainBackground, + ) { + super( + accountService, + biometricService, + vaultTimeoutSettingsService, + logoutService, + messagingService, + searchService, + folderService, + masterPasswordService, + stateEventRunnerService, + cipherService, + authService, + systemService, + processReloadService, + logService, + keyService, + ); + } + + async runPlatformOnLockActions(): Promise { + await this.main.refreshMenu(true); + } +} diff --git a/apps/browser/src/auth/services/new-device-verification/extension-new-device-verification-component.service.spec.ts b/apps/browser/src/auth/services/new-device-verification/extension-new-device-verification-component.service.spec.ts new file mode 100644 index 00000000000..7c91cae3fcb --- /dev/null +++ b/apps/browser/src/auth/services/new-device-verification/extension-new-device-verification-component.service.spec.ts @@ -0,0 +1,21 @@ +import { ExtensionNewDeviceVerificationComponentService } from "./extension-new-device-verification-component.service"; + +describe("ExtensionNewDeviceVerificationComponentService", () => { + let sut: ExtensionNewDeviceVerificationComponentService; + + beforeEach(() => { + sut = new ExtensionNewDeviceVerificationComponentService(); + }); + + it("should instantiate the service", () => { + expect(sut).not.toBeFalsy(); + }); + + describe("showBackButton()", () => { + it("should return false", () => { + const result = sut.showBackButton(); + + expect(result).toBe(false); + }); + }); +}); diff --git a/apps/browser/src/auth/services/new-device-verification/extension-new-device-verification-component.service.ts b/apps/browser/src/auth/services/new-device-verification/extension-new-device-verification-component.service.ts new file mode 100644 index 00000000000..05e60fc8dad --- /dev/null +++ b/apps/browser/src/auth/services/new-device-verification/extension-new-device-verification-component.service.ts @@ -0,0 +1,13 @@ +import { + DefaultNewDeviceVerificationComponentService, + NewDeviceVerificationComponentService, +} from "@bitwarden/auth/angular"; + +export class ExtensionNewDeviceVerificationComponentService + extends DefaultNewDeviceVerificationComponentService + implements NewDeviceVerificationComponentService +{ + showBackButton() { + return false; + } +} diff --git a/apps/browser/src/autofill/background/abstractions/notification.background.ts b/apps/browser/src/autofill/background/abstractions/notification.background.ts index 52720b1f9f5..e50a317e8a7 100644 --- a/apps/browser/src/autofill/background/abstractions/notification.background.ts +++ b/apps/browser/src/autofill/background/abstractions/notification.background.ts @@ -35,7 +35,7 @@ interface NotificationQueueMessage { } type ChangePasswordNotificationData = { - cipherId: CipherView["id"]; + cipherIds: CipherView["id"][]; newPassword: string; }; @@ -147,7 +147,7 @@ type NotificationBackgroundExtensionMessageHandlers = { bgGetEnableChangedPasswordPrompt: () => Promise; bgGetEnableAddedLoginPrompt: () => Promise; bgGetExcludedDomains: () => Promise; - bgGetActiveUserServerConfig: () => Promise; + bgGetActiveUserServerConfig: () => Promise; getWebVaultUrlForNotification: () => Promise; }; diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts index 6067d563db2..96809fa26b2 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -1,19 +1,22 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { CipherIconDetails } from "@bitwarden/common/vault/icon/build-cipher-icon"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { InlineMenuFillType } from "../../enums/autofill-overlay.enum"; +import AutofillField from "../../models/autofill-field"; import AutofillPageDetails from "../../models/autofill-page-details"; import { PageDetail } from "../../services/abstractions/autofill.service"; import { LockedVaultPendingNotificationsData } from "./notification.background"; -export type PageDetailsForTab = Record< - chrome.runtime.MessageSender["tab"]["id"], - Map ->; +export type TabId = NonNullable; + +export type FrameId = NonNullable; + +type PageDetailsByFrame = Map; + +export type PageDetailsForTab = Record; export type SubFrameOffsetData = { top: number; @@ -21,19 +24,14 @@ export type SubFrameOffsetData = { url?: string; frameId?: number; parentFrameIds?: number[]; + isCrossOriginSubframe?: boolean; + isMainFrame?: boolean; + hasParentFrame?: boolean; } | null; -export type SubFrameOffsetsForTab = Record< - chrome.runtime.MessageSender["tab"]["id"], - Map ->; +type SubFrameOffsetsByFrame = Map; -export type WebsiteIconData = { - imageEnabled: boolean; - image: string; - fallbackImage: string; - icon: string; -}; +export type SubFrameOffsetsForTab = Record; export type UpdateOverlayCiphersParams = { updateAllCipherTypes: boolean; @@ -49,6 +47,7 @@ export type FocusedFieldData = { accountCreationFieldType?: string; showPasskeys?: boolean; focusedFieldForm?: string; + focusedFieldOpid?: string; }; export type InlineMenuElementPosition = { @@ -146,7 +145,7 @@ export type OverlayBackgroundExtensionMessage = { isFieldCurrentlyFilling?: boolean; subFrameData?: SubFrameOffsetData; focusedFieldData?: FocusedFieldData; - allFieldsRect?: any; + allFieldsRect?: AutofillField[]; isOpeningFullInlineMenu?: boolean; styles?: Partial; data?: LockedVaultPendingNotificationsData; @@ -155,13 +154,30 @@ export type OverlayBackgroundExtensionMessage = { ToggleInlineMenuHiddenMessage & UpdateInlineMenuVisibilityMessage; +export type OverlayPortCommand = + | "fillCipher" + | "addNewVaultItem" + | "viewCipher" + | "redirectFocus" + | "updateHeight" + | "buttonClicked" + | "blurred" + | "updateColorScheme" + | "unlockVault" + | "refreshGeneratedPassword" + | "fillGeneratedPassword"; + export type OverlayPortMessage = { - [key: string]: any; - command: string; - direction?: string; + command: OverlayPortCommand; + direction?: "up" | "down" | "left" | "right"; inlineMenuCipherId?: string; addNewCipherType?: CipherType; usePasskey?: boolean; + height?: number; + backgroundColorScheme?: "light" | "dark"; + viewsCipherData?: InlineMenuCipherData; + loginUrl?: string; + fillGeneratedPassword?: boolean; }; export type InlineMenuCipherData = { @@ -170,7 +186,7 @@ export type InlineMenuCipherData = { type: CipherType; reprompt: CipherRepromptType; favorite: boolean; - icon: WebsiteIconData; + icon: CipherIconDetails; accountCreationFieldType?: string; login?: { totp?: string; @@ -201,9 +217,14 @@ export type BuildCipherDataParams = { export type BackgroundMessageParam = { message: OverlayBackgroundExtensionMessage; }; + export type BackgroundSenderParam = { - sender: chrome.runtime.MessageSender; + sender: chrome.runtime.MessageSender & { + tab: NonNullable; + frameId: FrameId; + }; }; + export type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSenderParam; export type OverlayBackgroundExtensionMessageHandlers = { @@ -253,9 +274,13 @@ export type OverlayBackgroundExtensionMessageHandlers = { export type PortMessageParam = { message: OverlayPortMessage; }; + export type PortConnectionParam = { - port: chrome.runtime.Port; + port: chrome.runtime.Port & { + sender: NonNullable; + }; }; + export type PortOnMessageHandlerParams = PortMessageParam & PortConnectionParam; export type InlineMenuButtonPortMessageHandlers = { diff --git a/apps/browser/src/autofill/background/context-menus.background.ts b/apps/browser/src/autofill/background/context-menus.background.ts index 0db2fd59af3..8c99c0b065e 100644 --- a/apps/browser/src/autofill/background/context-menus.background.ts +++ b/apps/browser/src/autofill/background/context-menus.background.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { BrowserApi } from "../../platform/browser/browser-api"; import { ContextMenuClickedHandler } from "../browser/context-menu-clicked-handler"; @@ -17,9 +15,11 @@ export default class ContextMenusBackground { return; } - this.contextMenus.onClicked.addListener((info, tab) => - this.contextMenuClickedHandler.run(info, tab), - ); + this.contextMenus.onClicked.addListener((info, tab) => { + if (tab) { + return this.contextMenuClickedHandler.run(info, tab); + } + }); BrowserApi.messageListener( "contextmenus.background", @@ -28,18 +28,16 @@ export default class ContextMenusBackground { sender: chrome.runtime.MessageSender, ) => { if (msg.command === "unlockCompleted" && msg.data.target === "contextmenus.background") { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.contextMenuClickedHandler - .cipherAction( - msg.data.commandToRetry.message.contextMenuOnClickData, - msg.data.commandToRetry.sender.tab, - ) - .then(() => { - // 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 - BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar"); + const onClickData = msg.data.commandToRetry.message.contextMenuOnClickData; + const senderTab = msg.data.commandToRetry.sender.tab; + + if (onClickData && senderTab) { + void this.contextMenuClickedHandler.cipherAction(onClickData, senderTab).then(() => { + if (sender.tab) { + void BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar"); + } }); + } } }, ); diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index 032baf2e32b..8df21bc66ef 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -133,19 +133,11 @@ describe("NotificationBackground", () => { expect(cipherView.name).toEqual("example.com"); expect(cipherView.login).toEqual({ - autofillOnPageLoad: null, - fido2Credentials: null, + fido2Credentials: [], password: message.password, - passwordRevisionDate: null, - totp: null, uris: [ { - _canLaunch: null, - _domain: null, - _host: null, - _hostname: null, _uri: message.uri, - match: null, }, ], username: message.username, @@ -289,7 +281,6 @@ describe("NotificationBackground", () => { let tab: chrome.tabs.Tab; let sender: chrome.runtime.MessageSender; let getEnableAddedLoginPromptSpy: jest.SpyInstance; - let getEnableChangedPasswordPromptSpy: jest.SpyInstance; let pushAddLoginToQueueSpy: jest.SpyInstance; let pushChangePasswordToQueueSpy: jest.SpyInstance; let getAllDecryptedForUrlSpy: jest.SpyInstance; @@ -306,10 +297,7 @@ describe("NotificationBackground", () => { notificationBackground as any, "getEnableAddedLoginPrompt", ); - getEnableChangedPasswordPromptSpy = jest.spyOn( - notificationBackground as any, - "getEnableChangedPasswordPrompt", - ); + pushAddLoginToQueueSpy = jest.spyOn(notificationBackground as any, "pushAddLoginToQueue"); pushChangePasswordToQueueSpy = jest.spyOn( notificationBackground as any, @@ -368,24 +356,6 @@ describe("NotificationBackground", () => { expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); }); - it("skips attempting to change the password for an existing login if the user has disabled changing the password notification", async () => { - const data: ModifyLoginCipherFormData = mockModifyLoginCipherFormData; - activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); - getEnableAddedLoginPromptSpy.mockReturnValueOnce(true); - getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); - getAllDecryptedForUrlSpy.mockResolvedValueOnce([ - mock({ login: { username: "test", password: "oldPassword" } }), - ]); - - await notificationBackground.triggerAddLoginNotification(data, tab); - - expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled(); - expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); - expect(getEnableChangedPasswordPromptSpy).toHaveBeenCalled(); - expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); - expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); - }); - it("skips attempting to change the password for an existing login if the password has not changed", async () => { const data: ModifyLoginCipherFormData = mockModifyLoginCipherFormData; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); @@ -445,37 +415,12 @@ describe("NotificationBackground", () => { sender.tab, ); }); - - it("adds a change password message to the queue if the user has changed an existing cipher's password", async () => { - const data: ModifyLoginCipherFormData = { - ...mockModifyLoginCipherFormData, - username: "tEsT", - }; - - activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); - getEnableAddedLoginPromptSpy.mockResolvedValueOnce(true); - getEnableChangedPasswordPromptSpy.mockResolvedValueOnce(true); - getAllDecryptedForUrlSpy.mockResolvedValueOnce([ - mock({ - id: "cipher-id", - login: { username: "test", password: "oldPassword" }, - }), - ]); - - await notificationBackground.triggerAddLoginNotification(data, tab); - - expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( - "cipher-id", - "example.com", - data.password, - sender.tab, - ); - }); }); describe("bgTriggerChangedPasswordNotification message handler", () => { let tab: chrome.tabs.Tab; let sender: chrome.runtime.MessageSender; + let getEnableChangedPasswordPromptSpy: jest.SpyInstance; let pushChangePasswordToQueueSpy: jest.SpyInstance; let getAllDecryptedForUrlSpy: jest.SpyInstance; const mockModifyLoginCipherFormData: ModifyLoginCipherFormData = { @@ -488,6 +433,11 @@ describe("NotificationBackground", () => { beforeEach(() => { tab = createChromeTabMock(); sender = mock({ tab }); + getEnableChangedPasswordPromptSpy = jest.spyOn( + notificationBackground as any, + "getEnableChangedPasswordPrompt", + ); + pushChangePasswordToQueueSpy = jest.spyOn( notificationBackground as any, "pushChangePasswordToQueue", @@ -495,6 +445,40 @@ describe("NotificationBackground", () => { getAllDecryptedForUrlSpy = jest.spyOn(cipherService, "getAllDecryptedForUrl"); }); + afterEach(() => { + getEnableChangedPasswordPromptSpy.mockRestore(); + pushChangePasswordToQueueSpy.mockRestore(); + getAllDecryptedForUrlSpy.mockRestore(); + }); + + it("skips attempting to change the password for an existing login if the user has disabled changing the password notification", async () => { + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + }; + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + getAllDecryptedForUrlSpy.mockResolvedValueOnce([ + mock({ login: { username: "test", password: "oldPassword" } }), + ]); + + await notificationBackground.triggerChangedPasswordNotification(data, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + }); + + it("skips attempting to add the change password message to the queue if the user is logged out", async () => { + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + uri: "https://example.com", + }; + + activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut); + + await notificationBackground.triggerChangedPasswordNotification(data, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + }); + it("skips attempting to add the change password message to the queue if the passed url is not valid", async () => { const data: ModifyLoginCipherFormData = mockModifyLoginCipherFormData; @@ -503,7 +487,92 @@ describe("NotificationBackground", () => { expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); }); - it("adds a change password message to the queue if the user does not have an unlocked account", async () => { + it("only only includes ciphers in notification data matching a username if username was present in the modify form data", async () => { + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + uri: "https://example.com", + username: "userName", + }; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce([ + mock({ + id: "cipher-id-1", + login: { username: "test", password: "currentPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "username", password: "currentPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "uSeRnAmE", password: "currentPassword" }, + }), + ]); + + await notificationBackground.triggerChangedPasswordNotification(data, tab); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-2", "cipher-id-3"], + "example.com", + data?.newPassword, + sender.tab, + ); + }); + + it("adds a change password message to the queue with current password, if there is a current password, but no new password", async () => { + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + uri: "https://example.com", + password: "newPasswordUpdatedElsewhere", + newPassword: null, + }; + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce([ + mock({ + id: "cipher-id-1", + login: { password: "currentPassword" }, + }), + ]); + await notificationBackground.triggerChangedPasswordNotification(data, tab); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1"], + "example.com", + data?.password, + sender.tab, + ); + }); + + it("adds a change password message to the queue with new password, if new password is provided", async () => { + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + uri: "https://example.com", + password: "password2", + newPassword: "password3", + }; + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce([ + mock({ + id: "cipher-id-1", + login: { password: "password1" }, + }), + mock({ + id: "cipher-id-4", + login: { password: "password4" }, + }), + ]); + await notificationBackground.triggerChangedPasswordNotification(data, tab); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-4"], + "example.com", + data?.newPassword, + sender.tab, + ); + }); + + it("adds a change password message to the queue if the user has a locked account", async () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, uri: "https://example.com", @@ -522,10 +591,12 @@ describe("NotificationBackground", () => { ); }); - it("skips adding a change password message to the queue if the multiple ciphers exist for the passed URL and the current password is not found within the list of ciphers", async () => { + it("doesn't add a password if there is no current or new password", async () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, uri: "https://example.com", + password: null, + newPassword: null, }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -537,23 +608,6 @@ describe("NotificationBackground", () => { expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); }); - it("skips adding a change password message if more than one existing cipher is found with a matching password ", async () => { - const data: ModifyLoginCipherFormData = { - ...mockModifyLoginCipherFormData, - uri: "https://example.com", - }; - activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); - getAllDecryptedForUrlSpy.mockResolvedValueOnce([ - mock({ login: { username: "test", password: "password" } }), - mock({ login: { username: "test2", password: "password" } }), - ]); - - await notificationBackground.triggerChangedPasswordNotification(data, tab); - - expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); - expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); - }); - it("adds a change password message to the queue if a single cipher matches the passed current password", async () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, @@ -570,28 +624,39 @@ describe("NotificationBackground", () => { await notificationBackground.triggerChangedPasswordNotification(data, tab); expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( - "cipher-id", + ["cipher-id"], "example.com", data?.newPassword, sender.tab, ); }); - it("skips adding a change password message if no current password is passed in the message and more than one cipher is found for a url", async () => { + it("adds a change password message with all matching ciphers if no current password is passed and more than one cipher is found for a url", async () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, uri: "https://example.com", + password: null, }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ - mock({ login: { username: "test", password: "password" } }), - mock({ login: { username: "test2", password: "password" } }), + mock({ + id: "cipher-id-1", + login: { username: "test", password: "password" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "test2", password: "password" }, + }), ]); await notificationBackground.triggerChangedPasswordNotification(data, tab); - expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); - expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2"], + "example.com", + data?.newPassword, + sender.tab, + ); }); it("adds a change password message to the queue if no current password is passed with the message, but a single cipher is matched for the uri", async () => { @@ -611,7 +676,7 @@ describe("NotificationBackground", () => { await notificationBackground.triggerChangedPasswordNotification(data, tab); expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( - "cipher-id", + ["cipher-id"], "example.com", data?.newPassword, sender.tab, @@ -1465,5 +1530,63 @@ describe("NotificationBackground", () => { expect(environmentServiceSpy).toHaveBeenCalled(); }); }); + + describe("handleUnlockPopoutClosed", () => { + let onRemovedListeners: Array<(tabId: number, removeInfo: chrome.tabs.OnRemovedInfo) => void>; + let tabsQuerySpy: jest.SpyInstance; + + beforeEach(() => { + onRemovedListeners = []; + chrome.tabs.onRemoved.addListener = jest.fn((listener) => { + onRemovedListeners.push(listener); + }); + chrome.runtime.getURL = jest.fn().mockReturnValue("chrome-extension://id/popup/index.html"); + notificationBackground.init(); + }); + + const triggerTabRemoved = async (tabId: number) => { + onRemovedListeners[0](tabId, mock()); + await flushPromises(); + }; + + it("sends abandon message when unlock popout is closed and vault is locked", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + tabsQuerySpy = jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([]); + + await triggerTabRemoved(1); + + expect(tabsQuerySpy).toHaveBeenCalled(); + expect(messagingService.send).toHaveBeenCalledWith("abandonAutofillPendingNotifications"); + }); + + it("uses tracked tabId for fast lookup when available", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + tabsQuerySpy = jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([ + { + id: 123, + url: "chrome-extension://id/popup/index.html?singleActionPopout=auth_unlockExtension", + } as chrome.tabs.Tab, + ]); + + await triggerTabRemoved(999); + tabsQuerySpy.mockClear(); + messagingService.send.mockClear(); + + await triggerTabRemoved(123); + + expect(tabsQuerySpy).not.toHaveBeenCalled(); + expect(messagingService.send).toHaveBeenCalledWith("abandonAutofillPendingNotifications"); + }); + + it("returns early when vault is unlocked", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + tabsQuerySpy = jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([]); + + await triggerTabRemoved(1); + + expect(tabsQuerySpy).not.toHaveBeenCalled(); + expect(messagingService.send).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index d44bf2f1507..547c5ba1575 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -45,7 +45,7 @@ import { SecurityTask } from "@bitwarden/common/vault/tasks/models/security-task // FIXME (PM-22628): Popup imports are forbidden in background // eslint-disable-next-line no-restricted-imports -import { openUnlockPopout } from "../../auth/popup/utils/auth-popout-window"; +import { AuthPopoutType, openUnlockPopout } from "../../auth/popup/utils/auth-popout-window"; import { BrowserApi } from "../../platform/browser/browser-api"; // FIXME (PM-22628): Popup imports are forbidden in background // eslint-disable-next-line no-restricted-imports @@ -89,6 +89,7 @@ export default class NotificationBackground { ExtensionCommand.AutofillCard, ExtensionCommand.AutofillIdentity, ]); + private unlockPopoutTabId?: number; private readonly extensionMessageHandlers: NotificationBackgroundExtensionMessageHandlers = { bgAdjustNotificationBar: ({ message, sender }) => this.handleAdjustNotificationBarMessage(message, sender), @@ -146,6 +147,7 @@ export default class NotificationBackground { } this.setupExtensionMessageListener(); + this.setupUnlockPopoutCloseListener(); this.cleanupNotificationQueue(); } @@ -213,14 +215,26 @@ export default class NotificationBackground { let cipherView: CipherView; if (cipherQueueMessage.type === NotificationType.ChangePassword) { const { - data: { cipherId }, + data: { cipherIds }, } = cipherQueueMessage; - cipherView = await this.getDecryptedCipherById(cipherId, activeUserId); + const cipherViews = await this.cipherService.getAllDecrypted(activeUserId); + return cipherViews + .filter((cipher) => cipherIds.includes(cipher.id)) + .map((cipherView) => { + const organizationType = getOrganizationType(cipherView.organizationId); + return this.convertToNotificationCipherData( + cipherView, + iconsServerUrl, + showFavicons, + organizationType, + ); + }); } else { cipherView = this.convertAddLoginQueueMessageToCipherView(cipherQueueMessage); } const organizationType = getOrganizationType(cipherView.organizationId); + return [ this.convertToNotificationCipherData( cipherView, @@ -555,16 +569,6 @@ export default class NotificationBackground { return true; } - const changePasswordIsEnabled = await this.getEnableChangedPasswordPrompt(); - - if ( - changePasswordIsEnabled && - usernameMatches.length === 1 && - usernameMatches[0].login.password !== login.password - ) { - await this.pushChangePasswordToQueue(usernameMatches[0].id, loginDomain, login.password, tab); - return true; - } return false; } @@ -603,45 +607,92 @@ export default class NotificationBackground { data: ModifyLoginCipherFormData, tab: chrome.tabs.Tab, ): Promise { - const changeData = { - url: data.uri, - currentPassword: data.password, - newPassword: data.newPassword, - }; - - const loginDomain = Utils.getDomain(changeData.url); - if (loginDomain == null) { + const changePasswordIsEnabled = await this.getEnableChangedPasswordPrompt(); + if (!changePasswordIsEnabled) { return false; } - - if ((await this.getAuthStatus()) < AuthenticationStatus.Unlocked) { - await this.pushChangePasswordToQueue(null, loginDomain, changeData.newPassword, tab, true); - return true; + const authStatus = await this.getAuthStatus(); + if (authStatus === AuthenticationStatus.LoggedOut) { + return false; } - - let id: string = null; const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(getOptionalUserId), ); - if (activeUserId == null) { + if (activeUserId === null) { + return false; + } + const loginDomain = Utils.getDomain(data.uri); + if (loginDomain === null) { return false; } - const ciphers = await this.cipherService.getAllDecryptedForUrl(changeData.url, activeUserId); - if (changeData.currentPassword != null) { - const passwordMatches = ciphers.filter( - (c) => c.login.password === changeData.currentPassword, - ); - if (passwordMatches.length === 1) { - id = passwordMatches[0].id; - } - } else if (ciphers.length === 1) { - id = ciphers[0].id; - } - if (id != null) { - await this.pushChangePasswordToQueue(id, loginDomain, changeData.newPassword, tab); + const username: string | null = data.username || null; + const currentPassword = data.password || null; + const newPassword = data.newPassword || null; + + if (authStatus === AuthenticationStatus.Locked && newPassword !== null) { + await this.pushChangePasswordToQueue(null, loginDomain, newPassword, tab, true); return true; } + + let ciphers: CipherView[] = await this.cipherService.getAllDecryptedForUrl( + data.uri, + activeUserId, + ); + + const normalizedUsername: string = username ? username.toLowerCase() : ""; + + const shouldMatchUsername = typeof username === "string" && username.length > 0; + + if (shouldMatchUsername) { + // Presence of a username should filter ciphers further. + ciphers = ciphers.filter( + (cipher) => + cipher.login.username !== null && + cipher.login.username.toLowerCase() === normalizedUsername, + ); + } + + if (ciphers.length === 1) { + const [cipher] = ciphers; + if ( + username !== null && + newPassword === null && + cipher.login.username === normalizedUsername && + cipher.login.password === currentPassword + ) { + // Assumed to be a login + return false; + } + } + + if (currentPassword && !newPassword) { + // Only use current password for change if no new password present. + if (ciphers.length > 0) { + await this.pushChangePasswordToQueue( + ciphers.map((cipher) => cipher.id), + loginDomain, + currentPassword, + tab, + ); + return true; + } + } + + if (newPassword) { + // Otherwise include all known ciphers. + if (ciphers.length > 0) { + await this.pushChangePasswordToQueue( + ciphers.map((cipher) => cipher.id), + loginDomain, + newPassword, + tab, + ); + + return true; + } + } + return false; } @@ -666,7 +717,7 @@ export default class NotificationBackground { } private async pushChangePasswordToQueue( - cipherId: string, + cipherIds: CipherView["id"][], loginDomain: string, newPassword: string, tab: chrome.tabs.Tab, @@ -677,7 +728,7 @@ export default class NotificationBackground { const launchTimestamp = new Date().getTime(); const message: AddChangePasswordNotificationQueueMessage = { type: NotificationType.ChangePassword, - data: { cipherId: cipherId, newPassword: newPassword }, + data: { cipherIds: cipherIds, newPassword: newPassword }, domain: loginDomain, tab: tab, launchTimestamp, @@ -716,12 +767,12 @@ export default class NotificationBackground { return; } - await this.saveOrUpdateCredentials(sender.tab, message.edit, message.folder); + await this.saveOrUpdateCredentials(sender.tab, message.cipherId, message.edit, message.folder); } async handleCipherUpdateRepromptResponse(message: NotificationBackgroundExtensionMessage) { if (message.success) { - await this.saveOrUpdateCredentials(message.tab, false, undefined, true); + await this.saveOrUpdateCredentials(message.tab, message.cipherId, false, undefined, true); } else { await BrowserApi.tabSendMessageData(message.tab, "saveCipherAttemptCompleted", { error: "Password reprompt failed", @@ -740,6 +791,7 @@ export default class NotificationBackground { */ private async saveOrUpdateCredentials( tab: chrome.tabs.Tab, + cipherId: CipherView["id"], edit: boolean, folderId?: string, skipReprompt: boolean = false, @@ -764,7 +816,7 @@ export default class NotificationBackground { if (queueMessage.type === NotificationType.ChangePassword) { const { - data: { cipherId, newPassword }, + data: { newPassword }, } = queueMessage; const cipherView = await this.getDecryptedCipherById(cipherId, activeUserId); @@ -1113,6 +1165,7 @@ export default class NotificationBackground { message: NotificationBackgroundExtensionMessage, sender: chrome.runtime.MessageSender, ): Promise { + this.unlockPopoutTabId = undefined; const messageData = message.data as LockedVaultPendingNotificationsData; const retryCommand = messageData.commandToRetry.message.command as ExtensionCommandType; if (this.allowedRetryCommands.has(retryCommand)) { @@ -1221,7 +1274,6 @@ export default class NotificationBackground { cipherView.folderId = folderId; cipherView.type = CipherType.Login; cipherView.login = loginView; - cipherView.organizationId = null; return cipherView; } @@ -1264,4 +1316,43 @@ export default class NotificationBackground { const tabDomain = Utils.getDomain(tab.url); return tabDomain === queueMessage.domain || tabDomain === Utils.getDomain(queueMessage.tab.url); } + + private setupUnlockPopoutCloseListener() { + chrome.tabs.onRemoved.addListener(async (tabId: number) => { + await this.handleUnlockPopoutClosed(tabId); + }); + } + + /** + * If the unlock popout is closed while the vault + * is still locked and there are pending autofill notifications, abandon them. + */ + private async handleUnlockPopoutClosed(removedTabId: number) { + const authStatus = await this.getAuthStatus(); + if (authStatus >= AuthenticationStatus.Unlocked) { + this.unlockPopoutTabId = undefined; + return; + } + + if (this.unlockPopoutTabId === removedTabId) { + this.unlockPopoutTabId = undefined; + this.messagingService.send("abandonAutofillPendingNotifications"); + return; + } + + if (this.unlockPopoutTabId) { + return; + } + + const extensionUrl = BrowserApi.getRuntimeURL("popup/index.html"); + const unlockPopoutTabs = (await BrowserApi.tabsQuery({ url: `${extensionUrl}*` })).filter( + (tab) => tab.url?.includes(`singleActionPopout=${AuthPopoutType.unlockExtension}`), + ); + + if (unlockPopoutTabs.length === 0) { + this.messagingService.send("abandonAutofillPendingNotifications"); + } else if (unlockPopoutTabs[0].id) { + this.unlockPopoutTabId = unlockPopoutTabs[0].id; + } + } } diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.ts b/apps/browser/src/autofill/background/overlay-notifications.background.ts index 4657dfb6d1f..e08fe540710 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.ts @@ -455,12 +455,12 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg notificationType: NotificationType, ): boolean => { switch (notificationType) { - case NotificationTypes.Change: - return modifyLoginData?.newPassword && !modifyLoginData.username; case NotificationTypes.Add: return ( modifyLoginData?.username && !!(modifyLoginData.password || modifyLoginData.newPassword) ); + case NotificationTypes.Change: + return !!(modifyLoginData.password || modifyLoginData.newPassword); case NotificationTypes.AtRiskPassword: return !modifyLoginData.newPassword; case NotificationTypes.Unlock: diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 47a5e8fec4c..50fb291b121 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -1,6 +1,7 @@ import { mock, MockProxy, mockReset } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { @@ -105,6 +106,7 @@ describe("OverlayBackground", () => { let platformUtilsService: MockProxy; let enablePasskeysMock$: BehaviorSubject; let vaultSettingsServiceMock: MockProxy; + const policyService = mock(); let fido2ActiveRequestManager: Fido2ActiveRequestManager; let selectedThemeMock$: BehaviorSubject; let inlineMenuFieldQualificationService: InlineMenuFieldQualificationService; @@ -156,7 +158,11 @@ describe("OverlayBackground", () => { fakeStateProvider = new FakeStateProvider(accountService); showFaviconsMock$ = new BehaviorSubject(true); neverDomainsMock$ = new BehaviorSubject({}); - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); + domainSettingsService = new DefaultDomainSettingsService( + fakeStateProvider, + policyService, + accountService, + ); domainSettingsService.showFavicons$ = showFaviconsMock$; domainSettingsService.neverDomains$ = neverDomainsMock$; logService = mock(); @@ -3280,6 +3286,9 @@ describe("OverlayBackground", () => { pageDetails: [pageDetailsForTab], fillNewPassword: true, allowTotpAutofill: true, + focusedFieldForm: undefined, + focusedFieldOpid: undefined, + inlineMenuFillType: undefined, }); expect(overlayBackground["inlineMenuCiphers"].entries()).toStrictEqual( new Map([ @@ -3674,6 +3683,9 @@ describe("OverlayBackground", () => { pageDetails: [overlayBackground["pageDetailsForTab"][sender.tab.id].get(sender.frameId)], fillNewPassword: true, allowTotpAutofill: false, + focusedFieldForm: undefined, + focusedFieldOpid: undefined, + inlineMenuFillType: InlineMenuFillTypes.PasswordGeneration, }); }); }); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 35585d58863..af8141f1ab8 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -1176,6 +1176,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { fillNewPassword: true, allowTotpAutofill: true, focusedFieldForm: this.focusedFieldData?.focusedFieldForm, + focusedFieldOpid: this.focusedFieldData?.focusedFieldOpid, + inlineMenuFillType: this.focusedFieldData?.inlineMenuFillType, }); if (totpCode) { @@ -1861,6 +1863,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { fillNewPassword: true, allowTotpAutofill: false, focusedFieldForm: this.focusedFieldData?.focusedFieldForm, + focusedFieldOpid: this.focusedFieldData?.focusedFieldOpid, + inlineMenuFillType: InlineMenuFillTypes.PasswordGeneration, }); globalThis.setTimeout(async () => { @@ -2945,17 +2949,21 @@ export class OverlayBackground implements OverlayBackgroundInterface { (await this.checkFocusedFieldHasValue(port.sender.tab)) && (await this.shouldShowSaveLoginInlineMenuList(port.sender.tab)); + const iframeUrl = BrowserApi.getRuntimeURL( + `overlay/menu-${isInlineMenuListPort ? "list" : "button"}.html`, + ); + const styleSheetUrl = BrowserApi.getRuntimeURL( + `overlay/menu-${isInlineMenuListPort ? "list" : "button"}.css`, + ); + const extensionOrigin = iframeUrl ? new URL(iframeUrl).origin : null; + this.postMessageToPort(port, { command: `initAutofillInlineMenu${isInlineMenuListPort ? "List" : "Button"}`, - iframeUrl: chrome.runtime.getURL( - `overlay/menu-${isInlineMenuListPort ? "list" : "button"}.html`, - ), + iframeUrl, pageTitle: chrome.i18n.getMessage( isInlineMenuListPort ? "bitwardenVault" : "bitwardenOverlayButton", ), - styleSheetUrl: chrome.runtime.getURL( - `overlay/menu-${isInlineMenuListPort ? "list" : "button"}.css`, - ), + styleSheetUrl, theme: await firstValueFrom(this.themeStateService.selectedTheme$), translations: this.getInlineMenuTranslations(), ciphers: isInlineMenuListPort ? await this.getInlineMenuCipherData() : null, @@ -2969,6 +2977,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { showSaveLoginMenu, showInlineMenuAccountCreation, authStatus, + extensionOrigin, }); this.updateInlineMenuPosition( port.sender, diff --git a/apps/browser/src/autofill/background/tabs.background.spec.ts b/apps/browser/src/autofill/background/tabs.background.spec.ts index 635ab8504a1..7bfa3b83c16 100644 --- a/apps/browser/src/autofill/background/tabs.background.spec.ts +++ b/apps/browser/src/autofill/background/tabs.background.spec.ts @@ -39,9 +39,7 @@ describe("TabsBackground", () => { "handleWindowOnFocusChanged", ); - // 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 - tabsBackground.init(); + void tabsBackground.init(); expect(chrome.windows.onFocusChanged.addListener).toHaveBeenCalledWith( handleWindowOnFocusChangedSpy, 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 c33cb6a4371..6f0979d4fd5 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts @@ -191,9 +191,11 @@ export class ContextMenuClickedHandler { }); } else { this.copyToClipboard({ text: cipher.login.password, tab: tab }); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.eventCollectionService.collect(EventType.Cipher_ClientCopiedPassword, cipher.id); + + void this.eventCollectionService.collect( + EventType.Cipher_ClientCopiedPassword, + cipher.id, + ); } break; diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.ts index 00ff55f5517..5a47975684c 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-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, map } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -179,9 +177,11 @@ export class MainContextMenuHandler { try { const account = await firstValueFrom(this.accountService.activeAccount$); - const hasPremium = await firstValueFrom( - this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), - ); + const hasPremium = + !!account?.id && + (await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + )); const isCardRestricted = ( await firstValueFrom(this.restrictedItemTypesService.restricted$) @@ -198,14 +198,16 @@ export class MainContextMenuHandler { if (requiresPremiumAccess && !hasPremium) { continue; } - if (menuItem.id.startsWith(AUTOFILL_CARD_ID) && isCardRestricted) { + if (menuItem.id?.startsWith(AUTOFILL_CARD_ID) && isCardRestricted) { continue; } await MainContextMenuHandler.create({ ...otherOptions, contexts: ["all"] }); } } catch (error) { - this.logService.warning(error.message); + if (error instanceof Error) { + this.logService.warning(error.message); + } } finally { this.initRunning = false; } @@ -318,9 +320,11 @@ export class MainContextMenuHandler { } const account = await firstValueFrom(this.accountService.activeAccount$); - const canAccessPremium = await firstValueFrom( - this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), - ); + const canAccessPremium = + !!account?.id && + (await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + )); if (canAccessPremium && (!cipher || !Utils.isNullOrEmpty(cipher.login?.totp))) { await createChildItem(COPY_VERIFICATION_CODE_ID); } @@ -333,7 +337,9 @@ export class MainContextMenuHandler { await createChildItem(AUTOFILL_IDENTITY_ID); } } catch (error) { - this.logService.warning(error.message); + if (error instanceof Error) { + this.logService.warning(error.message); + } } } @@ -351,7 +357,11 @@ export class MainContextMenuHandler { this.loadOptions( this.i18nService.t(authed ? "unlockVaultMenu" : "loginToVaultMenu"), NOOP_COMMAND_SUFFIX, - ).catch((error) => this.logService.warning(error.message)); + ).catch((error) => { + if (error instanceof Error) { + return this.logService.warning(error.message); + } + }); } } @@ -363,7 +373,9 @@ export class MainContextMenuHandler { } } } catch (error) { - this.logService.warning(error.message); + if (error instanceof Error) { + this.logService.warning(error.message); + } } } @@ -373,7 +385,9 @@ export class MainContextMenuHandler { await MainContextMenuHandler.create(menuItem); } } catch (error) { - this.logService.warning(error.message); + if (error instanceof Error) { + this.logService.warning(error.message); + } } } @@ -383,7 +397,9 @@ export class MainContextMenuHandler { await MainContextMenuHandler.create(menuItem); } } catch (error) { - this.logService.warning(error.message); + if (error instanceof Error) { + this.logService.warning(error.message); + } } } @@ -395,7 +411,9 @@ export class MainContextMenuHandler { await this.loadOptions(this.i18nService.t("addLoginMenu"), CREATE_LOGIN_ID); } catch (error) { - this.logService.warning(error.message); + if (error instanceof Error) { + this.logService.warning(error.message); + } } } } diff --git a/apps/browser/src/autofill/content/auto-submit-login.ts b/apps/browser/src/autofill/content/auto-submit-login.ts index ca5c8ebee80..511d35d7a49 100644 --- a/apps/browser/src/autofill/content/auto-submit-login.ts +++ b/apps/browser/src/autofill/content/auto-submit-login.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { EVENTS } from "@bitwarden/common/autofill/constants"; import AutofillPageDetails from "../models/autofill-page-details"; @@ -123,9 +121,9 @@ import { * @param fillScript - The autofill script to use */ function triggerAutoSubmitOnForm(fillScript: AutofillScript) { - const formOpid = fillScript.autosubmit[0]; + const formOpid = fillScript.autosubmit?.[0]; - if (formOpid === null) { + if (!formOpid) { triggerAutoSubmitOnFormlessFields(fillScript); return; } @@ -159,8 +157,11 @@ import { fillScript.script[fillScript.script.length - 1][1], ); - const lastFieldIsPasswordInput = - elementIsInputElement(currentElement) && currentElement.type === "password"; + const lastFieldIsPasswordInput = !!( + currentElement && + elementIsInputElement(currentElement) && + currentElement.type === "password" + ); while (currentElement && currentElement.tagName !== "HTML") { if (submitElementFoundAndClicked(currentElement, lastFieldIsPasswordInput)) { diff --git a/apps/browser/src/autofill/content/components/buttons/action-button.ts b/apps/browser/src/autofill/content/components/buttons/action-button.ts index b43bed7f96b..73fc1e79ec5 100644 --- a/apps/browser/src/autofill/content/components/buttons/action-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/action-button.ts @@ -68,7 +68,7 @@ const actionButtonStyles = ({ overflow: hidden; text-align: center; text-overflow: ellipsis; - font-weight: 700; + font-weight: 500; ${disabled || isLoading ? ` diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-action.ts b/apps/browser/src/autofill/content/components/cipher/cipher-action.ts index 34ad5e1c9a9..7a392849996 100644 --- a/apps/browser/src/autofill/content/components/cipher/cipher-action.ts +++ b/apps/browser/src/autofill/content/components/cipher/cipher-action.ts @@ -4,8 +4,10 @@ import { BadgeButton } from "../../../content/components/buttons/badge-button"; import { EditButton } from "../../../content/components/buttons/edit-button"; import { NotificationTypes } from "../../../notification/abstractions/notification-bar"; import { I18n } from "../common-types"; +import { selectedCipher as selectedCipherSignal } from "../signals/selected-cipher"; export type CipherActionProps = { + cipherId: string; handleAction?: (e: Event) => void; i18n: I18n; itemName: string; @@ -15,6 +17,7 @@ export type CipherActionProps = { }; export function CipherAction({ + cipherId, handleAction = () => { /* no-op */ }, @@ -24,9 +27,17 @@ export function CipherAction({ theme, username, }: CipherActionProps) { + const selectCipherHandleAction = (e: Event) => { + selectedCipherSignal.set(cipherId); + try { + handleAction(e); + } finally { + selectedCipherSignal.set(null); + } + }; return notificationType === NotificationTypes.Change ? BadgeButton({ - buttonAction: handleAction, + buttonAction: selectCipherHandleAction, buttonText: i18n.notificationUpdate, itemName, theme, diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-item.ts b/apps/browser/src/autofill/content/components/cipher/cipher-item.ts index ab3b57f535c..3bfc44636b3 100644 --- a/apps/browser/src/autofill/content/components/cipher/cipher-item.ts +++ b/apps/browser/src/autofill/content/components/cipher/cipher-item.ts @@ -40,6 +40,7 @@ export function CipherItem({ if (notificationType === NotificationTypes.Change || notificationType === NotificationTypes.Add) { cipherActionButton = html`
${CipherAction({ + cipherId: cipher.id, handleAction, i18n, itemName: name, diff --git a/apps/browser/src/autofill/content/components/cipher/types.ts b/apps/browser/src/autofill/content/components/cipher/types.ts index 590311682bf..f8b5d2b85bf 100644 --- a/apps/browser/src/autofill/content/components/cipher/types.ts +++ b/apps/browser/src/autofill/content/components/cipher/types.ts @@ -1,3 +1,5 @@ +import { CipherIconDetails } from "@bitwarden/common/vault/icon/build-cipher-icon"; + export const CipherTypes = { Login: 1, SecureNote: 2, @@ -22,20 +24,13 @@ export const OrganizationCategories = { family: "family", } as const; -export type WebsiteIconData = { - imageEnabled: boolean; - image: string; - fallbackImage: string; - icon: string; -}; - type BaseCipherData = { id: string; name: string; type: CipherTypeValue; reprompt: CipherRepromptType; favorite: boolean; - icon: WebsiteIconData; + icon: CipherIconDetails; }; export type CipherData = BaseCipherData & { diff --git a/apps/browser/src/autofill/content/components/constants/styles.ts b/apps/browser/src/autofill/content/components/constants/styles.ts index 55130781808..c1d6228459a 100644 --- a/apps/browser/src/autofill/content/components/constants/styles.ts +++ b/apps/browser/src/autofill/content/components/constants/styles.ts @@ -144,17 +144,17 @@ export const border = { export const typography = { body1: ` line-height: 24px; - font-family: Roboto, sans-serif; + font-family: Inter, sans-serif; font-size: 16px; `, body2: ` line-height: 20px; - font-family: Roboto, sans-serif; + font-family: Inter, sans-serif; font-size: 14px; `, helperMedium: ` line-height: 16px; - font-family: Roboto, sans-serif; + font-family: Inter, sans-serif; font-size: 12px; `, }; diff --git a/apps/browser/src/autofill/content/components/notification/at-risk-password/message.ts b/apps/browser/src/autofill/content/components/notification/at-risk-password/message.ts index 42d4907711d..9c55c1e7e2b 100644 --- a/apps/browser/src/autofill/content/components/notification/at-risk-password/message.ts +++ b/apps/browser/src/autofill/content/components/notification/at-risk-password/message.ts @@ -29,7 +29,7 @@ const baseTextStyles = css` text-align: left; text-overflow: ellipsis; line-height: 24px; - font-family: Roboto, sans-serif; + font-family: Inter, sans-serif; font-size: 16px; `; diff --git a/apps/browser/src/autofill/content/components/notification/confirmation/message.ts b/apps/browser/src/autofill/content/components/notification/confirmation/message.ts index 7f15d882297..36ea9c1f9d6 100644 --- a/apps/browser/src/autofill/content/components/notification/confirmation/message.ts +++ b/apps/browser/src/autofill/content/components/notification/confirmation/message.ts @@ -84,7 +84,7 @@ const baseTextStyles = css` text-align: left; text-overflow: ellipsis; line-height: 24px; - font-family: Roboto, sans-serif; + font-family: Inter, sans-serif; font-size: 16px; `; @@ -115,7 +115,7 @@ const notificationConfirmationButtonTextStyles = (theme: Theme) => css` ${baseTextStyles} color: ${themes[theme].primary[600]}; - font-weight: 700; + font-weight: 500; cursor: pointer; `; diff --git a/apps/browser/src/autofill/content/components/notification/header-message.ts b/apps/browser/src/autofill/content/components/notification/header-message.ts index 47fe8cd2828..2e51d82dd07 100644 --- a/apps/browser/src/autofill/content/components/notification/header-message.ts +++ b/apps/browser/src/autofill/content/components/notification/header-message.ts @@ -19,7 +19,7 @@ const notificationHeaderMessageStyles = (theme: Theme) => css` line-height: 28px; white-space: nowrap; color: ${themes[theme].text.main}; - font-family: Roboto, sans-serif; + font-family: Inter, sans-serif; font-size: 18px; - font-weight: 600; + font-weight: 500; `; diff --git a/apps/browser/src/autofill/content/components/option-selection/option-items.ts b/apps/browser/src/autofill/content/components/option-selection/option-items.ts index ceb72905357..58216b6c1b2 100644 --- a/apps/browser/src/autofill/content/components/option-selection/option-items.ts +++ b/apps/browser/src/autofill/content/components/option-selection/option-items.ts @@ -94,7 +94,7 @@ const optionsLabelStyles = ({ theme }: { theme: Theme }) => css` user-select: none; padding: 0.375rem ${spacing["3"]}; color: ${themes[theme].text.muted}; - font-weight: 600; + font-weight: 500; `; export const optionsMenuItemMaxWidth = 260; diff --git a/apps/browser/src/autofill/content/components/rows/action-row.ts b/apps/browser/src/autofill/content/components/rows/action-row.ts index 0380f91012a..8f13b166156 100644 --- a/apps/browser/src/autofill/content/components/rows/action-row.ts +++ b/apps/browser/src/autofill/content/components/rows/action-row.ts @@ -34,7 +34,7 @@ const actionRowStyles = (theme: Theme) => css` min-height: 40px; text-align: left; color: ${themes[theme].primary["600"]}; - font-weight: 700; + font-weight: 500; > span { display: block; diff --git a/apps/browser/src/autofill/content/components/signals/selected-cipher.ts b/apps/browser/src/autofill/content/components/signals/selected-cipher.ts new file mode 100644 index 00000000000..360457233f5 --- /dev/null +++ b/apps/browser/src/autofill/content/components/signals/selected-cipher.ts @@ -0,0 +1,3 @@ +import { signal } from "@lit-labs/signals"; + +export const selectedCipher = signal(null); diff --git a/apps/browser/src/autofill/content/content-message-handler.spec.ts b/apps/browser/src/autofill/content/content-message-handler.spec.ts index fe023f344d6..874e1cc76ff 100644 --- a/apps/browser/src/autofill/content/content-message-handler.spec.ts +++ b/apps/browser/src/autofill/content/content-message-handler.spec.ts @@ -56,7 +56,11 @@ describe("ContentMessageHandler", () => { }); it("sends an authResult message", () => { - postWindowMessage({ command: "authResult", lastpass: true, code: "code", state: "state" }); + postWindowMessage( + { command: "authResult", lastpass: true, code: "code", state: "state" }, + "https://localhost/", + window, + ); expect(sendMessageSpy).toHaveBeenCalledWith({ command: "authResult", @@ -68,7 +72,11 @@ describe("ContentMessageHandler", () => { }); it("sends a webAuthnResult message", () => { - postWindowMessage({ command: "webAuthnResult", data: "data", remember: true }); + postWindowMessage( + { command: "webAuthnResult", data: "data", remember: true }, + "https://localhost/", + window, + ); expect(sendMessageSpy).toHaveBeenCalledWith({ command: "webAuthnResult", @@ -82,7 +90,7 @@ describe("ContentMessageHandler", () => { const mockCode = "mockCode"; const command = "duoResult"; - postWindowMessage({ command: command, code: mockCode }); + postWindowMessage({ command: command, code: mockCode }, "https://localhost/", window); expect(sendMessageSpy).toHaveBeenCalledWith({ command: command, diff --git a/apps/browser/src/autofill/content/content-message-handler.ts b/apps/browser/src/autofill/content/content-message-handler.ts index c57b2d959f3..63afc215923 100644 --- a/apps/browser/src/autofill/content/content-message-handler.ts +++ b/apps/browser/src/autofill/content/content-message-handler.ts @@ -86,17 +86,30 @@ function handleOpenBrowserExtensionToUrlMessage({ url }: { url?: ExtensionPageUr } /** - * Handles the window message event. + * Handles window message events, validating source and extracting referrer for security. * * @param event - The window message event */ function handleWindowMessageEvent(event: MessageEvent) { - const { source, data } = event; + const { source, data, origin } = event; if (source !== window || !data?.command) { return; } - const referrer = source.location.hostname; + // Extract hostname from event.origin for secure referrer validation in background script + let referrer: string; + // Sandboxed iframe or opaque origin support + if (origin === "null") { + referrer = "null"; + } else { + try { + const originUrl = new URL(origin); + referrer = originUrl.hostname; + } catch { + return; + } + } + const handler = windowMessageHandlers[data.command]; if (handler) { handler({ data, referrer }); diff --git a/apps/browser/src/autofill/content/context-menu-handler.ts b/apps/browser/src/autofill/content/context-menu-handler.ts index 82cf95afc81..d3926d57c9a 100644 --- a/apps/browser/src/autofill/content/context-menu-handler.ts +++ b/apps/browser/src/autofill/content/context-menu-handler.ts @@ -1,43 +1,43 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore const inputTags = ["input", "textarea", "select"]; const labelTags = ["label", "span"]; -const attributes = ["id", "name", "label-aria", "placeholder"]; +const attributeKeys = ["id", "name", "label-aria", "placeholder"]; const invalidElement = chrome.i18n.getMessage("copyCustomFieldNameInvalidElement"); const noUniqueIdentifier = chrome.i18n.getMessage("copyCustomFieldNameNotUnique"); -let clickedEl: HTMLElement = null; +let clickedElement: HTMLElement | null = null; // Find the best attribute to be used as the Name for an element in a custom field. function getClickedElementIdentifier() { - if (clickedEl == null) { + if (clickedElement == null) { return invalidElement; } - const clickedTag = clickedEl.nodeName.toLowerCase(); - let inputEl = null; + const clickedTag = clickedElement.nodeName.toLowerCase(); + let inputElement = null; // Try to identify the input element (which may not be the clicked element) if (labelTags.includes(clickedTag)) { - let inputId = null; + let inputId; if (clickedTag === "label") { - inputId = clickedEl.getAttribute("for"); + inputId = clickedElement.getAttribute("for"); } else { - inputId = clickedEl.closest("label")?.getAttribute("for"); + inputId = clickedElement.closest("label")?.getAttribute("for"); } - inputEl = document.getElementById(inputId); + if (inputId) { + inputElement = document.getElementById(inputId); + } } else { - inputEl = clickedEl; + inputElement = clickedElement; } - if (inputEl == null || !inputTags.includes(inputEl.nodeName.toLowerCase())) { + if (inputElement == null || !inputTags.includes(inputElement.nodeName.toLowerCase())) { return invalidElement; } - for (const attr of attributes) { - const attributeValue = inputEl.getAttribute(attr); - const selector = "[" + attr + '="' + attributeValue + '"]'; + for (const attributeKey of attributeKeys) { + const attributeValue = inputElement.getAttribute(attributeKey); + const selector = "[" + attributeKey + '="' + attributeValue + '"]'; if (!isNullOrEmpty(attributeValue) && document.querySelectorAll(selector)?.length === 1) { return attributeValue; } @@ -45,14 +45,14 @@ function getClickedElementIdentifier() { return noUniqueIdentifier; } -function isNullOrEmpty(s: string) { +function isNullOrEmpty(s: string | null) { return s == null || s === ""; } // We only have access to the element that's been clicked when the context menu is first opened. // Remember it for use later. document.addEventListener("contextmenu", (event) => { - clickedEl = event.target as HTMLElement; + clickedElement = event.target as HTMLElement; }); // Runs when the 'Copy Custom Field Name' context menu item is actually clicked. @@ -62,9 +62,8 @@ chrome.runtime.onMessage.addListener((event, _sender, sendResponse) => { if (sendResponse) { sendResponse(identifier); } - // 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 - chrome.runtime.sendMessage({ + + void chrome.runtime.sendMessage({ command: "getClickedElementResponse", sender: "contextMenuHandler", identifier: identifier, diff --git a/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts b/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts index 6ad069ad56e..b341be28ebb 100644 --- a/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts +++ b/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts @@ -13,6 +13,7 @@ type SharedFido2ScriptRegistrationOptions = SharedFido2ScriptInjectionDetails & matches: string[]; excludeMatches: string[]; allFrames: true; + world?: "MAIN" | "ISOLATED"; }; type Fido2ExtensionMessage = { diff --git a/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts b/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts index 752851b3d37..76ad78a6cd8 100644 --- a/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts +++ b/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts @@ -203,6 +203,7 @@ describe("Fido2Background", () => { { file: Fido2ContentScript.PageScriptDelayAppend }, { file: Fido2ContentScript.ContentScript }, ], + world: "ISOLATED", ...sharedRegistrationOptions, }); }); diff --git a/apps/browser/src/autofill/fido2/background/fido2.background.ts b/apps/browser/src/autofill/fido2/background/fido2.background.ts index 22ee4a1822d..0ee7a43767f 100644 --- a/apps/browser/src/autofill/fido2/background/fido2.background.ts +++ b/apps/browser/src/autofill/fido2/background/fido2.background.ts @@ -176,6 +176,7 @@ export class Fido2Background implements Fido2BackgroundInterface { { file: await this.getFido2PageScriptAppendFileName() }, { file: Fido2ContentScript.ContentScript }, ], + world: "ISOLATED", ...this.sharedRegistrationOptions, }); } diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script-append.mv2.spec.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script-append.mv2.spec.ts index b444c967080..0b10841e390 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script-append.mv2.spec.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script-append.mv2.spec.ts @@ -29,38 +29,48 @@ describe("FIDO2 page-script for manifest v2", () => { expect(window.document.createElement).not.toHaveBeenCalled(); }); - it("appends the `page-script.js` file to the document head when the contentType is `text/html`", () => { + it("appends the `page-script.js` file to the document head when the contentType is `text/html`", async () => { + const scriptContents = "test-script-contents"; jest.spyOn(window.document.head, "prepend").mockImplementation((node) => { createdScriptElement = node as HTMLScriptElement; return node; }); + window.fetch = jest.fn().mockResolvedValue({ + text: () => Promise.resolve(scriptContents), + } as Response); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-require-imports require("./fido2-page-script-delay-append.mv2.ts"); + await jest.runAllTimersAsync(); expect(window.document.createElement).toHaveBeenCalledWith("script"); expect(chrome.runtime.getURL).toHaveBeenCalledWith(Fido2ContentScript.PageScript); expect(window.document.head.prepend).toHaveBeenCalledWith(expect.any(HTMLScriptElement)); - expect(createdScriptElement.src).toBe(`chrome-extension://id/${Fido2ContentScript.PageScript}`); + expect(createdScriptElement.innerHTML).toBe(scriptContents); }); - it("appends the `page-script.js` file to the document element if the head is not available", () => { + it("appends the `page-script.js` file to the document element if the head is not available", async () => { + const scriptContents = "test-script-contents"; window.document.documentElement.removeChild(window.document.head); jest.spyOn(window.document.documentElement, "prepend").mockImplementation((node) => { createdScriptElement = node as HTMLScriptElement; return node; }); + window.fetch = jest.fn().mockResolvedValue({ + text: () => Promise.resolve(scriptContents), + } as Response); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-require-imports require("./fido2-page-script-delay-append.mv2.ts"); + await jest.runAllTimersAsync(); expect(window.document.createElement).toHaveBeenCalledWith("script"); expect(chrome.runtime.getURL).toHaveBeenCalledWith(Fido2ContentScript.PageScript); expect(window.document.documentElement.prepend).toHaveBeenCalledWith( expect.any(HTMLScriptElement), ); - expect(createdScriptElement.src).toBe(`chrome-extension://id/${Fido2ContentScript.PageScript}`); + expect(createdScriptElement.innerHTML).toBe(scriptContents); }); }); diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts index 775bc76266d..8c0d17c7e21 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts @@ -2,15 +2,26 @@ * This script handles injection of the FIDO2 override page script into the document. * This is required for manifest v2, but will be removed when we migrate fully to manifest v3. */ -(function (globalContext) { +void (async function (globalContext) { if (globalContext.document.contentType !== "text/html") { return; } const script = globalContext.document.createElement("script"); - script.src = chrome.runtime.getURL("content/fido2-page-script.js"); script.async = false; + const pageScriptUrl = chrome.runtime.getURL("content/fido2-page-script.js"); + // Inject the script contents directly to avoid leaking the extension URL + try { + const response = await fetch(pageScriptUrl); + const scriptContents = await response.text(); + script.innerHTML = scriptContents; + } catch { + // eslint-disable-next-line no-console + console.error("Failed to load FIDO2 page script contents. Injection failed."); + return; + } + // We are ensuring that the script injection is delayed in the event that we are loading // within an iframe element. This prevents an issue with web mail clients that load content // using ajax within iframes. In particular, Zimbra web mail client was observed to have this issue. 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 5b9ea5e5b27..1cd614a9516 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script.ts @@ -267,9 +267,7 @@ import { Messenger } from "./messaging/messenger"; clearWaitForFocus(); void messenger.destroy(); - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { + } catch { /** empty */ } } diff --git a/apps/browser/src/autofill/fido2/content/messaging/messenger.spec.ts b/apps/browser/src/autofill/fido2/content/messaging/messenger.spec.ts index 5283c60882d..1aa8c27c0ae 100644 --- a/apps/browser/src/autofill/fido2/content/messaging/messenger.spec.ts +++ b/apps/browser/src/autofill/fido2/content/messaging/messenger.spec.ts @@ -31,9 +31,8 @@ describe("Messenger", () => { it("should deliver message to B when sending request from A", () => { const request = createRequest(); - // 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 - messengerA.request(request); + + void messengerA.request(request); const received = handlerB.receive(); @@ -66,14 +65,13 @@ describe("Messenger", () => { it("should deliver abort signal to B when requesting abort", () => { const abortController = new AbortController(); - // 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 - messengerA.request(createRequest(), abortController.signal); + + void messengerA.request(createRequest(), abortController.signal); abortController.abort(); const received = handlerB.receive(); - expect(received[0].abortController.signal.aborted).toBe(true); + expect(received[0].abortController?.signal.aborted).toBe(true); }); describe("destroy", () => { @@ -103,29 +101,25 @@ describe("Messenger", () => { it("should dispatch the destroy event on messenger destruction", async () => { const request = createRequest(); - // 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 - messengerA.request(request); + + void messengerA.request(request); const dispatchEventSpy = jest.spyOn((messengerA as any).onDestroy, "dispatchEvent"); - // 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 - messengerA.destroy(); + + void messengerA.destroy(); expect(dispatchEventSpy).toHaveBeenCalledWith(expect.any(Event)); }); it("should trigger onDestroyListener when the destroy event is dispatched", async () => { const request = createRequest(); - // 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 - messengerA.request(request); + + void messengerA.request(request); const onDestroyListener = jest.fn(); (messengerA as any).onDestroy.addEventListener("destroy", onDestroyListener); - // 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 - messengerA.destroy(); + + void messengerA.destroy(); expect(onDestroyListener).toHaveBeenCalled(); const eventArg = onDestroyListener.mock.calls[0][0]; @@ -213,7 +207,7 @@ class MockMessagePort { remotePort: MockMessagePort; postMessage(message: T, port?: MessagePort) { - this.remotePort.onmessage( + this.remotePort.onmessage?.( new MessageEvent("message", { data: message, ports: port ? [port] : [], diff --git a/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts b/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts index 8de48a49a8e..5818bbf8d82 100644 --- a/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts +++ b/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts @@ -6,7 +6,7 @@ import { filter, firstValueFrom, fromEvent, - fromEventPattern, + map, merge, Observable, Subject, @@ -28,6 +28,7 @@ import { import { Utils } from "@bitwarden/common/platform/misc/utils"; import { BrowserApi } from "../../../platform/browser/browser-api"; +import { fromChromeEvent } from "../../../platform/browser/from-chrome-event"; // FIXME (PM-22628): Popup imports are forbidden in background // eslint-disable-next-line no-restricted-imports import { closeFido2Popout, openFido2Popout } from "../../../vault/popup/utils/vault-popout-window"; @@ -154,9 +155,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi } static sendMessage(msg: BrowserFido2Message) { - // 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 - BrowserApi.sendMessage(BrowserFido2MessageName, msg); + void BrowserApi.sendMessage(BrowserFido2MessageName, msg); } static abortPopout(sessionId: string, fallbackRequested = false) { @@ -205,9 +204,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi fromEvent(abortController.signal, "abort") .pipe(takeUntil(this.destroy$)) .subscribe(() => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.close(); + void this.close(); BrowserFido2UserInterfaceSession.sendMessage({ type: BrowserFido2MessageTypes.AbortRequest, sessionId: this.sessionId, @@ -223,21 +220,13 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi ) .subscribe((msg) => { if (msg.type === BrowserFido2MessageTypes.AbortResponse) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.close(); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.abort(msg.fallbackRequested); + void this.close(); + void this.abort(msg.fallbackRequested); } }); - this.windowClosed$ = fromEventPattern( - // FIXME: Make sure that is does not cause a memory leak in Safari or use BrowserApi.AddListener - // and test that it doesn't break. Tracking Ticket: https://bitwarden.atlassian.net/browse/PM-4735 - // eslint-disable-next-line no-restricted-syntax - (handler: any) => chrome.windows.onRemoved.addListener(handler), - (handler: any) => chrome.windows.onRemoved.removeListener(handler), + this.windowClosed$ = fromChromeEvent(chrome.windows.onRemoved).pipe( + map(([windowId]) => windowId), ); BrowserFido2UserInterfaceSession.sendMessage({ @@ -391,12 +380,8 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi takeUntil(this.destroy$), ) .subscribe(() => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.close(); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.abort(true); + void this.close(); + void this.abort(true); }); await connectPromise; diff --git a/apps/browser/src/autofill/models/autofill-field.ts b/apps/browser/src/autofill/models/autofill-field.ts index 1a8c3bb875b..9d2cf3773d4 100644 --- a/apps/browser/src/autofill/models/autofill-field.ts +++ b/apps/browser/src/autofill/models/autofill-field.ts @@ -1,6 +1,4 @@ import { FieldRect } from "../background/abstractions/overlay.background"; -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { AutofillFieldQualifierType } from "../enums/autofill-field.enums"; import { InlineMenuAccountCreationFieldTypes, @@ -13,34 +11,36 @@ import { export default class AutofillField { [key: string]: any; /** - * The unique identifier assigned to this field during collection of the page details + * Non-null asserted. The unique identifier assigned to this field during collection of the page details */ - opid: string; + opid!: string; /** - * Sequential number assigned to each element collected, based on its position in the DOM. + * Non-null asserted. Sequential number assigned to each element collected, based on its position in the DOM. * Used to do perform proximal checks for username and password fields on the DOM. */ - elementNumber: number; + elementNumber!: number; /** - * Designates whether the field is viewable on the current part of the DOM that the user can see + * Non-null asserted. Designates whether the field is viewable on the current part of the DOM that the user can see */ - viewable: boolean; + viewable!: boolean; /** - * The HTML `id` attribute of the field + * Non-null asserted. The HTML `id` attribute of the field */ - htmlID: string | null; + htmlID!: string | null; /** - * The HTML `name` attribute of the field + * Non-null asserted. The HTML `name` attribute of the field */ - htmlName: string | null; + htmlName!: string | null; /** - * The HTML `class` attribute of the field + * Non-null asserted. The HTML `class` attribute of the field */ - htmlClass: string | null; + htmlClass!: string | null; - tabindex: string | null; + /** Non-null asserted. */ + tabindex!: string | null; - title: string | null; + /** Non-null asserted. */ + title!: string | null; /** * The `tagName` for the field */ diff --git a/apps/browser/src/autofill/models/autofill-form.ts b/apps/browser/src/autofill/models/autofill-form.ts index d335a81b3c4..e9161620527 100644 --- a/apps/browser/src/autofill/models/autofill-form.ts +++ b/apps/browser/src/autofill/models/autofill-form.ts @@ -1,28 +1,31 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore /** * Represents an HTML form whose elements can be autofilled */ export default class AutofillForm { [key: string]: any; + /** - * The unique identifier assigned to this field during collection of the page details + * Non-null asserted. The unique identifier assigned to this field during collection of the page details */ - opid: string; + opid!: string; + /** - * The HTML `name` attribute of the form field + * Non-null asserted. The HTML `name` attribute of the form field */ - htmlName: string; + htmlName!: string; + /** - * The HTML `id` attribute of the form field + * Non-null asserted. The HTML `id` attribute of the form field */ - htmlID: string; + htmlID!: string; + /** - * The HTML `action` attribute of the form field + * Non-null asserted. The HTML `action` attribute of the form field */ - htmlAction: string; + htmlAction!: string; + /** - * The HTML `method` attribute of the form field + * Non-null asserted. The HTML `method` attribute of the form field. */ - htmlMethod: string; + htmlMethod!: "get" | "post" | string; } diff --git a/apps/browser/src/autofill/models/autofill-page-details.ts b/apps/browser/src/autofill/models/autofill-page-details.ts index c32dfed4e43..ca8c66a3152 100644 --- a/apps/browser/src/autofill/models/autofill-page-details.ts +++ b/apps/browser/src/autofill/models/autofill-page-details.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import AutofillField from "./autofill-field"; import AutofillForm from "./autofill-form"; @@ -7,16 +5,20 @@ import AutofillForm from "./autofill-form"; * The details of a page that have been collected and can be used for autofill */ export default class AutofillPageDetails { - title: string; - url: string; - documentUrl: string; + /** Non-null asserted. */ + title!: string; + /** Non-null asserted. */ + url!: string; + /** Non-null asserted. */ + documentUrl!: string; /** - * A collection of all of the forms in the page DOM, keyed by their `opid` + * Non-null asserted. A collection of all of the forms in the page DOM, keyed by their `opid` */ - forms: { [id: string]: AutofillForm }; + forms!: { [id: string]: AutofillForm }; /** - * A collection of all the fields in the page DOM, keyed by their `opid` + * Non-null asserted. A collection of all the fields in the page DOM, keyed by their `opid` */ - fields: AutofillField[]; - collectedTimestamp: number; + fields!: AutofillField[]; + /** Non-null asserted. */ + collectedTimestamp!: number; } diff --git a/apps/browser/src/autofill/models/autofill-script.ts b/apps/browser/src/autofill/models/autofill-script.ts index 1da05e07308..43c85c58c9a 100644 --- a/apps/browser/src/autofill/models/autofill-script.ts +++ b/apps/browser/src/autofill/models/autofill-script.ts @@ -1,26 +1,33 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -// String values affect code flow in autofill.ts and must not be changed -export type FillScriptActions = "click_on_opid" | "focus_by_opid" | "fill_by_opid"; - export type FillScript = [action: FillScriptActions, opid: string, value?: string]; export type AutofillScriptProperties = { delay_between_operations?: number; }; +export const FillScriptActionTypes = { + fill_by_opid: "fill_by_opid", + click_on_opid: "click_on_opid", + focus_by_opid: "focus_by_opid", +} as const; + +// String values affect code flow in autofill.ts and must not be changed +export type FillScriptActions = keyof typeof FillScriptActionTypes; + export type AutofillInsertActions = { - fill_by_opid: ({ opid, value }: { opid: string; value: string }) => void; - click_on_opid: ({ opid }: { opid: string }) => void; - focus_by_opid: ({ opid }: { opid: string }) => void; + [FillScriptActionTypes.fill_by_opid]: ({ opid, value }: { opid: string; value: string }) => void; + [FillScriptActionTypes.click_on_opid]: ({ opid }: { opid: string }) => void; + [FillScriptActionTypes.focus_by_opid]: ({ opid }: { opid: string }) => void; }; export default class AutofillScript { script: FillScript[] = []; properties: AutofillScriptProperties = {}; - metadata: any = {}; // Unused, not written or read - autosubmit: string[]; // Appears to be unused, read but not written - savedUrls: string[]; - untrustedIframe: boolean; - itemType: string; // Appears to be unused, read but not written + /** Non-null asserted. */ + autosubmit!: string[] | null; // Appears to be unused, read but not written + /** Non-null asserted. */ + savedUrls!: string[]; + /** Non-null asserted. */ + untrustedIframe!: boolean; + /** Non-null asserted. */ + itemType!: string; // Appears to be unused, read but not written } diff --git a/apps/browser/src/autofill/notification/abstractions/notification-bar.ts b/apps/browser/src/autofill/notification/abstractions/notification-bar.ts index 7881d2f1cac..b23c3c17abb 100644 --- a/apps/browser/src/autofill/notification/abstractions/notification-bar.ts +++ b/apps/browser/src/autofill/notification/abstractions/notification-bar.ts @@ -51,6 +51,7 @@ type NotificationBarWindowMessage = { }; error?: string; initData?: NotificationBarIframeInitData; + parentOrigin?: string; }; type NotificationBarWindowMessageHandlers = { diff --git a/apps/browser/src/autofill/notification/bar.html b/apps/browser/src/autofill/notification/bar.html index 796601057d8..8934fe6a031 100644 --- a/apps/browser/src/autofill/notification/bar.html +++ b/apps/browser/src/autofill/notification/bar.html @@ -4,55 +4,5 @@ Bitwarden - - -
-
- - - -
-
-
- -
-
- - - - - - - + diff --git a/apps/browser/src/autofill/notification/bar.scss b/apps/browser/src/autofill/notification/bar.scss deleted file mode 100644 index c91c5f3ebac..00000000000 --- a/apps/browser/src/autofill/notification/bar.scss +++ /dev/null @@ -1,304 +0,0 @@ -@import "../shared/styles/variables"; - -body { - margin: 0; - padding: 0; - height: 100%; - font-size: 14px; - line-height: 16px; - font-family: $font-family-sans-serif; - - @include themify($themes) { - color: themed("textColor"); - background-color: themed("backgroundColor"); - } -} - -img { - margin: 0; - padding: 0; - border: 0; -} - -button, -select { - font-size: $font-size-base; - font-family: $font-family-sans-serif; -} - -.outer-wrapper { - display: block; - position: relative; - padding: 8px; - min-height: 42px; - border: 1px solid transparent; - border-bottom: 2px solid transparent; - border-radius: 4px; - box-sizing: border-box; - - @include themify($themes) { - border-color: themed("borderColor"); - border-bottom-color: themed("primaryColor"); - } - - &.success-event { - @include themify($themes) { - border-bottom-color: themed("successColor"); - } - } - - &.error-event { - @include themify($themes) { - border-bottom-color: themed("errorColor"); - } - } -} - -.inner-wrapper { - display: grid; - grid-template-columns: auto max-content; -} - -.outer-wrapper > *, -.inner-wrapper > * { - align-self: center; -} - -#logo { - width: 24px; - height: 24px; - display: block; -} - -.logo-wrapper { - position: absolute; - top: 8px; - left: 10px; - overflow: hidden; -} - -#close-button { - display: flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - margin-right: 10px; - padding: 0; - - &:hover { - @include themify($themes) { - border-color: rgba(themed("textColor"), 0.2); - background-color: rgba(themed("textColor"), 0.2); - } - } -} - -#close { - display: block; - width: 16px; - height: 16px; - - > path { - @include themify($themes) { - fill: themed("textColor"); - } - } -} - -.notification-close { - position: absolute; - top: 6px; - right: 6px; -} - -#content .inner-wrapper { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - - .notification-body { - width: 100%; - padding: 4px 38px 24px 42px; - font-weight: 400; - } - - .notification-actions { - display: flex; - width: 100%; - align-items: stretch; - justify-content: flex-end; - - #never-save { - margin-right: auto; - padding: 0; - font-size: 16px; - font-weight: 500; - letter-spacing: 0.5px; - } - - #select-folder { - width: 125px; - margin-right: 6px; - font-size: 12px; - appearance: none; - background-repeat: no-repeat; - background-position: center right 4px; - background-size: 16px; - - @include themify($themes) { - color: themed("mutedTextColor"); - border-color: themed("mutedTextColor"); - } - - &:not([disabled]) { - display: block; - } - } - - .primary, - .secondary { - font-size: 12px; - } - - .secondary { - margin-right: 6px; - border-width: 1px; - } - - .primary { - margin-right: 2px; - } - - &.success-message, - &.error-message { - padding: 4px 36px 6px 42px; - } - } -} - -button { - padding: 4px 8px; - border-radius: $border-radius; - border: 1px solid transparent; - cursor: pointer; -} - -button.primary:not(.neutral) { - @include themify($themes) { - background-color: themed("primaryColor"); - color: themed("textContrast"); - border-color: themed("primaryColor"); - } - - &:hover { - @include themify($themes) { - background-color: darken(themed("primaryColor"), 1.5%); - color: darken(themed("textContrast"), 6%); - } - } -} - -button.secondary:not(.neutral) { - @include themify($themes) { - background-color: themed("backgroundColor"); - color: themed("mutedTextColor"); - border-color: themed("mutedTextColor"); - } - - &:hover { - @include themify($themes) { - background-color: themed("backgroundOffsetColor"); - color: darken(themed("mutedTextColor"), 6%); - } - } -} - -button.link, -button.neutral { - @include themify($themes) { - background-color: transparent; - color: themed("primaryColor"); - } - - &:hover { - text-decoration: underline; - - @include themify($themes) { - color: darken(themed("primaryColor"), 6%); - } - } -} - -select { - padding: 4px 6px; - border: 1px solid #000000; - border-radius: $border-radius; - - @include themify($themes) { - color: themed("textColor"); - background-color: themed("inputBackgroundColor"); - border-color: themed("inputBorderColor"); - } -} - -.success-message { - display: flex; - align-items: center; - justify-content: center; - - @include themify($themes) { - color: themed("successColor"); - } - - svg { - margin-right: 8px; - - path { - @include themify($themes) { - fill: themed("successColor"); - } - } - } -} - -.error-message { - @include themify($themes) { - color: themed("errorColor"); - } -} - -.success-event, -.error-event { - .notification-body { - display: none; - } -} - -@media screen and (max-width: 768px) { - #select-folder { - display: none; - } -} - -@media print { - body { - display: none; - } -} - -.theme_light { - #content .inner-wrapper { - #select-folder { - background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB3aWR0aD0nMTYnIGhlaWdodD0nMTYnIGZpbGw9J25vbmUnPjxwYXRoIHN0cm9rZT0nIzIxMjUyOScgZD0nbTUgNiAzIDMgMy0zJy8+PC9zdmc+"); - } - } -} - -.theme_dark { - #content .inner-wrapper { - #select-folder { - background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxNicgaGVpZ2h0PScxNicgZmlsbD0nbm9uZSc+PHBhdGggc3Ryb2tlPScjZmZmZmZmJyBkPSdtNSA2IDMgMyAzLTMnLz48L3N2Zz4="); - } - } -} diff --git a/apps/browser/src/autofill/notification/bar.spec.ts b/apps/browser/src/autofill/notification/bar.spec.ts new file mode 100644 index 00000000000..ae60e2efc91 --- /dev/null +++ b/apps/browser/src/autofill/notification/bar.spec.ts @@ -0,0 +1,121 @@ +import { mock } from "jest-mock-extended"; + +import { postWindowMessage } from "../spec/testing-utils"; + +import { NotificationBarWindowMessage } from "./abstractions/notification-bar"; +import "./bar"; + +jest.mock("lit", () => ({ render: jest.fn() })); +jest.mock("@lit-labs/signals", () => ({ + signal: jest.fn((testValue) => ({ get: (): typeof testValue => testValue })), +})); +jest.mock("../content/components/notification/container", () => ({ + NotificationContainer: jest.fn(), +})); + +describe("NotificationBar iframe handleWindowMessage security", () => { + const trustedOrigin = "http://localhost"; + const maliciousOrigin = "https://malicious.com"; + + const createMessage = ( + overrides: Partial = {}, + ): NotificationBarWindowMessage => ({ + command: "initNotificationBar", + ...overrides, + }); + + beforeEach(() => { + Object.defineProperty(globalThis, "location", { + value: { search: `?parentOrigin=${encodeURIComponent(trustedOrigin)}` }, + writable: true, + configurable: true, + }); + Object.defineProperty(globalThis, "parent", { + value: mock(), + writable: true, + configurable: true, + }); + globalThis.dispatchEvent(new Event("load")); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + description: "not from parent window", + message: () => createMessage(), + origin: trustedOrigin, + source: () => mock(), + }, + { + description: "with mismatched origin", + message: () => createMessage(), + origin: maliciousOrigin, + source: () => globalThis.parent, + }, + { + description: "without command field", + message: () => ({}), + origin: trustedOrigin, + source: () => globalThis.parent, + }, + { + description: "initNotificationBar with mismatched parentOrigin", + message: () => createMessage({ parentOrigin: maliciousOrigin }), + origin: trustedOrigin, + source: () => globalThis.parent, + }, + { + description: "when windowMessageOrigin is not set", + message: () => createMessage(), + origin: "different-origin", + source: () => globalThis.parent, + resetOrigin: true, + }, + { + description: "with null source", + message: () => createMessage(), + origin: trustedOrigin, + source: (): null => null, + }, + { + description: "with unknown command", + message: () => createMessage({ command: "unknownCommand" }), + origin: trustedOrigin, + source: () => globalThis.parent, + }, + ])("should reject messages $description", ({ message, origin, source, resetOrigin }) => { + if (resetOrigin) { + Object.defineProperty(globalThis, "location", { + value: { search: "" }, + writable: true, + configurable: true, + }); + } + const spy = jest.spyOn(globalThis.parent, "postMessage").mockImplementation(); + postWindowMessage(message(), origin, source()); + expect(spy).not.toHaveBeenCalled(); + }); + + it("should accept and handle valid trusted messages", () => { + const spy = jest.spyOn(globalThis.parent, "postMessage").mockImplementation(); + spy.mockClear(); + + const validMessage = createMessage({ + parentOrigin: trustedOrigin, + initData: { + type: "change", + isVaultLocked: false, + removeIndividualVault: false, + importType: null, + launchTimestamp: Date.now(), + }, + }); + postWindowMessage(validMessage, trustedOrigin, globalThis.parent); + expect(validMessage.command).toBe("initNotificationBar"); + expect(validMessage.parentOrigin).toBe(trustedOrigin); + expect(validMessage.initData).toBeDefined(); + }); +}); diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts index 9ae6fcedc8f..333f8d5e534 100644 --- a/apps/browser/src/autofill/notification/bar.ts +++ b/apps/browser/src/autofill/notification/bar.ts @@ -1,6 +1,7 @@ import { render } from "lit"; import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import type { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { NotificationCipherData } from "../content/components/cipher/types"; @@ -8,6 +9,7 @@ import { CollectionView, I18n, OrgView } from "../content/components/common-type import { AtRiskNotification } from "../content/components/notification/at-risk-password/container"; import { NotificationConfirmationContainer } from "../content/components/notification/confirmation/container"; import { NotificationContainer } from "../content/components/notification/container"; +import { selectedCipher as selectedCipherSignal } from "../content/components/signals/selected-cipher"; import { selectedFolder as selectedFolderSignal } from "../content/components/signals/selected-folder"; import { selectedVault as selectedVaultSignal } from "../content/components/signals/selected-vault"; @@ -22,6 +24,13 @@ import { let notificationBarIframeInitData: NotificationBarIframeInitData = {}; let windowMessageOrigin: string; +const urlParams = new URLSearchParams(globalThis.location.search); +const trustedParentOrigin = urlParams.get("parentOrigin"); + +if (trustedParentOrigin) { + windowMessageOrigin = trustedParentOrigin; +} + const notificationBarWindowMessageHandlers: NotificationBarWindowMessageHandlers = { initNotificationBar: ({ message }) => initNotificationBar(message), saveCipherAttemptCompleted: ({ message }) => handleSaveCipherConfirmation(message), @@ -180,18 +189,16 @@ async function initNotificationBar(message: NotificationBarWindowMessage) { const i18n = getI18n(); const resolvedTheme = getResolvedTheme(theme ?? ThemeTypes.Light); - const resolvedType = resolveNotificationType(notificationBarIframeInitData); - const headerMessage = getNotificationHeaderMessage(i18n, resolvedType); - const notificationTestId = getNotificationTestId(resolvedType); + const notificationType = resolveNotificationType(notificationBarIframeInitData); + const headerMessage = getNotificationHeaderMessage(i18n, notificationType); + const notificationTestId = getNotificationTestId(notificationType); appendHeaderMessageToTitle(headerMessage); - document.body.innerHTML = ""; - if (isVaultLocked) { const notificationConfig = { ...notificationBarIframeInitData, headerMessage, - type: resolvedType, + type: notificationType, notificationTestId, theme: resolvedTheme, personalVaultIsAllowed: !personalVaultDisallowed, @@ -201,7 +208,8 @@ async function initNotificationBar(message: NotificationBarWindowMessage) { }; const handleSaveAction = () => { - sendSaveCipherMessage(true); + // cipher ID is null while vault is locked. + sendSaveCipherMessage(null, true); render( NotificationContainer({ @@ -262,7 +270,7 @@ async function initNotificationBar(message: NotificationBarWindowMessage) { NotificationContainer({ ...notificationBarIframeInitData, headerMessage, - type: resolvedType, + type: notificationType, theme: resolvedTheme, notificationTestId, personalVaultIsAllowed: !personalVaultDisallowed, @@ -276,9 +284,8 @@ async function initNotificationBar(message: NotificationBarWindowMessage) { }); function handleEditOrUpdateAction(e: Event) { - const notificationType = initData?.type; e.preventDefault(); - notificationType === "add" ? sendSaveCipherMessage(true) : sendSaveCipherMessage(false); + sendSaveCipherMessage(selectedCipherSignal.get(), notificationType === NotificationTypes.Add); } } @@ -291,6 +298,7 @@ function handleCloseNotification(e: Event) { } function handleSaveAction(e: Event) { + const selectedCipher = selectedCipherSignal.get(); const selectedVault = selectedVaultSignal.get(); const selectedFolder = selectedFolderSignal.get(); @@ -304,16 +312,16 @@ function handleSaveAction(e: Event) { } e.preventDefault(); - - sendSaveCipherMessage(removeIndividualVault(), selectedFolder); + sendSaveCipherMessage(selectedCipher, removeIndividualVault(), selectedFolder); if (removeIndividualVault()) { return; } } -function sendSaveCipherMessage(edit: boolean, folder?: string) { +function sendSaveCipherMessage(cipherId: CipherView["id"] | null, edit: boolean, folder?: string) { sendPlatformMessage({ command: "bgSaveCipher", + cipherId, folder, edit, }); @@ -394,15 +402,27 @@ function setupWindowMessageListener() { } function handleWindowMessage(event: MessageEvent) { - if (!windowMessageOrigin) { - windowMessageOrigin = event.origin; - } - - if (event.origin !== windowMessageOrigin) { + if (event?.source !== globalThis.parent) { return; } const message = event.data as NotificationBarWindowMessage; + if (!message?.command) { + return; + } + + if (!windowMessageOrigin || event.origin !== windowMessageOrigin) { + return; + } + + if ( + message.command === "initNotificationBar" && + message.parentOrigin && + message.parentOrigin !== event.origin + ) { + return; + } + const handler = notificationBarWindowMessageHandlers[message.command]; if (!handler) { return; @@ -430,5 +450,8 @@ function getResolvedTheme(theme: Theme) { } function postMessageToParent(message: NotificationBarWindowMessage) { - globalThis.parent.postMessage(message, windowMessageOrigin || "*"); + if (!windowMessageOrigin) { + return; + } + globalThis.parent.postMessage(message, windowMessageOrigin); } diff --git a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-button.ts b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-button.ts index 642e7dd24e9..0836ecf5ff1 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-button.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-button.ts @@ -10,6 +10,7 @@ export type InitAutofillInlineMenuButtonMessage = UpdateAuthStatusMessage & { styleSheetUrl: string; translations: Record; portKey: string; + token: string; }; export type AutofillInlineMenuButtonWindowMessageHandlers = { diff --git a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-container.ts b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-container.ts index a147e0ba165..98fd84373a8 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-container.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-container.ts @@ -5,6 +5,7 @@ import { InlineMenuCipherData } from "../../../background/abstractions/overlay.b export type AutofillInlineMenuContainerMessage = { command: string; portKey: string; + token: string; }; export type InitAutofillInlineMenuElementMessage = AutofillInlineMenuContainerMessage & { @@ -16,6 +17,7 @@ export type InitAutofillInlineMenuElementMessage = AutofillInlineMenuContainerMe translations: Record; ciphers: InlineMenuCipherData[] | null; portName: string; + extensionOrigin?: string; }; export type AutofillInlineMenuContainerWindowMessage = AutofillInlineMenuContainerMessage & diff --git a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-list.ts b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-list.ts index f5e1fe08850..cf778ef7892 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-list.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-list.ts @@ -27,6 +27,7 @@ export type InitAutofillInlineMenuListMessage = AutofillInlineMenuListMessage & showInlineMenuAccountCreation?: boolean; showPasskeysLabels?: boolean; portKey: string; + token: string; generatedPassword?: string; showSaveLoginMenu?: boolean; }; 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 f1a74556b24..b7bd24c537b 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 @@ -53,13 +53,35 @@ describe("AutofillInlineMenuContentService", () => { }); }); + describe("messageHandlers", () => { + it("returns the extension message handlers", () => { + const handlers = autofillInlineMenuContentService.messageHandlers; + + expect(handlers).toHaveProperty("closeAutofillInlineMenu"); + expect(handlers).toHaveProperty("appendAutofillInlineMenuToDom"); + }); + }); + describe("isElementInlineMenu", () => { - it("returns true if the passed element is the inline menu", () => { + it("returns true if the passed element is the inline menu list", () => { const element = document.createElement("div"); autofillInlineMenuContentService["listElement"] = element; expect(autofillInlineMenuContentService.isElementInlineMenu(element)).toBe(true); }); + + it("returns true if the passed element is the inline menu button", () => { + const element = document.createElement("div"); + autofillInlineMenuContentService["buttonElement"] = element; + + expect(autofillInlineMenuContentService.isElementInlineMenu(element)).toBe(true); + }); + + it("returns false if the passed element is not the inline menu", () => { + const element = document.createElement("div"); + + expect(autofillInlineMenuContentService.isElementInlineMenu(element)).toBe(false); + }); }); describe("extension message handlers", () => { @@ -388,7 +410,7 @@ describe("AutofillInlineMenuContentService", () => { }); it("closes the inline menu if the page body is not sufficiently opaque", async () => { - document.querySelector("html").style.opacity = "0.9"; + document.documentElement.style.opacity = "0.9"; document.body.style.opacity = "0"; await autofillInlineMenuContentService["handlePageMutations"]([mockBodyMutationRecord]); @@ -397,7 +419,7 @@ describe("AutofillInlineMenuContentService", () => { }); it("closes the inline menu if the page html is not sufficiently opaque", async () => { - document.querySelector("html").style.opacity = "0.3"; + document.documentElement.style.opacity = "0.3"; document.body.style.opacity = "0.7"; await autofillInlineMenuContentService["handlePageMutations"]([mockHTMLMutationRecord]); @@ -406,7 +428,7 @@ describe("AutofillInlineMenuContentService", () => { }); it("does not close the inline menu if the page html and body is sufficiently opaque", async () => { - document.querySelector("html").style.opacity = "0.9"; + document.documentElement.style.opacity = "0.9"; document.body.style.opacity = "1"; await autofillInlineMenuContentService["handlePageMutations"]([mockBodyMutationRecord]); await waitForIdleCallback(); @@ -599,5 +621,465 @@ describe("AutofillInlineMenuContentService", () => { overlayElement: AutofillOverlayElement.List, }); }); + + it("clears the persistent last child override timeout", () => { + jest.useFakeTimers(); + const clearTimeoutSpy = jest.spyOn(globalThis, "clearTimeout"); + autofillInlineMenuContentService["handlePersistentLastChildOverrideTimeout"] = setTimeout( + jest.fn(), + 500, + ); + + autofillInlineMenuContentService.destroy(); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); + + it("unobserves page attributes", () => { + const disconnectSpy = jest.spyOn( + autofillInlineMenuContentService["htmlMutationObserver"], + "disconnect", + ); + + autofillInlineMenuContentService.destroy(); + + expect(disconnectSpy).toHaveBeenCalled(); + }); + }); + + describe("getOwnedTagNames", () => { + it("returns an empty array when no elements are created", () => { + expect(autofillInlineMenuContentService.getOwnedTagNames()).toEqual([]); + }); + + it("returns the button element tag name", () => { + const buttonElement = document.createElement("div"); + autofillInlineMenuContentService["buttonElement"] = buttonElement; + + const tagNames = autofillInlineMenuContentService.getOwnedTagNames(); + + expect(tagNames).toContain("DIV"); + }); + + it("returns both button and list element tag names", () => { + const buttonElement = document.createElement("div"); + const listElement = document.createElement("span"); + autofillInlineMenuContentService["buttonElement"] = buttonElement; + autofillInlineMenuContentService["listElement"] = listElement; + + const tagNames = autofillInlineMenuContentService.getOwnedTagNames(); + + expect(tagNames).toEqual(["DIV", "SPAN"]); + }); + }); + + describe("getUnownedTopLayerItems", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("returns the tag names from button and list elements", () => { + const buttonElement = document.createElement("div"); + buttonElement.setAttribute("popover", "manual"); + autofillInlineMenuContentService["buttonElement"] = buttonElement; + + const listElement = document.createElement("span"); + listElement.setAttribute("popover", "manual"); + autofillInlineMenuContentService["listElement"] = listElement; + + /** Mock querySelectorAll to avoid :modal selector issues in jsdom */ + const querySelectorAllSpy = jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([] as any); + + const items = autofillInlineMenuContentService.getUnownedTopLayerItems(); + + expect(querySelectorAllSpy).toHaveBeenCalled(); + expect(items.length).toBe(0); + }); + + it("calls querySelectorAll with correct selector when includeCandidates is false", () => { + /** Mock querySelectorAll to avoid :modal selector issues in jsdom */ + const querySelectorAllSpy = jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([] as any); + + autofillInlineMenuContentService.getUnownedTopLayerItems(false); + + const calledSelector = querySelectorAllSpy.mock.calls[0][0]; + expect(calledSelector).toContain(":modal"); + expect(calledSelector).toContain(":popover-open"); + }); + + it("includes candidates selector when requested", () => { + /** Mock querySelectorAll to avoid :modal selector issues in jsdom */ + const querySelectorAllSpy = jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([] as any); + + autofillInlineMenuContentService.getUnownedTopLayerItems(true); + + const calledSelector = querySelectorAllSpy.mock.calls[0][0]; + expect(calledSelector).toContain("[popover], dialog"); + }); + }); + + describe("refreshTopLayerPosition", () => { + it("does nothing when inline menu is disabled", () => { + const getUnownedTopLayerItemsSpy = jest.spyOn( + autofillInlineMenuContentService, + "getUnownedTopLayerItems", + ); + + autofillInlineMenuContentService["inlineMenuEnabled"] = false; + const buttonElement = document.createElement("div"); + autofillInlineMenuContentService["buttonElement"] = buttonElement; + + autofillInlineMenuContentService.refreshTopLayerPosition(); + + // Should exit early and not call `getUnownedTopLayerItems` + expect(getUnownedTopLayerItemsSpy).not.toHaveBeenCalled(); + }); + + it("does nothing when no other top layer items exist", () => { + const buttonElement = document.createElement("div"); + autofillInlineMenuContentService["buttonElement"] = buttonElement; + jest + .spyOn(autofillInlineMenuContentService, "getUnownedTopLayerItems") + .mockReturnValue([] as any); + + const getElementsByTagSpy = jest.spyOn(globalThis.document, "getElementsByTagName"); + + autofillInlineMenuContentService.refreshTopLayerPosition(); + + // Should exit early and not get inline elements to refresh + expect(getElementsByTagSpy).not.toHaveBeenCalled(); + }); + + it("refreshes button popover when button is in document", () => { + jest + .spyOn(autofillInlineMenuContentService, "getUnownedTopLayerItems") + .mockReturnValue([document.createElement("div")] as any); + + const buttonElement = document.createElement("div"); + buttonElement.setAttribute("popover", "manual"); + buttonElement.showPopover = jest.fn(); + buttonElement.hidePopover = jest.fn(); + document.body.appendChild(buttonElement); + autofillInlineMenuContentService["buttonElement"] = buttonElement; + + autofillInlineMenuContentService.refreshTopLayerPosition(); + + expect(buttonElement.hidePopover).toHaveBeenCalled(); + expect(buttonElement.showPopover).toHaveBeenCalled(); + }); + + it("refreshes list popover when list is in document", () => { + jest + .spyOn(autofillInlineMenuContentService, "getUnownedTopLayerItems") + .mockReturnValue([document.createElement("div")] as any); + + const listElement = document.createElement("div"); + listElement.setAttribute("popover", "manual"); + listElement.showPopover = jest.fn(); + listElement.hidePopover = jest.fn(); + document.body.appendChild(listElement); + autofillInlineMenuContentService["listElement"] = listElement; + + autofillInlineMenuContentService.refreshTopLayerPosition(); + + expect(listElement.hidePopover).toHaveBeenCalled(); + expect(listElement.showPopover).toHaveBeenCalled(); + }); + }); + + describe("checkAndUpdateRefreshCount", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2023-01-01T00:00:00.000Z")); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("does nothing when inline menu is disabled", () => { + autofillInlineMenuContentService["inlineMenuEnabled"] = false; + + autofillInlineMenuContentService["checkAndUpdateRefreshCount"]("topLayer"); + + expect(autofillInlineMenuContentService["refreshCountWithinTimeThreshold"].topLayer).toBe(0); + }); + + it("increments refresh count when within time threshold", () => { + autofillInlineMenuContentService["lastTrackedTimestamp"].topLayer = Date.now() - 1000; + + autofillInlineMenuContentService["checkAndUpdateRefreshCount"]("topLayer"); + + expect(autofillInlineMenuContentService["refreshCountWithinTimeThreshold"].topLayer).toBe(1); + }); + + it("resets count when outside time threshold", () => { + autofillInlineMenuContentService["lastTrackedTimestamp"].topLayer = Date.now() - 6000; + autofillInlineMenuContentService["refreshCountWithinTimeThreshold"].topLayer = 5; + + autofillInlineMenuContentService["checkAndUpdateRefreshCount"]("topLayer"); + + expect(autofillInlineMenuContentService["refreshCountWithinTimeThreshold"].topLayer).toBe(0); + }); + + it("disables inline menu and shows alert when count exceeds threshold", () => { + const alertSpy = jest.spyOn(globalThis.window, "alert").mockImplementation(); + const checkPageRisksSpy = jest.spyOn( + autofillInlineMenuContentService as any, + "checkPageRisks", + ); + autofillInlineMenuContentService["lastTrackedTimestamp"].topLayer = Date.now() - 1000; + autofillInlineMenuContentService["refreshCountWithinTimeThreshold"].topLayer = 6; + + autofillInlineMenuContentService["checkAndUpdateRefreshCount"]("topLayer"); + + expect(autofillInlineMenuContentService["inlineMenuEnabled"]).toBe(false); + expect(alertSpy).toHaveBeenCalled(); + expect(checkPageRisksSpy).toHaveBeenCalled(); + }); + }); + + describe("refreshPopoverAttribute", () => { + it("calls checkAndUpdateRefreshCount with popoverAttribute type", () => { + const checkSpy = jest.spyOn( + autofillInlineMenuContentService as any, + "checkAndUpdateRefreshCount", + ); + const element = document.createElement("div"); + element.setAttribute("popover", "auto"); + element.showPopover = jest.fn(); + + autofillInlineMenuContentService["refreshPopoverAttribute"](element); + + expect(checkSpy).toHaveBeenCalledWith("popoverAttribute"); + expect(element.getAttribute("popover")).toBe("manual"); + expect(element.showPopover).toHaveBeenCalled(); + }); + }); + + describe("handleInlineMenuElementMutationObserverUpdate - popover attribute", () => { + it("refreshes popover attribute when changed from manual", () => { + const element = document.createElement("div"); + element.setAttribute("popover", "auto"); + element.showPopover = jest.fn(); + const refreshSpy = jest.spyOn( + autofillInlineMenuContentService as any, + "refreshPopoverAttribute", + ); + autofillInlineMenuContentService["buttonElement"] = element; + + const mockMutation = createMutationRecordMock({ + target: element, + type: "attributes", + attributeName: "popover", + }); + + autofillInlineMenuContentService["handleInlineMenuElementMutationObserverUpdate"]([ + mockMutation, + ]); + + expect(refreshSpy).toHaveBeenCalledWith(element); + }); + + it("does not refresh popover attribute when already manual", () => { + const element = document.createElement("div"); + element.setAttribute("popover", "manual"); + const refreshSpy = jest.spyOn( + autofillInlineMenuContentService as any, + "refreshPopoverAttribute", + ); + autofillInlineMenuContentService["buttonElement"] = element; + + const mockMutation = createMutationRecordMock({ + target: element, + type: "attributes", + attributeName: "popover", + }); + + autofillInlineMenuContentService["handleInlineMenuElementMutationObserverUpdate"]([ + mockMutation, + ]); + + expect(refreshSpy).not.toHaveBeenCalled(); + }); + }); + + describe("appendInlineMenuElements when disabled", () => { + beforeEach(() => { + observeContainerMutationsSpy.mockImplementation(); + }); + + it("does not append button when inline menu is disabled", async () => { + autofillInlineMenuContentService["inlineMenuEnabled"] = false; + jest.spyOn(globalThis.document.body, "appendChild"); + + sendMockExtensionMessage({ + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, + }); + await flushPromises(); + + expect(globalThis.document.body.appendChild).not.toHaveBeenCalled(); + }); + + it("does not append list when inline menu is disabled", async () => { + autofillInlineMenuContentService["inlineMenuEnabled"] = false; + jest.spyOn(globalThis.document.body, "appendChild"); + + sendMockExtensionMessage({ + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, + }); + await flushPromises(); + + expect(globalThis.document.body.appendChild).not.toHaveBeenCalled(); + }); + }); + + describe("custom element creation for non-Firefox browsers", () => { + beforeEach(() => { + autofillInlineMenuContentService["isFirefoxBrowser"] = false; + observeContainerMutationsSpy.mockImplementation(); + }); + + it("creates a custom element for button in non-Firefox browsers", () => { + const definespy = jest.spyOn(globalThis.customElements, "define"); + + sendMockExtensionMessage({ + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(definespy).toHaveBeenCalled(); + expect(autofillInlineMenuContentService["buttonElement"]).toBeDefined(); + expect(autofillInlineMenuContentService["buttonElement"]?.tagName).not.toBe("DIV"); + }); + + it("creates a custom element for list in non-Firefox browsers", () => { + const defineSpy = jest.spyOn(globalThis.customElements, "define"); + + sendMockExtensionMessage({ + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, + }); + + expect(defineSpy).toHaveBeenCalled(); + expect(autofillInlineMenuContentService["listElement"]).toBeDefined(); + expect(autofillInlineMenuContentService["listElement"]?.tagName).not.toBe("DIV"); + }); + }); + + describe("getPageIsOpaque", () => { + it("returns false when no page elements exist", () => { + jest.spyOn(globalThis.document, "querySelectorAll").mockReturnValue([] as any); + + const result = autofillInlineMenuContentService["getPageIsOpaque"](); + + expect(result).toBe(false); + }); + + it("returns true when all html and body nodes have sufficient opacity", () => { + jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([document.documentElement, document.body] as any); + jest + .spyOn(globalThis.window, "getComputedStyle") + .mockImplementation(() => ({ opacity: "1" }) as CSSStyleDeclaration); + + const result = autofillInlineMenuContentService["getPageIsOpaque"](); + + expect(result).toBe(true); + }); + + it("returns false when html opacity is below threshold", () => { + jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([document.documentElement, document.body] as any); + let callCount = 0; + jest.spyOn(globalThis.window, "getComputedStyle").mockImplementation(() => { + callCount++; + return { opacity: callCount === 1 ? "0.5" : "1" } as CSSStyleDeclaration; + }); + + const result = autofillInlineMenuContentService["getPageIsOpaque"](); + + expect(result).toBe(false); + }); + + it("returns false when body opacity is below threshold", () => { + jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([document.documentElement, document.body] as any); + let callCount = 0; + jest.spyOn(globalThis.window, "getComputedStyle").mockImplementation(() => { + callCount++; + return { opacity: callCount === 1 ? "1" : "0.5" } as CSSStyleDeclaration; + }); + + const result = autofillInlineMenuContentService["getPageIsOpaque"](); + + expect(result).toBe(false); + }); + + it("returns false when opacity of at least one duplicate body is below threshold", () => { + const duplicateBody = document.createElement("body"); + jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([document.documentElement, document.body, duplicateBody] as any); + let callCount = 0; + jest.spyOn(globalThis.window, "getComputedStyle").mockImplementation(() => { + callCount++; + + let opacityValue = "0.5"; + switch (callCount) { + case 1: + opacityValue = "1"; + break; + case 2: + opacityValue = "0.7"; + break; + default: + break; + } + + return { opacity: opacityValue } as CSSStyleDeclaration; + }); + + const result = autofillInlineMenuContentService["getPageIsOpaque"](); + + expect(result).toBe(false); + }); + + it("returns true when opacity is above threshold", () => { + jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([document.documentElement, document.body] as any); + jest + .spyOn(globalThis.window, "getComputedStyle") + .mockImplementation(() => ({ opacity: "0.7" }) as CSSStyleDeclaration); + + const result = autofillInlineMenuContentService["getPageIsOpaque"](); + + expect(result).toBe(true); + }); + + it("returns false when opacity is at threshold", () => { + jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([document.documentElement, document.body] as any); + jest + .spyOn(globalThis.window, "getComputedStyle") + .mockImplementation(() => ({ opacity: "0.6" }) as CSSStyleDeclaration); + + const result = autofillInlineMenuContentService["getPageIsOpaque"](); + + expect(result).toBe(false); + }); }); }); 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 247104e13a5..b61e5e19d53 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 @@ -22,6 +22,19 @@ import { import { AutofillInlineMenuButtonIframe } from "../iframe-content/autofill-inline-menu-button-iframe"; import { AutofillInlineMenuListIframe } from "../iframe-content/autofill-inline-menu-list-iframe"; +const experienceValidationBackoffThresholds = { + topLayer: { + countLimit: 5, + timeSpanLimit: 5000, + }, + popoverAttribute: { + countLimit: 10, + timeSpanLimit: 5000, + }, +}; + +type BackoffCheckType = keyof typeof experienceValidationBackoffThresholds; + export class AutofillInlineMenuContentService implements AutofillInlineMenuContentServiceInterface { private readonly sendExtensionMessage = sendExtensionMessage; private readonly generateRandomCustomElementName = generateRandomCustomElementName; @@ -35,6 +48,19 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte private bodyMutationObserver: MutationObserver; private inlineMenuElementsMutationObserver: MutationObserver; private containerElementMutationObserver: MutationObserver; + private refreshCountWithinTimeThreshold: { [key in BackoffCheckType]: number } = { + topLayer: 0, + popoverAttribute: 0, + }; + private lastTrackedTimestamp = { + topLayer: Date.now(), + popoverAttribute: Date.now(), + }; + /** + * Distinct from preventing inline menu script injection, this is for cases + * where the page is subsequently determined to be risky. + */ + private inlineMenuEnabled = true; private mutationObserverIterations = 0; private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout; private handlePersistentLastChildOverrideTimeout: number | NodeJS.Timeout; @@ -140,6 +166,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * Updates the position of both the inline menu button and inline menu list. */ private async appendInlineMenuElements({ overlayElement }: AutofillExtensionMessage) { + if (!this.inlineMenuEnabled) { + return; + } + if (overlayElement === AutofillOverlayElement.Button) { return this.appendButtonElement(); } @@ -151,6 +181,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * Updates the position of the inline menu button. */ private async appendButtonElement(): Promise { + if (!this.inlineMenuEnabled) { + return; + } + if (!this.buttonElement) { this.createButtonElement(); this.updateCustomElementDefaultStyles(this.buttonElement); @@ -167,6 +201,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * Updates the position of the inline menu list. */ private async appendListElement(): Promise { + if (!this.inlineMenuEnabled) { + return; + } + if (!this.listElement) { this.createListElement(); this.updateCustomElementDefaultStyles(this.listElement); @@ -219,6 +257,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * to create the element if it already exists in the DOM. */ private createButtonElement() { + if (!this.inlineMenuEnabled) { + return; + } + if (this.isFirefoxBrowser) { this.buttonElement = globalThis.document.createElement("div"); this.buttonElement.setAttribute("popover", "manual"); @@ -237,6 +279,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte } }, ); + this.buttonElement = globalThis.document.createElement(customElementName); this.buttonElement.setAttribute("popover", "manual"); } @@ -246,6 +289,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * to create the element if it already exists in the DOM. */ private createListElement() { + if (!this.inlineMenuEnabled) { + return; + } + if (this.isFirefoxBrowser) { this.listElement = globalThis.document.createElement("div"); this.listElement.setAttribute("popover", "manual"); @@ -264,6 +311,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte } }, ); + this.listElement = globalThis.document.createElement(customElementName); this.listElement.setAttribute("popover", "manual"); } @@ -379,14 +427,23 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte } const element = record.target as HTMLElement; - if (record.attributeName !== "style") { - this.removeModifiedElementAttributes(element); + if (record.attributeName === "popover" && this.inlineMenuEnabled) { + const attributeValue = element.getAttribute(record.attributeName); + if (attributeValue !== "manual") { + this.refreshPopoverAttribute(element); + } continue; } - element.removeAttribute("style"); - this.updateCustomElementDefaultStyles(element); + if (record.attributeName === "style") { + element.removeAttribute("style"); + this.updateCustomElementDefaultStyles(element); + + continue; + } + + this.removeModifiedElementAttributes(element); } }; @@ -400,7 +457,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte const attributes = Array.from(element.attributes); for (let attributeIndex = 0; attributeIndex < attributes.length; attributeIndex++) { const attribute = attributes[attributeIndex]; - if (attribute.name === "style") { + if (attribute.name === "style" || attribute.name === "popover") { continue; } @@ -430,7 +487,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte private checkPageRisks = async () => { const pageIsOpaque = await this.getPageIsOpaque(); - const risksFound = !pageIsOpaque; + const risksFound = !pageIsOpaque || !this.inlineMenuEnabled; if (risksFound) { this.closeInlineMenu(); @@ -481,7 +538,49 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte return otherTopLayeritems; }; + /** + * Internally track owned injected experience refreshes as a side-effect + * of host page interference. + */ + private checkAndUpdateRefreshCount = (countType: BackoffCheckType) => { + if (!this.inlineMenuEnabled) { + return; + } + + const { countLimit, timeSpanLimit } = experienceValidationBackoffThresholds[countType]; + const now = Date.now(); + const timeSinceLastTrackedRefresh = now - this.lastTrackedTimestamp[countType]; + const currentlyWithinTimeThreshold = timeSinceLastTrackedRefresh <= timeSpanLimit; + const withinCountThreshold = this.refreshCountWithinTimeThreshold[countType] <= countLimit; + + if (currentlyWithinTimeThreshold) { + if (withinCountThreshold) { + this.refreshCountWithinTimeThreshold[countType]++; + } else { + // Set inline menu to be off; page is aggressively trying to take top position of top layer + this.inlineMenuEnabled = false; + void this.checkPageRisks(); + + const warningMessage = chrome.i18n.getMessage("topLayerHijackWarning"); + globalThis.window.alert(warningMessage); + } + } else { + this.lastTrackedTimestamp[countType] = now; + this.refreshCountWithinTimeThreshold[countType] = 0; + } + }; + + private refreshPopoverAttribute = (element: HTMLElement) => { + this.checkAndUpdateRefreshCount("popoverAttribute"); + element.setAttribute("popover", "manual"); + element.showPopover(); + }; + refreshTopLayerPosition = () => { + if (!this.inlineMenuEnabled) { + return; + } + const otherTopLayerItems = this.getUnownedTopLayerItems(); // No need to refresh if there are no other top-layer items @@ -495,6 +594,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte const listInDocument = this.listElement && (globalThis.document.getElementsByTagName(this.listElement.tagName)[0] as HTMLElement); + if (buttonInDocument) { buttonInDocument.hidePopover(); buttonInDocument.showPopover(); @@ -504,6 +604,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte listInDocument.hidePopover(); listInDocument.showPopover(); } + + if (buttonInDocument || listInDocument) { + this.checkAndUpdateRefreshCount("topLayer"); + } }; /** @@ -513,24 +617,28 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * `body` (enforced elsewhere). */ private getPageIsOpaque = () => { - // These are computed style values, so we don't need to worry about non-float values - // for `opacity`, here // @TODO for definitive checks, traverse up the node tree from the inline menu container; // nodes can exist between `html` and `body` - const htmlElement = globalThis.document.querySelector("html"); - const bodyElement = globalThis.document.querySelector("body"); + /** + * `querySelectorAll` for (non-standard) cases where the page has additional copies of + * page nodes that should be unique + */ + const pageElements = globalThis.document.querySelectorAll("html, body"); - if (!htmlElement || !bodyElement) { + if (!pageElements.length) { return false; } - const htmlOpacity = globalThis.window.getComputedStyle(htmlElement)?.opacity || "0"; - const bodyOpacity = globalThis.window.getComputedStyle(bodyElement)?.opacity || "0"; + return [...pageElements].every((element) => { + // These are computed style values, so we don't need to worry about non-float values + // for `opacity`, here + const elementOpacity = globalThis.window.getComputedStyle(element)?.opacity || "0"; - // Any value above this is considered "opaque" for our purposes - const opacityThreshold = 0.6; + // Any value above this is considered "opaque" for our purposes + const opacityThreshold = 0.6; - return parseFloat(htmlOpacity) > opacityThreshold && parseFloat(bodyOpacity) > opacityThreshold; + return parseFloat(elementOpacity) > opacityThreshold; + }); }; /** 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 2fea65a7f01..3e2b364b17b 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 @@ -8,7 +8,10 @@ export class AutofillInlineMenuIframeElement { iframeTitle: string, ariaAlert?: string, ) { + const style = this.createInternalStyleNode(); const shadow: ShadowRoot = element.attachShadow({ mode: "closed" }); + shadow.prepend(style); + const autofillInlineMenuIframeService = new AutofillInlineMenuIframeService( shadow, portName, @@ -18,4 +21,50 @@ export class AutofillInlineMenuIframeElement { ); autofillInlineMenuIframeService.initMenuIframe(); } + + /** + * Builds and prepends an internal stylesheet to the container node with rules + * to prevent targeting by the host's global styling rules. This should only be + * used for pseudo elements such as `::backdrop` or `::before`. All other + * styles should be applied inline upon the parent container itself for improved + * specificity priority. + */ + private createInternalStyleNode() { + const css = document.createTextNode(` + :host::backdrop, + :host::before, + :host::after { + all: initial !important; + backdrop-filter: none !important; + filter: none !important; + inset: auto !important; + touch-action: auto !important; + user-select: text !important; + display: none !important; + position: relative !important; + top: auto !important; + right: auto !important; + bottom: auto !important; + left: auto !important; + transform: none !important; + transform-origin: 50% 50% !important; + opacity: 1 !important; + mix-blend-mode: normal !important; + isolation: isolate !important; + z-index: 0 !important; + background: none !important; + background-color: transparent !important; + background-image: none !important; + width: 0 !important; + height: 0 !important; + content: "" !important; + pointer-events: all !important; + } + `); + const style = globalThis.document.createElement("style"); + style.setAttribute("type", "text/css"); + style.appendChild(css); + + return style; + } } 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 9f2947c2e99..3bb86ee7876 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 @@ -191,7 +191,7 @@ describe("AutofillInlineMenuIframeService", () => { expect( autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, - ).toHaveBeenCalledWith(message, "*"); + ).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]); }); it("handles port messages that are registered with the message handlers and does not pass the message on to the iframe", () => { @@ -217,7 +217,7 @@ describe("AutofillInlineMenuIframeService", () => { expect(autofillInlineMenuIframeService["portKey"]).toBe(portKey); expect( autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, - ).toHaveBeenCalledWith(message, "*"); + ).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]); }); }); @@ -242,7 +242,7 @@ describe("AutofillInlineMenuIframeService", () => { expect(updateElementStylesSpy).not.toHaveBeenCalled(); expect( autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, - ).toHaveBeenCalledWith(message, "*"); + ).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]); }); it("sets a light theme based on the user's system preferences", () => { @@ -262,7 +262,7 @@ describe("AutofillInlineMenuIframeService", () => { command: "initAutofillInlineMenuList", theme: ThemeType.Light, }, - "*", + autofillInlineMenuIframeService["extensionOrigin"], ); }); @@ -283,7 +283,7 @@ describe("AutofillInlineMenuIframeService", () => { command: "initAutofillInlineMenuList", theme: ThemeType.Dark, }, - "*", + autofillInlineMenuIframeService["extensionOrigin"], ); }); @@ -387,7 +387,7 @@ describe("AutofillInlineMenuIframeService", () => { command: "updateAutofillInlineMenuColorScheme", colorScheme: "normal", }, - "*", + autofillInlineMenuIframeService["extensionOrigin"], ); }); 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 9a9821f643c..8b1423b1290 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 @@ -3,6 +3,7 @@ import { EVENTS } from "@bitwarden/common/autofill/constants"; import { ThemeTypes } from "@bitwarden/common/platform/enums"; +import { BrowserApi } from "../../../../platform/browser/browser-api"; import { sendExtensionMessage, setElementStyles } from "../../../utils"; import { BackgroundPortMessageHandlers, @@ -15,6 +16,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe private readonly sendExtensionMessage = sendExtensionMessage; private port: chrome.runtime.Port | null = null; private portKey: string; + private readonly extensionOrigin: string; private iframeMutationObserver: MutationObserver; private iframe: HTMLIFrameElement; private ariaAlertElement: HTMLDivElement; @@ -69,6 +71,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe private iframeTitle: string, private ariaAlert?: string, ) { + this.extensionOrigin = BrowserApi.getRuntimeURL("")?.slice(0, -1); this.iframeMutationObserver = new MutationObserver(this.handleMutations); } @@ -81,7 +84,7 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe * that is declared. */ initMenuIframe() { - this.defaultIframeAttributes.src = chrome.runtime.getURL("overlay/menu.html"); + this.defaultIframeAttributes.src = BrowserApi.getRuntimeURL("overlay/menu.html"); this.defaultIframeAttributes.title = this.iframeTitle; this.iframe = globalThis.document.createElement("iframe"); @@ -259,7 +262,10 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe } private postMessageToIFrame(message: any) { - this.iframe.contentWindow?.postMessage({ portKey: this.portKey, ...message }, "*"); + this.iframe.contentWindow?.postMessage( + { portKey: this.portKey, ...message }, + this.extensionOrigin, + ); } /** diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/button/autofill-inline-menu-button.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/button/autofill-inline-menu-button.spec.ts index 7fa07850f00..10f6c905342 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/button/autofill-inline-menu-button.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/button/autofill-inline-menu-button.spec.ts @@ -1,5 +1,6 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { BrowserApi } from "../../../../../platform/browser/browser-api"; import { createInitAutofillInlineMenuButtonMessageMock } from "../../../../spec/autofill-mocks"; import { flushPromises, postWindowMessage } from "../../../../spec/testing-utils"; @@ -10,11 +11,11 @@ describe("AutofillInlineMenuButton", () => { let autofillInlineMenuButton: AutofillInlineMenuButton; const portKey: string = "inlineMenuButtonPortKey"; + const expectedOrigin = BrowserApi.getRuntimeURL("")?.slice(0, -1) || "chrome-extension://id"; beforeEach(() => { document.body.innerHTML = ``; autofillInlineMenuButton = document.querySelector("autofill-inline-menu-button"); - autofillInlineMenuButton["messageOrigin"] = "https://localhost/"; jest.spyOn(globalThis.document, "createElement"); jest.spyOn(globalThis.parent, "postMessage"); }); @@ -56,8 +57,8 @@ describe("AutofillInlineMenuButton", () => { autofillInlineMenuButton["buttonElement"].click(); expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { command: "autofillInlineMenuButtonClicked", portKey }, - "*", + { command: "autofillInlineMenuButtonClicked", portKey, token: "test-token" }, + expectedOrigin, ); }); }); @@ -70,7 +71,7 @@ describe("AutofillInlineMenuButton", () => { it("does not post a message to close the autofill inline menu if the element is focused during the focus check", async () => { jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); - postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused" }); + postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused", token: "test-token" }); await flushPromises(); expect(globalThis.parent.postMessage).not.toHaveBeenCalledWith({ @@ -84,7 +85,7 @@ describe("AutofillInlineMenuButton", () => { .spyOn(autofillInlineMenuButton["buttonElement"], "querySelector") .mockReturnValue(autofillInlineMenuButton["buttonElement"]); - postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused" }); + postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused", token: "test-token" }); await flushPromises(); expect(globalThis.parent.postMessage).not.toHaveBeenCalledWith({ @@ -98,7 +99,7 @@ describe("AutofillInlineMenuButton", () => { jest .spyOn(autofillInlineMenuButton["buttonElement"], "querySelector") .mockReturnValue(autofillInlineMenuButton["buttonElement"]); - postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused" }); + postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused", token: "test-token" }); await flushPromises(); globalThis.document.dispatchEvent(new MouseEvent("mouseout")); @@ -113,12 +114,12 @@ describe("AutofillInlineMenuButton", () => { jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false); jest.spyOn(autofillInlineMenuButton["buttonElement"], "querySelector").mockReturnValue(null); - postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused" }); + postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused", token: "test-token" }); await flushPromises(); expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { command: "triggerDelayedAutofillInlineMenuClosure", portKey }, - "*", + { command: "triggerDelayedAutofillInlineMenuClosure", portKey, token: "test-token" }, + expectedOrigin, ); }); @@ -128,6 +129,7 @@ describe("AutofillInlineMenuButton", () => { postWindowMessage({ command: "updateAutofillInlineMenuButtonAuthStatus", authStatus: AuthenticationStatus.Unlocked, + token: "test-token", }); await flushPromises(); @@ -143,6 +145,7 @@ describe("AutofillInlineMenuButton", () => { postWindowMessage({ command: "updateAutofillInlineMenuColorScheme", colorScheme: "dark", + token: "test-token", }); await flushPromises(); diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/button/autofill-inline-menu-button.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/button/autofill-inline-menu-button.ts index 4f497172b39..414673a9b81 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/button/autofill-inline-menu-button.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/button/autofill-inline-menu-button.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import "@webcomponents/custom-elements"; import "lit/polyfill-support.js"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -103,7 +101,10 @@ export class AutofillInlineMenuButton extends AutofillInlineMenuPageElement { */ private updatePageColorScheme({ colorScheme }: AutofillInlineMenuButtonMessage) { const colorSchemeMetaTag = globalThis.document.querySelector("meta[name='color-scheme']"); - colorSchemeMetaTag?.setAttribute("content", colorScheme); + + if (colorSchemeMetaTag && colorScheme) { + colorSchemeMetaTag.setAttribute("content", colorScheme); + } } /** diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts index 36ef3897c56..dffacce0ffc 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts @@ -1,10 +1,7 @@ import { AutofillOverlayElement } from "../../../../enums/autofill-overlay.enum"; import { AutofillInlineMenuButton } from "./autofill-inline-menu-button"; - -// FIXME: Remove when updating file. Eslint update -// eslint-disable-next-line @typescript-eslint/no-require-imports -require("./button.scss"); +import "./button.css"; (function () { globalThis.customElements.define(AutofillOverlayElement.Button, AutofillInlineMenuButton); diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/button/button.scss b/apps/browser/src/autofill/overlay/inline-menu/pages/button/button.css similarity index 74% rename from apps/browser/src/autofill/overlay/inline-menu/pages/button/button.scss rename to apps/browser/src/autofill/overlay/inline-menu/pages/button/button.css index 64e54179893..a1fce6f14da 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/button/button.scss +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/button/button.css @@ -1,5 +1,3 @@ -@import "../../../../shared/styles/variables"; - * { box-sizing: border-box; } @@ -27,10 +25,10 @@ autofill-inline-menu-button { border: none; background: transparent; cursor: pointer; - - .inline-menu-button-svg-icon { - display: block; - width: 100%; - height: auto; - } +} + +.inline-menu-button .inline-menu-button-svg-icon { + display: block; + width: 100%; + height: auto; } diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts index b4e480797da..81bf7240230 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts @@ -3,6 +3,7 @@ import { mock } from "jest-mock-extended"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { BrowserApi } from "../../../../../platform/browser/browser-api"; import { InlineMenuCipherData } from "../../../../background/abstractions/overlay.background"; import { createAutofillOverlayCipherDataMock, @@ -23,6 +24,7 @@ describe("AutofillInlineMenuList", () => { let autofillInlineMenuList: AutofillInlineMenuList | null; const portKey: string = "inlineMenuListPortKey"; + const expectedOrigin = BrowserApi.getRuntimeURL("")?.slice(0, -1) || "chrome-extension://id"; const events: { eventName: any; callback: any }[] = []; beforeEach(() => { @@ -67,8 +69,8 @@ describe("AutofillInlineMenuList", () => { unlockButton.dispatchEvent(new Event("click")); expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { command: "unlockVault", portKey }, - "*", + { command: "unlockVault", portKey, token: "test-token" }, + expectedOrigin, ); }); }); @@ -134,8 +136,13 @@ describe("AutofillInlineMenuList", () => { addVaultItemButton.dispatchEvent(new Event("click")); expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { command: "addNewVaultItem", portKey, addNewCipherType: CipherType.Login }, - "*", + { + command: "addNewVaultItem", + portKey, + addNewCipherType: CipherType.Login, + token: "test-token", + }, + expectedOrigin, ); }); }); @@ -324,8 +331,9 @@ describe("AutofillInlineMenuList", () => { inlineMenuCipherId: "1", usePasskey: false, portKey, + token: "test-token", }, - "*", + expectedOrigin, ); }); @@ -492,8 +500,13 @@ describe("AutofillInlineMenuList", () => { viewCipherButton.dispatchEvent(new Event("click")); expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { command: "viewSelectedCipher", inlineMenuCipherId: "1", portKey }, - "*", + { + command: "viewSelectedCipher", + inlineMenuCipherId: "1", + portKey, + token: "test-token", + }, + expectedOrigin, ); }); @@ -581,8 +594,13 @@ describe("AutofillInlineMenuList", () => { newVaultItemButtonSpy.dispatchEvent(new Event("click")); expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { command: "addNewVaultItem", portKey, addNewCipherType: CipherType.Login }, - "*", + { + command: "addNewVaultItem", + portKey, + addNewCipherType: CipherType.Login, + token: "test-token", + }, + expectedOrigin, ); }); @@ -826,8 +844,8 @@ describe("AutofillInlineMenuList", () => { fillGeneratedPasswordButton.dispatchEvent(new Event("click")); expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { command: "fillGeneratedPassword", portKey }, - "*", + { command: "fillGeneratedPassword", portKey, token: "test-token" }, + expectedOrigin, ); }); @@ -843,7 +861,7 @@ describe("AutofillInlineMenuList", () => { expect(globalThis.parent.postMessage).not.toHaveBeenCalledWith( { command: "fillGeneratedPassword", portKey }, - "*", + expectedOrigin, ); }); @@ -857,8 +875,8 @@ describe("AutofillInlineMenuList", () => { ); expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { command: "fillGeneratedPassword", portKey }, - "*", + { command: "fillGeneratedPassword", portKey, token: "test-token" }, + expectedOrigin, ); }); @@ -896,8 +914,8 @@ describe("AutofillInlineMenuList", () => { refreshGeneratedPasswordButton.dispatchEvent(new Event("click")); expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { command: "refreshGeneratedPassword", portKey }, - "*", + { command: "refreshGeneratedPassword", portKey, token: "test-token" }, + expectedOrigin, ); }); @@ -913,7 +931,7 @@ describe("AutofillInlineMenuList", () => { expect(globalThis.parent.postMessage).not.toHaveBeenCalledWith( { command: "refreshGeneratedPassword", portKey }, - "*", + expectedOrigin, ); }); @@ -927,8 +945,8 @@ describe("AutofillInlineMenuList", () => { ); expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { command: "refreshGeneratedPassword", portKey }, - "*", + { command: "refreshGeneratedPassword", portKey, token: "test-token" }, + expectedOrigin, ); }); @@ -972,7 +990,7 @@ describe("AutofillInlineMenuList", () => { it("does not post a `checkAutofillInlineMenuButtonFocused` message to the parent if the inline menu is currently focused", () => { jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); - postWindowMessage({ command: "checkAutofillInlineMenuListFocused" }); + postWindowMessage({ command: "checkAutofillInlineMenuListFocused", token: "test-token" }); expect(globalThis.parent.postMessage).not.toHaveBeenCalled(); }); @@ -983,7 +1001,7 @@ describe("AutofillInlineMenuList", () => { .spyOn(autofillInlineMenuList["inlineMenuListContainer"], "querySelector") .mockReturnValue(autofillInlineMenuList["inlineMenuListContainer"]); - postWindowMessage({ command: "checkAutofillInlineMenuListFocused" }); + postWindowMessage({ command: "checkAutofillInlineMenuListFocused", token: "test-token" }); expect(globalThis.parent.postMessage).not.toHaveBeenCalled(); }); @@ -994,7 +1012,7 @@ describe("AutofillInlineMenuList", () => { jest .spyOn(autofillInlineMenuList["inlineMenuListContainer"], "querySelector") .mockReturnValue(autofillInlineMenuList["inlineMenuListContainer"]); - postWindowMessage({ command: "checkAutofillInlineMenuListFocused" }); + postWindowMessage({ command: "checkAutofillInlineMenuListFocused", token: "test-token" }); await flushPromises(); globalThis.document.dispatchEvent(new MouseEvent("mouseout")); @@ -1010,11 +1028,11 @@ describe("AutofillInlineMenuList", () => { .spyOn(autofillInlineMenuList["inlineMenuListContainer"], "querySelector") .mockReturnValue(null); - postWindowMessage({ command: "checkAutofillInlineMenuListFocused" }); + postWindowMessage({ command: "checkAutofillInlineMenuListFocused", token: "test-token" }); expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { command: "checkAutofillInlineMenuButtonFocused", portKey }, - "*", + { command: "checkAutofillInlineMenuButtonFocused", portKey, token: "test-token" }, + expectedOrigin, ); }); @@ -1022,7 +1040,7 @@ describe("AutofillInlineMenuList", () => { postWindowMessage(createInitAutofillInlineMenuListMessageMock()); const updateCiphersSpy = jest.spyOn(autofillInlineMenuList as any, "updateListItems"); - postWindowMessage({ command: "updateAutofillInlineMenuListCiphers" }); + postWindowMessage({ command: "updateAutofillInlineMenuListCiphers", token: "test-token" }); expect(updateCiphersSpy).toHaveBeenCalled(); }); @@ -1062,7 +1080,10 @@ describe("AutofillInlineMenuList", () => { postWindowMessage(createInitAutofillInlineMenuListMessageMock()); await flushPromises(); - postWindowMessage({ command: "updateAutofillInlineMenuGeneratedPassword" }); + postWindowMessage({ + command: "updateAutofillInlineMenuGeneratedPassword", + token: "test-token", + }); expect(buildColorizedPasswordElementSpy).not.toHaveBeenCalled(); }); @@ -1074,6 +1095,7 @@ describe("AutofillInlineMenuList", () => { postWindowMessage({ command: "updateAutofillInlineMenuGeneratedPassword", generatedPassword, + token: "test-token", }); expect(buildPasswordGeneratorSpy).toHaveBeenCalled(); @@ -1090,6 +1112,7 @@ describe("AutofillInlineMenuList", () => { postWindowMessage({ command: "updateAutofillInlineMenuGeneratedPassword", generatedPassword, + token: "test-token", }); expect(buildPasswordGeneratorSpy).toHaveBeenCalledTimes(1); @@ -1115,7 +1138,7 @@ describe("AutofillInlineMenuList", () => { ); await flushPromises(); - postWindowMessage({ command: "showSaveLoginInlineMenuList" }); + postWindowMessage({ command: "showSaveLoginInlineMenuList", token: "test-token" }); expect(buildSaveLoginInlineMenuSpy).not.toHaveBeenCalled(); }); @@ -1124,7 +1147,7 @@ describe("AutofillInlineMenuList", () => { postWindowMessage(createInitAutofillInlineMenuListMessageMock()); await flushPromises(); - postWindowMessage({ command: "showSaveLoginInlineMenuList" }); + postWindowMessage({ command: "showSaveLoginInlineMenuList", token: "test-token" }); expect(buildSaveLoginInlineMenuSpy).toHaveBeenCalled(); }); @@ -1143,7 +1166,7 @@ describe("AutofillInlineMenuList", () => { "setAttribute", ); - postWindowMessage({ command: "focusAutofillInlineMenuList" }); + postWindowMessage({ command: "focusAutofillInlineMenuList", token: "test-token" }); expect(inlineMenuContainerSetAttributeSpy).toHaveBeenCalledWith("role", "dialog"); expect(inlineMenuContainerSetAttributeSpy).toHaveBeenCalledWith("aria-modal", "true"); @@ -1161,7 +1184,7 @@ describe("AutofillInlineMenuList", () => { autofillInlineMenuList["inlineMenuListContainer"].querySelector("#unlock-button"); jest.spyOn(unlockButton as HTMLElement, "focus"); - postWindowMessage({ command: "focusAutofillInlineMenuList" }); + postWindowMessage({ command: "focusAutofillInlineMenuList", token: "test-token" }); expect((unlockButton as HTMLElement).focus).toBeCalled(); }); @@ -1173,7 +1196,7 @@ describe("AutofillInlineMenuList", () => { autofillInlineMenuList["inlineMenuListContainer"].querySelector("#new-item-button"); jest.spyOn(newItemButton as HTMLElement, "focus"); - postWindowMessage({ command: "focusAutofillInlineMenuList" }); + postWindowMessage({ command: "focusAutofillInlineMenuList", token: "test-token" }); expect((newItemButton as HTMLElement).focus).toBeCalled(); }); @@ -1184,7 +1207,7 @@ describe("AutofillInlineMenuList", () => { autofillInlineMenuList["inlineMenuListContainer"].querySelector(".fill-cipher-button"); jest.spyOn(firstCipherItem as HTMLElement, "focus"); - postWindowMessage({ command: "focusAutofillInlineMenuList" }); + postWindowMessage({ command: "focusAutofillInlineMenuList", token: "test-token" }); expect((firstCipherItem as HTMLElement).focus).toBeCalled(); }); @@ -1197,8 +1220,8 @@ describe("AutofillInlineMenuList", () => { globalThis.dispatchEvent(new Event("blur")); expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { command: "autofillInlineMenuBlurred", portKey }, - "*", + { command: "autofillInlineMenuBlurred", portKey, token: "test-token" }, + expectedOrigin, ); }); }); @@ -1220,8 +1243,13 @@ describe("AutofillInlineMenuList", () => { ); expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { command: "redirectAutofillInlineMenuFocusOut", direction: "previous", portKey }, - "*", + { + command: "redirectAutofillInlineMenuFocusOut", + direction: "previous", + portKey, + token: "test-token", + }, + expectedOrigin, ); }); @@ -1229,8 +1257,13 @@ describe("AutofillInlineMenuList", () => { globalThis.document.dispatchEvent(new KeyboardEvent("keydown", { code: "Tab" })); expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { command: "redirectAutofillInlineMenuFocusOut", direction: "next", portKey }, - "*", + { + command: "redirectAutofillInlineMenuFocusOut", + direction: "next", + portKey, + token: "test-token", + }, + expectedOrigin, ); }); @@ -1238,8 +1271,13 @@ describe("AutofillInlineMenuList", () => { globalThis.document.dispatchEvent(new KeyboardEvent("keydown", { code: "Escape" })); expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { command: "redirectAutofillInlineMenuFocusOut", direction: "current", portKey }, - "*", + { + command: "redirectAutofillInlineMenuFocusOut", + direction: "current", + portKey, + token: "test-token", + }, + expectedOrigin, ); }); }); @@ -1274,8 +1312,13 @@ describe("AutofillInlineMenuList", () => { autofillInlineMenuList["handleResizeObserver"](entries as unknown as ResizeObserverEntry[]); expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { command: "updateAutofillInlineMenuListHeight", styles: { height: "300px" }, portKey }, - "*", + { + command: "updateAutofillInlineMenuListHeight", + styles: { height: "300px" }, + portKey, + token: "test-token", + }, + expectedOrigin, ); }); }); diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss b/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss index 93f5f647ffe..ee9c68ee603 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss @@ -82,7 +82,7 @@ body * { width: 100%; font-family: $font-family-sans-serif; font-size: 1.6rem; - font-weight: 700; + font-weight: 500; text-align: left; background: transparent; border: none; @@ -187,7 +187,7 @@ body * { top: 0; z-index: 1; font-family: $font-family-sans-serif; - font-weight: 600; + font-weight: 500; font-size: 1rem; line-height: 1.3; letter-spacing: 0.025rem; diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.spec.ts index f7a5727e47f..e0a6e626b3c 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.spec.ts @@ -6,11 +6,13 @@ import { AutofillInlineMenuContainer } from "./autofill-inline-menu-container"; describe("AutofillInlineMenuContainer", () => { const portKey = "testPortKey"; - const iframeUrl = "https://example.com"; + const extensionOrigin = "chrome-extension://test-extension-id"; + const iframeUrl = `${extensionOrigin}/overlay/menu-list.html`; const pageTitle = "Example"; let autofillInlineMenuContainer: AutofillInlineMenuContainer; beforeEach(() => { + jest.spyOn(chrome.runtime, "getURL").mockReturnValue(`${extensionOrigin}/`); autofillInlineMenuContainer = new AutofillInlineMenuContainer(); }); @@ -28,7 +30,7 @@ describe("AutofillInlineMenuContainer", () => { portName: AutofillOverlayPort.List, }; - postWindowMessage(message); + postWindowMessage(message, extensionOrigin); expect(autofillInlineMenuContainer["defaultIframeAttributes"].src).toBe(message.iframeUrl); expect(autofillInlineMenuContainer["defaultIframeAttributes"].title).toBe(message.pageTitle); @@ -44,15 +46,48 @@ describe("AutofillInlineMenuContainer", () => { portName: AutofillOverlayPort.Button, }; - postWindowMessage(message); + postWindowMessage(message, extensionOrigin); jest.spyOn(autofillInlineMenuContainer["inlineMenuPageIframe"].contentWindow, "postMessage"); autofillInlineMenuContainer["inlineMenuPageIframe"].dispatchEvent(new Event("load")); expect(chrome.runtime.connect).toHaveBeenCalledWith({ name: message.portName }); + const expectedMessage = expect.objectContaining({ + ...message, + token: expect.any(String), + }); expect( autofillInlineMenuContainer["inlineMenuPageIframe"].contentWindow.postMessage, - ).toHaveBeenCalledWith(message, "*"); + ).toHaveBeenCalledWith(expectedMessage, "*"); + }); + + it("ignores initialization when URLs are not from extension origin", () => { + const invalidIframeUrlMessage = { + command: "initAutofillInlineMenuList", + iframeUrl: "https://malicious.com/overlay/menu-list.html", + pageTitle, + portKey, + portName: AutofillOverlayPort.List, + }; + + postWindowMessage(invalidIframeUrlMessage, extensionOrigin); + expect(autofillInlineMenuContainer["inlineMenuPageIframe"]).toBeUndefined(); + expect(autofillInlineMenuContainer["isInitialized"]).toBe(false); + + autofillInlineMenuContainer = new AutofillInlineMenuContainer(); + + const invalidStyleSheetUrlMessage = { + command: "initAutofillInlineMenuList", + iframeUrl, + pageTitle, + portKey, + portName: AutofillOverlayPort.List, + styleSheetUrl: "https://malicious.com/styles.css", + }; + + postWindowMessage(invalidStyleSheetUrlMessage, extensionOrigin); + expect(autofillInlineMenuContainer["inlineMenuPageIframe"]).toBeUndefined(); + expect(autofillInlineMenuContainer["isInitialized"]).toBe(false); }); }); @@ -69,7 +104,7 @@ describe("AutofillInlineMenuContainer", () => { portName: AutofillOverlayPort.Button, }; - postWindowMessage(message); + postWindowMessage(message, extensionOrigin); iframe = autofillInlineMenuContainer["inlineMenuPageIframe"]; jest.spyOn(iframe.contentWindow, "postMessage"); @@ -112,7 +147,8 @@ describe("AutofillInlineMenuContainer", () => { }); it("posts a message to the background from the inline menu iframe", () => { - const message = { command: "checkInlineMenuButtonFocused", portKey }; + const token = autofillInlineMenuContainer["token"]; + const message = { command: "checkInlineMenuButtonFocused", portKey, token }; postWindowMessage(message, "null", iframe.contentWindow as any); @@ -124,7 +160,62 @@ describe("AutofillInlineMenuContainer", () => { postWindowMessage(message); - expect(iframe.contentWindow.postMessage).toHaveBeenCalledWith(message, "*"); + const expectedMessage = expect.objectContaining({ + ...message, + token: expect.any(String), + }); + expect(iframe.contentWindow.postMessage).toHaveBeenCalledWith(expectedMessage, "*"); + }); + + it("ignores messages from iframe with invalid token", () => { + const message = { command: "checkInlineMenuButtonFocused", portKey, token: "invalid-token" }; + + postWindowMessage(message, "null", iframe.contentWindow as any); + + expect(port.postMessage).not.toHaveBeenCalled(); + }); + + it("ignores messages from iframe with commands not in the allowlist", () => { + const token = autofillInlineMenuContainer["token"]; + const message = { command: "maliciousCommand", portKey, token }; + + postWindowMessage(message, "null", iframe.contentWindow as any); + + expect(port.postMessage).not.toHaveBeenCalled(); + }); + }); + + describe("isExtensionUrlWithOrigin", () => { + it("validates extension URLs with matching origin", () => { + const url = "chrome-extension://test-id/path/to/file.html"; + const origin = "chrome-extension://test-id"; + + expect(autofillInlineMenuContainer["isExtensionUrlWithOrigin"](url, origin)).toBe(true); + }); + + it("rejects extension URLs with mismatched origin", () => { + const url = "chrome-extension://test-id/path/to/file.html"; + const origin = "chrome-extension://different-id"; + + expect(autofillInlineMenuContainer["isExtensionUrlWithOrigin"](url, origin)).toBe(false); + }); + + it("validates extension URL against its own origin when no expectedOrigin provided", () => { + const url = "moz-extension://test-id/path/to/file.html"; + + expect(autofillInlineMenuContainer["isExtensionUrlWithOrigin"](url)).toBe(true); + }); + + it("rejects non-extension protocols", () => { + const url = "https://example.com/path"; + const origin = "https://example.com"; + + expect(autofillInlineMenuContainer["isExtensionUrlWithOrigin"](url, origin)).toBe(false); + }); + + it("rejects empty or invalid URLs", () => { + expect(autofillInlineMenuContainer["isExtensionUrlWithOrigin"]("")).toBe(false); + expect(autofillInlineMenuContainer["isExtensionUrlWithOrigin"]("not-a-url")).toBe(false); }); }); }); diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.ts index 663eae9144a..6c61cfae6b4 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.ts @@ -1,8 +1,7 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { EVENTS } from "@bitwarden/common/autofill/constants"; -import { setElementStyles } from "../../../../utils"; +import { BrowserApi } from "../../../../../platform/browser/browser-api"; +import { generateRandomChars, setElementStyles } from "../../../../utils"; import { InitAutofillInlineMenuElementMessage, AutofillInlineMenuContainerWindowMessageHandlers, @@ -10,12 +9,37 @@ import { AutofillInlineMenuContainerPortMessage, } from "../../abstractions/autofill-inline-menu-container"; +/** + * Allowlist of commands that can be sent to the background script. + */ +const ALLOWED_BG_COMMANDS = new Set([ + "addNewVaultItem", + "autofillInlineMenuBlurred", + "autofillInlineMenuButtonClicked", + "checkAutofillInlineMenuButtonFocused", + "checkInlineMenuButtonFocused", + "fillAutofillInlineMenuCipher", + "fillGeneratedPassword", + "redirectAutofillInlineMenuFocusOut", + "refreshGeneratedPassword", + "refreshOverlayCiphers", + "triggerDelayedAutofillInlineMenuClosure", + "updateAutofillInlineMenuColorScheme", + "updateAutofillInlineMenuListHeight", + "unlockVault", + "viewSelectedCipher", +]); + export class AutofillInlineMenuContainer { private readonly setElementStyles = setElementStyles; - private readonly extensionOriginsSet: Set; private port: chrome.runtime.Port | null = null; - private portName: string; - private inlineMenuPageIframe: HTMLIFrameElement; + /** Non-null asserted. */ + private portName!: string; + /** Non-null asserted. */ + private inlineMenuPageIframe!: HTMLIFrameElement; + private token: string; + private isInitialized: boolean = false; + private readonly extensionOrigin: string; private readonly iframeStyles: Partial = { all: "initial", position: "fixed", @@ -42,16 +66,15 @@ export class AutofillInlineMenuContainer { tabIndex: "-1", }; private readonly windowMessageHandlers: AutofillInlineMenuContainerWindowMessageHandlers = { - initAutofillInlineMenuButton: (message) => this.handleInitInlineMenuIframe(message), - initAutofillInlineMenuList: (message) => this.handleInitInlineMenuIframe(message), + initAutofillInlineMenuButton: (message: InitAutofillInlineMenuElementMessage) => + this.handleInitInlineMenuIframe(message), + initAutofillInlineMenuList: (message: InitAutofillInlineMenuElementMessage) => + this.handleInitInlineMenuIframe(message), }; constructor() { - this.extensionOriginsSet = new Set([ - chrome.runtime.getURL("").slice(0, -1).toLowerCase(), // Remove the trailing slash and normalize the extension url to lowercase - "null", - ]); - + this.token = generateRandomChars(32); + this.extensionOrigin = BrowserApi.getRuntimeURL("")?.slice(0, -1); globalThis.addEventListener("message", this.handleWindowMessage); } @@ -61,9 +84,24 @@ export class AutofillInlineMenuContainer { * @param message - The message containing the iframe url and page title. */ private handleInitInlineMenuIframe(message: InitAutofillInlineMenuElementMessage) { + if (this.isInitialized) { + return; + } + + const expectedOrigin = message.extensionOrigin || this.extensionOrigin; + + if (!this.isExtensionUrlWithOrigin(message.iframeUrl, expectedOrigin)) { + return; + } + + if (message.styleSheetUrl && !this.isExtensionUrlWithOrigin(message.styleSheetUrl)) { + return; + } + this.defaultIframeAttributes.src = message.iframeUrl; this.defaultIframeAttributes.title = message.pageTitle; this.portName = message.portName; + this.isInitialized = true; this.inlineMenuPageIframe = globalThis.document.createElement("iframe"); this.setElementStyles(this.inlineMenuPageIframe, this.iframeStyles, true); @@ -79,6 +117,31 @@ export class AutofillInlineMenuContainer { globalThis.document.body.appendChild(this.inlineMenuPageIframe); } + /** + * Validates that a URL uses an extension protocol and matches the expected extension origin. + * If no expectedOrigin is provided, validates against the URL's own origin. + * + * @param url - The URL to validate. + */ + private isExtensionUrlWithOrigin(url: string, expectedOrigin?: string): boolean { + if (!url) { + return false; + } + try { + const urlObj = new URL(url); + const isExtensionProtocol = /^[a-z]+(-[a-z]+)?-extension:$/i.test(urlObj.protocol); + + if (!isExtensionProtocol) { + return false; + } + + const originToValidate = expectedOrigin ?? urlObj.origin; + return urlObj.origin === originToValidate || urlObj.href.startsWith(originToValidate + "/"); + } catch { + return false; + } + } + /** * Sets up the port message listener for the inline menu page. * @@ -86,7 +149,8 @@ export class AutofillInlineMenuContainer { */ private setupPortMessageListener = (message: InitAutofillInlineMenuElementMessage) => { this.port = chrome.runtime.connect({ name: this.portName }); - this.postMessageToInlineMenuPage(message); + const initMessage = { ...message, token: this.token }; + this.postMessageToInlineMenuPageUnsafe(initMessage); }; /** @@ -95,6 +159,22 @@ export class AutofillInlineMenuContainer { * @param message - The message to post. */ private postMessageToInlineMenuPage(message: AutofillInlineMenuContainerWindowMessage) { + if (this.inlineMenuPageIframe?.contentWindow) { + const messageWithToken = { ...message, token: this.token }; + this.postMessageToInlineMenuPageUnsafe(messageWithToken); + } + } + + /** + * Posts a message to the inline menu page iframe without token validation. + * + * UNSAFE: Bypasses token authentication and sends raw messages. Only use internally + * when sending trusted messages (e.g., initialization) or when token validation + * would create circular dependencies. External callers should use postMessageToInlineMenuPage(). + * + * @param message - The message to post. + */ + private postMessageToInlineMenuPageUnsafe(message: Record) { if (this.inlineMenuPageIframe?.contentWindow) { this.inlineMenuPageIframe.contentWindow.postMessage(message, "*"); } @@ -106,9 +186,15 @@ export class AutofillInlineMenuContainer { * @param message - The message to post. */ private postMessageToBackground(message: AutofillInlineMenuContainerPortMessage) { - if (this.port) { - this.port.postMessage(message); + if (!this.port) { + return; } + + if (message.command && !ALLOWED_BG_COMMANDS.has(message.command)) { + return; + } + + this.port.postMessage(message); } /** @@ -116,23 +202,42 @@ export class AutofillInlineMenuContainer { * * @param event - The message event. */ - private handleWindowMessage = (event: MessageEvent) => { + private handleWindowMessage = (event: MessageEvent) => { const message = event.data; + if (!message?.command) { + return; + } if (this.isForeignWindowMessage(event)) { return; } if (this.windowMessageHandlers[message.command]) { + // only accept init messages from extension origin or parent window + if ( + (message.command === "initAutofillInlineMenuButton" || + message.command === "initAutofillInlineMenuList") && + !this.isMessageFromExtensionOrigin(event) && + !this.isMessageFromParentWindow(event) + ) { + return; + } this.windowMessageHandlers[message.command](message); return; } if (this.isMessageFromParentWindow(event)) { + // messages from parent window are trusted and forwarded to iframe this.postMessageToInlineMenuPage(message); return; } - this.postMessageToBackground(message); + // messages from iframe to background require object identity verification with a contentWindow check and token auth + if (this.isMessageFromInlineMenuPageIframe(event)) { + if (this.isValidSessionToken(message)) { + this.postMessageToBackground(message); + } + return; + } }; /** @@ -142,8 +247,8 @@ export class AutofillInlineMenuContainer { * * @param event - The message event. */ - private isForeignWindowMessage(event: MessageEvent) { - if (!event.data.portKey) { + private isForeignWindowMessage(event: MessageEvent) { + if (!event.data?.portKey) { return true; } @@ -159,7 +264,9 @@ export class AutofillInlineMenuContainer { * * @param event - The message event. */ - private isMessageFromParentWindow(event: MessageEvent): boolean { + private isMessageFromParentWindow( + event: MessageEvent, + ): boolean { return globalThis.parent === event.source; } @@ -168,14 +275,43 @@ export class AutofillInlineMenuContainer { * * @param event - The message event. */ - private isMessageFromInlineMenuPageIframe(event: MessageEvent): boolean { + private isMessageFromInlineMenuPageIframe( + event: MessageEvent, + ): boolean { if (!this.inlineMenuPageIframe) { return false; } + // only trust the specific iframe we created + return this.inlineMenuPageIframe.contentWindow === event.source; + } - return ( - this.inlineMenuPageIframe.contentWindow === event.source && - this.extensionOriginsSet.has(event.origin.toLowerCase()) - ); + /** + * Validates that the message contains a valid session token. + * The session token is generated when the container is created and is refreshed + * every time the inline menu container is recreated. + * + */ + private isValidSessionToken(message: { token: string }): boolean { + if (!this.token || !message?.token || !message?.token.length) { + return false; + } + return message.token === this.token; + } + + /** + * Validates that a message event originates from the extension. + * + * @param event - The message event to validate. + * @returns True if the message is from the extension origin. + */ + private isMessageFromExtensionOrigin(event: MessageEvent): boolean { + try { + if (event.origin === "null") { + return false; + } + return event.origin === this.extensionOrigin; + } catch { + return false; + } } } diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts index 950676cf202..5df6e7cd190 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { EVENTS } from "@bitwarden/common/autofill/constants"; import { RedirectFocusDirection } from "../../../../enums/autofill-overlay.enum"; @@ -10,10 +8,15 @@ import { export class AutofillInlineMenuPageElement extends HTMLElement { protected shadowDom: ShadowRoot; - protected messageOrigin: string; - protected translations: Record; - private portKey: string; - protected windowMessageHandlers: AutofillInlineMenuPageElementWindowMessageHandlers; + /** Non-null asserted. */ + protected messageOrigin!: string; + /** Non-null asserted. */ + protected translations!: Record; + /** Non-null asserted. */ + private portKey!: string; + /** Non-null asserted. */ + protected windowMessageHandlers!: AutofillInlineMenuPageElementWindowMessageHandlers; + private token?: string; constructor() { super(); @@ -56,7 +59,16 @@ export class AutofillInlineMenuPageElement extends HTMLElement { * @param message - The message to post */ protected postMessageToParent(message: AutofillInlineMenuPageElementWindowMessage) { - globalThis.parent.postMessage({ portKey: this.portKey, ...message }, "*"); + // never send messages containing authentication tokens without a valid token and an established messageOrigin + if (!this.token || !this.messageOrigin) { + return; + } + const messageWithAuth: Record = { + portKey: this.portKey, + ...message, + token: this.token, + }; + globalThis.parent.postMessage(messageWithAuth, this.messageOrigin); } /** @@ -94,6 +106,10 @@ export class AutofillInlineMenuPageElement extends HTMLElement { return; } + if (event.source !== globalThis.parent) { + return; + } + if (!this.messageOrigin) { this.messageOrigin = event.origin; } @@ -103,6 +119,26 @@ export class AutofillInlineMenuPageElement extends HTMLElement { } const message = event?.data; + + if (!message?.command) { + return; + } + + const isInitCommand = + message.command === "initAutofillInlineMenuButton" || + message.command === "initAutofillInlineMenuList"; + + if (isInitCommand) { + if (!message?.token) { + return; + } + this.token = message.token; + } else { + if (!this.token || !message?.token || message.token !== this.token) { + return; + } + } + const handler = this.windowMessageHandlers[message?.command]; if (!handler) { return; diff --git a/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap b/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap index e5bafe34b5f..cfcedc9da7a 100644 --- a/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap +++ b/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap @@ -1,13 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`OverlayNotificationsContentService opening the notification bar creates the notification bar elements and appends them to the body 1`] = ` +exports[`OverlayNotificationsContentService opening the notification bar creates the notification bar elements and appends them to the body within a shadow root 1`] = `
`; const iframe = document.querySelector("iframe") as HTMLIFrameElement; + jest + .spyOn(iframe, "getBoundingClientRect") + .mockReturnValue(mockRect({ width: 1, height: 1, left: 2, top: 2 })); const subFrameData = { url: "https://example.com/", frameId: 10, @@ -2305,6 +2323,9 @@ describe("AutofillOverlayContentService", () => { it("posts the calculated sub frame data to the background", async () => { document.body.innerHTML = ``; const iframe = document.querySelector("iframe") as HTMLIFrameElement; + jest + .spyOn(iframe, "getBoundingClientRect") + .mockReturnValue(mockRect({ width: 1, height: 1, left: 2, top: 2 })); const subFrameData = { url: "https://example.com/", frameId: 10, @@ -2335,6 +2356,39 @@ describe("AutofillOverlayContentService", () => { }); }); + describe("calculateSubFrameOffsets", () => { + it("returns null when iframe has zero width and height", () => { + const iframe = document.querySelector("iframe") as HTMLIFrameElement; + + jest + .spyOn(iframe, "getBoundingClientRect") + .mockReturnValue(mockRect({ left: 0, top: 0, width: 0, height: 0 })); + + const result = autofillOverlayContentService["calculateSubFrameOffsets"]( + iframe, + "https://example.com/", + 10, + ); + + expect(result).toBeNull(); + }); + + it("returns null when iframe is not connected to the document", () => { + const iframe = document.createElement("iframe") as HTMLIFrameElement; + + jest + .spyOn(iframe, "getBoundingClientRect") + .mockReturnValue(mockRect({ width: 100, height: 50, left: 10, top: 20 })); + + const result = autofillOverlayContentService["calculateSubFrameOffsets"]( + iframe, + "https://example.com/", + 10, + ); + expect(result).toBeNull(); + }); + }); + describe("checkMostRecentlyFocusedFieldHasValue message handler", () => { it("returns true if the most recently focused field has a truthy value", async () => { autofillOverlayContentService["mostRecentlyFocusedField"] = mock< diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 656516d1119..817a7cca43c 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -975,6 +975,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ showPasskeys: !!autofillFieldData?.showPasskeys, accountCreationFieldType: autofillFieldData?.accountCreationFieldType, focusedFieldForm: autofillFieldData?.form, + focusedFieldOpid: autofillFieldData?.opid, }; const allFields = this.formFieldElements; @@ -1085,7 +1086,15 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ pageDetails, ) ) { - this.setQualifiedAccountCreationFillType(autofillFieldData); + const hasUsernameField = [...this.formFieldElements.values()].some((field) => + this.inlineMenuFieldQualificationService.isUsernameField(field), + ); + + if (hasUsernameField) { + void this.setQualifiedLoginFillType(autofillFieldData); + } else { + this.setQualifiedAccountCreationFillType(autofillFieldData); + } return false; } @@ -1109,6 +1118,12 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ * @param autofillFieldData - Autofill field data captured from the form field element. */ private async setQualifiedLoginFillType(autofillFieldData: AutofillField) { + // Check if this is a current password field in a password change form + if (this.inlineMenuFieldQualificationService.isUpdateCurrentPasswordField(autofillFieldData)) { + autofillFieldData.inlineMenuFillType = InlineMenuFillTypes.CurrentPasswordUpdate; + return; + } + autofillFieldData.inlineMenuFillType = CipherType.Login; autofillFieldData.showPasskeys = autofillFieldData.autoCompleteType.includes("webauthn"); @@ -1485,12 +1500,17 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ frameId?: number, ): SubFrameOffsetData { const iframeRect = iframeElement.getBoundingClientRect(); + const iframeRectHasSize = iframeRect.width > 0 && iframeRect.height > 0; const iframeStyles = globalThis.getComputedStyle(iframeElement); const paddingLeft = parseInt(iframeStyles.getPropertyValue("padding-left")) || 0; const paddingTop = parseInt(iframeStyles.getPropertyValue("padding-top")) || 0; const borderWidthLeft = parseInt(iframeStyles.getPropertyValue("border-left-width")) || 0; const borderWidthTop = parseInt(iframeStyles.getPropertyValue("border-top-width")) || 0; + if (!iframeRect || !iframeRectHasSize || !iframeElement.isConnected) { + return null; + } + return { url: subFrameUrl, frameId, @@ -1525,6 +1545,10 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ subFrameData.frameId, ); + if (!subFrameOffsets) { + return; + } + subFrameData.top += subFrameOffsets.top; subFrameData.left += subFrameOffsets.left; @@ -1657,10 +1681,6 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ globalThis.addEventListener(EVENTS.RESIZE, repositionHandler); } - private shouldRepositionSubFrameInlineMenuOnScroll = async () => { - return await this.sendExtensionMessage("shouldRepositionSubFrameInlineMenuOnScroll"); - }; - /** * Removes the listeners that facilitate repositioning * the overlay elements on scroll or resize. diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index f0ae8856ecd..13e97766594 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -1,6 +1,7 @@ import { mock, MockProxy, mockReset } from "jest-mock-extended"; import { BehaviorSubject, of, Subject } from "rxjs"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; @@ -43,12 +44,14 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service"; import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums"; +import { InlineMenuFillTypes } from "../enums/autofill-overlay.enum"; import { AutofillPort } from "../enums/autofill-port.enum"; import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript from "../models/autofill-script"; import { createAutofillFieldMock, + createAutofillFormMock, createAutofillPageDetailsMock, createAutofillScriptMock, createChromeTabMock, @@ -86,6 +89,7 @@ describe("AutofillService", () => { const totpService = mock(); const eventCollectionService = mock(); const logService = mock(); + const policyService = mock(); const userVerificationService = mock(); const billingAccountProfileStateService = mock(); const platformUtilsService = mock(); @@ -100,6 +104,15 @@ describe("AutofillService", () => { beforeEach(() => { configService = mock(); configService.getFeatureFlag$.mockImplementation(() => of(false)); + + // Initialize domainSettingsService BEFORE it's used + domainSettingsService = new DefaultDomainSettingsService( + fakeStateProvider, + policyService, + accountService, + ); + domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); + scriptInjectorService = new BrowserScriptInjectorService( domainSettingsService, platformUtilsService, @@ -138,8 +151,6 @@ describe("AutofillService", () => { userNotificationsSettings, messageListener, ); - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); - domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); jest.spyOn(BrowserApi, "tabSendMessage"); }); @@ -363,9 +374,7 @@ describe("AutofillService", () => { jest.spyOn(autofillService as any, "injectAutofillScriptsInAllTabs"); jest.spyOn(autofillService, "getAutofillOnPageLoad").mockResolvedValue(true); - // 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 - autofillService.reloadAutofillScripts(); + void autofillService.reloadAutofillScripts(); expect(port1.disconnect).toHaveBeenCalled(); expect(port2.disconnect).toHaveBeenCalled(); @@ -674,7 +683,9 @@ describe("AutofillService", () => { await autofillService.doAutoFill(autofillOptions); triggerTestFailure(); } catch (error) { - expect(error.message).toBe(nothingToAutofillError); + if (error instanceof Error) { + expect(error.message).toBe(nothingToAutofillError); + } } }); @@ -685,7 +696,9 @@ describe("AutofillService", () => { await autofillService.doAutoFill(autofillOptions); triggerTestFailure(); } catch (error) { - expect(error.message).toBe(nothingToAutofillError); + if (error instanceof Error) { + expect(error.message).toBe(nothingToAutofillError); + } } }); @@ -696,7 +709,9 @@ describe("AutofillService", () => { await autofillService.doAutoFill(autofillOptions); triggerTestFailure(); } catch (error) { - expect(error.message).toBe(nothingToAutofillError); + if (error instanceof Error) { + expect(error.message).toBe(nothingToAutofillError); + } } }); @@ -707,7 +722,9 @@ describe("AutofillService", () => { await autofillService.doAutoFill(autofillOptions); triggerTestFailure(); } catch (error) { - expect(error.message).toBe(nothingToAutofillError); + if (error instanceof Error) { + expect(error.message).toBe(nothingToAutofillError); + } } }); @@ -721,7 +738,9 @@ describe("AutofillService", () => { await autofillService.doAutoFill(autofillOptions); triggerTestFailure(); } catch (error) { - expect(error.message).toBe(didNotAutofillError); + if (error instanceof Error) { + expect(error.message).toBe(didNotAutofillError); + } } }); }); @@ -760,7 +779,6 @@ describe("AutofillService", () => { { command: "fillForm", fillScript: { - metadata: {}, properties: { delay_between_operations: 20, }, @@ -857,7 +875,9 @@ describe("AutofillService", () => { expect(logService.info).toHaveBeenCalledWith( "Autofill on page load was blocked due to an untrusted iframe.", ); - expect(error.message).toBe(didNotAutofillError); + if (error instanceof Error) { + expect(error.message).toBe(didNotAutofillError); + } } }); @@ -892,7 +912,10 @@ describe("AutofillService", () => { } catch (error) { expect(autofillService["generateFillScript"]).toHaveBeenCalled(); expect(BrowserApi.tabSendMessage).not.toHaveBeenCalled(); - expect(error.message).toBe(didNotAutofillError); + + if (error instanceof Error) { + expect(error.message).toBe(didNotAutofillError); + } } }); @@ -1364,7 +1387,10 @@ describe("AutofillService", () => { triggerTestFailure(); } catch (error) { expect(BrowserApi.getTabFromCurrentWindow).toHaveBeenCalled(); - expect(error.message).toBe("No tab found."); + + if (error instanceof Error) { + expect(error.message).toBe("No tab found."); + } } }); @@ -1604,7 +1630,6 @@ describe("AutofillService", () => { expect(autofillService["generateLoginFillScript"]).toHaveBeenCalledWith( { - metadata: {}, properties: {}, script: [ ["click_on_opid", "username-field"], @@ -1642,7 +1667,6 @@ describe("AutofillService", () => { expect(autofillService["generateCardFillScript"]).toHaveBeenCalledWith( { - metadata: {}, properties: {}, script: [ ["click_on_opid", "username-field"], @@ -1680,7 +1704,6 @@ describe("AutofillService", () => { expect(autofillService["generateIdentityFillScript"]).toHaveBeenCalledWith( { - metadata: {}, properties: {}, script: [ ["click_on_opid", "username-field"], @@ -2058,6 +2081,193 @@ describe("AutofillService", () => { }); }); + describe("given password generation with inlineMenuFillType", () => { + beforeEach(() => { + pageDetails.forms = undefined; + pageDetails.fields = []; // Clear fields to start fresh + options.inlineMenuFillType = InlineMenuFillTypes.PasswordGeneration; + options.cipher.login.totp = null; // Disable TOTP for these tests + }); + + it("includes all password fields from the same form when filling with password generation", async () => { + const newPasswordField = createAutofillFieldMock({ + opid: "new-password", + type: "password", + form: "validFormId", + elementNumber: 2, + }); + const confirmPasswordField = createAutofillFieldMock({ + opid: "confirm-password", + type: "password", + form: "validFormId", + elementNumber: 3, + }); + pageDetails.fields.push(newPasswordField, confirmPasswordField); + options.focusedFieldOpid = newPasswordField.opid; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(filledFields[newPasswordField.opid]).toBeDefined(); + expect(filledFields[confirmPasswordField.opid]).toBeDefined(); + }); + + it("finds username field for the first password field when generating passwords", async () => { + const newPasswordField = createAutofillFieldMock({ + opid: "new-password", + type: "password", + form: "validFormId", + elementNumber: 2, + }); + pageDetails.fields.push(newPasswordField); + options.focusedFieldOpid = newPasswordField.opid; + jest.spyOn(autofillService as any, "findUsernameField"); + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(autofillService["findUsernameField"]).toHaveBeenCalledWith( + pageDetails, + expect.objectContaining({ opid: newPasswordField.opid }), + false, + false, + true, + ); + }); + + it("does not include password fields from different forms", async () => { + const formAPasswordField = createAutofillFieldMock({ + opid: "form-a-password", + type: "password", + form: "formA", + elementNumber: 1, + }); + const formBPasswordField = createAutofillFieldMock({ + opid: "form-b-password", + type: "password", + form: "formB", + elementNumber: 2, + }); + pageDetails.fields = [formAPasswordField, formBPasswordField]; + options.focusedFieldOpid = formAPasswordField.opid; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(filledFields[formAPasswordField.opid]).toBeDefined(); + expect(filledFields[formBPasswordField.opid]).toBeUndefined(); + }); + }); + + describe("given current password update with inlineMenuFillType", () => { + beforeEach(() => { + pageDetails.forms = undefined; + pageDetails.fields = []; // Clear fields to start fresh + options.inlineMenuFillType = InlineMenuFillTypes.CurrentPasswordUpdate; + options.cipher.login.totp = null; // Disable TOTP for these tests + }); + + it("includes all password fields from the same form when updating current password", async () => { + const currentPasswordField = createAutofillFieldMock({ + opid: "current-password", + type: "password", + form: "validFormId", + elementNumber: 1, + }); + const newPasswordField = createAutofillFieldMock({ + opid: "new-password", + type: "password", + form: "validFormId", + elementNumber: 2, + }); + const confirmPasswordField = createAutofillFieldMock({ + opid: "confirm-password", + type: "password", + form: "validFormId", + elementNumber: 3, + }); + pageDetails.fields.push(currentPasswordField, newPasswordField, confirmPasswordField); + options.focusedFieldOpid = currentPasswordField.opid; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(filledFields[currentPasswordField.opid]).toBeDefined(); + expect(filledFields[newPasswordField.opid]).toBeDefined(); + expect(filledFields[confirmPasswordField.opid]).toBeDefined(); + }); + + it("includes all password fields from the same form without TOTP", async () => { + const currentPasswordField = createAutofillFieldMock({ + opid: "current-password", + type: "password", + form: "validFormId", + elementNumber: 1, + }); + const newPasswordField = createAutofillFieldMock({ + opid: "new-password", + type: "password", + form: "validFormId", + elementNumber: 2, + }); + pageDetails.fields.push(currentPasswordField, newPasswordField); + options.focusedFieldOpid = currentPasswordField.opid; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(filledFields[currentPasswordField.opid]).toBeDefined(); + expect(filledFields[newPasswordField.opid]).toBeDefined(); + }); + + it("does not include password fields from different forms during password update", async () => { + const formAPasswordField = createAutofillFieldMock({ + opid: "form-a-password", + type: "password", + form: "formA", + elementNumber: 1, + }); + const formBPasswordField = createAutofillFieldMock({ + opid: "form-b-password", + type: "password", + form: "formB", + elementNumber: 2, + }); + pageDetails.fields = [formAPasswordField, formBPasswordField]; + options.focusedFieldOpid = formAPasswordField.opid; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(filledFields[formAPasswordField.opid]).toBeDefined(); + expect(filledFields[formBPasswordField.opid]).toBeUndefined(); + }); + }); + describe("given a set of page details that does not contain a password field", () => { let emailField: AutofillField; let emailFieldView: FieldView; @@ -2273,7 +2483,7 @@ describe("AutofillService", () => { ); expect(value).toStrictEqual({ autosubmit: null, - metadata: {}, + itemType: "", properties: { delay_between_operations: 20 }, savedUrls: ["https://www.example.com"], script: [ @@ -2288,10 +2498,150 @@ describe("AutofillService", () => { ["fill_by_opid", "password", "password"], ["focus_by_opid", "password"], ], - itemType: "", untrustedIframe: false, }); }); + + describe("given a focused username field", () => { + let focusedField: AutofillField; + let passwordField: AutofillField; + + beforeEach(() => { + focusedField = createAutofillFieldMock({ + opid: "focused-username", + type: "text", + form: "form1", + elementNumber: 1, + }); + passwordField = createAutofillFieldMock({ + opid: "password", + type: "password", + form: "form1", + elementNumber: 2, + }); + pageDetails.forms = { + form1: createAutofillFormMock({ opid: "form1" }), + }; + options.focusedFieldOpid = "focused-username"; + jest.spyOn(autofillService as any, "inUntrustedIframe").mockResolvedValue(false); + jest.spyOn(AutofillService, "fillByOpid"); + }); + + it("will return early when no matching password is found and set autosubmit if enabled", async () => { + pageDetails.fields = [focusedField]; + options.autoSubmitLogin = true; + + const value = await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(AutofillService.fillByOpid).toHaveBeenCalledTimes(1); + expect(AutofillService.fillByOpid).toHaveBeenCalledWith( + fillScript, + focusedField, + options.cipher.login.username, + ); + expect(value.autosubmit).toEqual(["form1"]); + }); + + it("will prioritize focused field and skip passwords in different forms", async () => { + const otherUsername = createAutofillFieldMock({ + opid: "other-username", + type: "text", + form: "form1", + elementNumber: 2, + }); + const passwordDifferentForm = createAutofillFieldMock({ + opid: "password-different", + type: "password", + form: "form2", + elementNumber: 1, + }); + pageDetails.fields = [focusedField, otherUsername, passwordField, passwordDifferentForm]; + pageDetails.forms.form2 = createAutofillFormMock({ opid: "form2" }); + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(AutofillService.fillByOpid).toHaveBeenCalledWith( + fillScript, + focusedField, + options.cipher.login.username, + ); + expect(AutofillService.fillByOpid).toHaveBeenCalledWith( + fillScript, + passwordField, + options.cipher.login.password, + ); + expect(AutofillService.fillByOpid).not.toHaveBeenCalledWith( + fillScript, + otherUsername, + expect.anything(), + ); + expect(AutofillService.fillByOpid).not.toHaveBeenCalledWith( + fillScript, + passwordDifferentForm, + expect.anything(), + ); + }); + + it("will not fill focused field if already in filledFields", async () => { + pageDetails.fields = [focusedField, passwordField]; + filledFields[focusedField.opid] = focusedField; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(AutofillService.fillByOpid).not.toHaveBeenCalledWith( + fillScript, + focusedField, + expect.anything(), + ); + expect(AutofillService.fillByOpid).toHaveBeenCalledWith( + fillScript, + passwordField, + options.cipher.login.password, + ); + }); + + it.each([ + ["email", "email"], + ["tel", "tel"], + ])("will treat focused %s field as username field", async (type, opid) => { + const focusedTypedField = createAutofillFieldMock({ + opid: `focused-${opid}`, + type: type as "email" | "tel", + form: "form1", + elementNumber: 1, + }); + pageDetails.fields = [focusedTypedField, passwordField]; + options.focusedFieldOpid = `focused-${opid}`; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(AutofillService.fillByOpid).toHaveBeenCalledWith( + fillScript, + focusedTypedField, + options.cipher.login.username, + ); + }); + }); }); }); @@ -2358,11 +2708,10 @@ describe("AutofillService", () => { describe("given an invalid autofill field", () => { const unmodifiedFillScriptValues: AutofillScript = { autosubmit: null, - metadata: {}, + itemType: "", properties: { delay_between_operations: 20 }, savedUrls: [], script: [], - itemType: "", untrustedIframe: false, }; @@ -2549,7 +2898,6 @@ describe("AutofillService", () => { expect(value).toStrictEqual({ autosubmit: null, itemType: "", - metadata: {}, properties: { delay_between_operations: 20, }, @@ -2983,12 +3331,16 @@ describe("AutofillService", () => { "example.com", "exampleapp.com", ]); - domainSettingsService.equivalentDomains$ = of([["not-example.com"]]); const pageUrl = "https://subdomain.example.com"; const tabUrl = "https://www.not-example.com"; const generateFillScriptOptions = createGenerateFillScriptOptionsMock({ tabUrl }); generateFillScriptOptions.cipher.login.matchesUri = jest.fn().mockReturnValueOnce(false); + // Mock getUrlEquivalentDomains to return the expected domains + jest + .spyOn(domainSettingsService, "getUrlEquivalentDomains") + .mockReturnValue(of(equivalentDomains)); + const result = await autofillService["inUntrustedIframe"](pageUrl, generateFillScriptOptions); expect(generateFillScriptOptions.cipher.login.matchesUri).toHaveBeenCalledWith( @@ -4105,6 +4457,7 @@ describe("AutofillService", () => { }); it("returns null if the field cannot be hidden", () => { + usernameField.form = "differentFormId"; const result = autofillService["findUsernameField"]( pageDetails, passwordField, @@ -4116,6 +4469,18 @@ describe("AutofillService", () => { expect(result).toBe(null); }); + it("returns the field if the username field is in the form", () => { + const result = autofillService["findUsernameField"]( + pageDetails, + passwordField, + false, + false, + false, + ); + + expect(result).toBe(usernameField); + }); + it("returns the field if the field can be hidden", () => { const result = autofillService["findUsernameField"]( pageDetails, diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 0e238d14d23..010f5ea0f27 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -52,6 +52,7 @@ import { ScriptInjectorService } from "../../platform/services/abstractions/scri // eslint-disable-next-line no-restricted-imports import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window"; import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums"; +import { InlineMenuFillTypes } from "../enums/autofill-overlay.enum"; import { AutofillPort } from "../enums/autofill-port.enum"; import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; @@ -161,7 +162,7 @@ export default class AutofillService implements AutofillServiceInterface { // Create a timeout observable that emits an empty array if pageDetailsFromTab$ hasn't emitted within 1 second. const pageDetailsTimeout$ = timer(1000).pipe( - map(() => []), + map((): any => []), takeUntil(sharedPageDetailsFromTab$), ); @@ -400,7 +401,7 @@ export default class AutofillService implements AutofillServiceInterface { * Gets the default URI match strategy setting from the domain settings service. */ async getDefaultUriMatchStrategy(): Promise { - return await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$); + return await firstValueFrom(this.domainSettingsService.resolvedDefaultUriMatchStrategy$); } /** @@ -451,6 +452,8 @@ export default class AutofillService implements AutofillServiceInterface { cipher: options.cipher, tabUrl: tab.url, defaultUriMatch: defaultUriMatch, + focusedFieldOpid: options.focusedFieldOpid, + inlineMenuFillType: options.inlineMenuFillType, }); if (!fillScript || !fillScript.script || !fillScript.script.length) { @@ -837,7 +840,7 @@ export default class AutofillService implements AutofillServiceInterface { } const passwords: AutofillField[] = []; - const usernames: AutofillField[] = []; + const usernames = new Map(); const totps: AutofillField[] = []; let pf: AutofillField = null; let username: AutofillField = null; @@ -856,27 +859,111 @@ export default class AutofillService implements AutofillServiceInterface { options.fillNewPassword, ); + const loginPasswordFields: AutofillField[] = []; + const registrationPasswordFields: AutofillField[] = []; + + passwordFields.forEach((passField) => { + if (this.isRegistrationPasswordField(pageDetails, passField)) { + registrationPasswordFields.push(passField); + } else { + loginPasswordFields.push(passField); + } + }); + + // Prefer login fields over registration fields + const prioritizedPasswordFields = + loginPasswordFields.length > 0 ? loginPasswordFields : registrationPasswordFields; + + const focusedField = + options.focusedFieldOpid && + pageDetails.fields.find((f) => f.opid === options.focusedFieldOpid); + const focusedForm = focusedField?.form; + + const isFocusedTotpField = + focusedField && + options.allowTotpAutofill && + (focusedField.type === "text" || + focusedField.type === "number" || + focusedField.type === "tel") && + (AutofillService.fieldIsFuzzyMatch(focusedField, [ + ...AutoFillConstants.TotpFieldNames, + ...AutoFillConstants.AmbiguousTotpFieldNames, + ]) || + focusedField.autoCompleteType === "one-time-code") && + !AutofillService.fieldIsFuzzyMatch(focusedField, [ + ...AutoFillConstants.RecoveryCodeFieldNames, + ]); + + const focusedUsernameField = + focusedField && + !isFocusedTotpField && + login.username && + (focusedField.type === "text" || + focusedField.type === "email" || + focusedField.type === "tel") && + focusedField; + + const passwordMatchesFocused = (pf: AutofillField): boolean => + !focusedField + ? true + : focusedForm != null + ? pf.form === focusedForm + : focusedUsernameField && + pf.form == null && + this.findUsernameField(pageDetails, pf, false, false, true)?.opid === + focusedUsernameField.opid; + + const getUsernameForPassword = ( + pf: AutofillField, + withoutForm: boolean, + ): AutofillField | null => { + // use focused username if it matches this password, otherwise fall back to finding username field before password + if (focusedUsernameField && passwordMatchesFocused(pf)) { + return focusedUsernameField; + } + return this.findUsernameField(pageDetails, pf, false, false, withoutForm); + }; + + if (focusedUsernameField && !prioritizedPasswordFields.some(passwordMatchesFocused)) { + if (!Object.prototype.hasOwnProperty.call(filledFields, focusedUsernameField.opid)) { + filledFields[focusedUsernameField.opid] = focusedUsernameField; + AutofillService.fillByOpid(fillScript, focusedUsernameField, login.username); + if (options.autoSubmitLogin && focusedUsernameField.form) { + fillScript.autosubmit = [focusedUsernameField.form]; + } + return AutofillService.setFillScriptForFocus( + { [focusedUsernameField.opid]: focusedUsernameField }, + fillScript, + ); + } + } + for (const formKey in pageDetails.forms) { // eslint-disable-next-line if (!pageDetails.forms.hasOwnProperty(formKey)) { continue; } - passwordFields.forEach((passField) => { + prioritizedPasswordFields.forEach((passField) => { + if (focusedField && !passwordMatchesFocused(passField)) { + return; + } + pf = passField; passwords.push(pf); if (login.username) { - username = this.findUsernameField(pageDetails, pf, false, false, false); - + username = getUsernameForPassword(pf, false); if (username) { - usernames.push(username); + usernames.set(username.opid, username); } } if (options.allowTotpAutofill && login.totp) { - totp = this.findTotpField(pageDetails, pf, false, false, false); - + totp = + isFocusedTotpField && passwordMatchesFocused(passField) + ? focusedField + : this.findTotpField(pageDetails, pf, false, false, false); if (totp) { totps.push(totp); } @@ -885,25 +972,57 @@ export default class AutofillService implements AutofillServiceInterface { } if (passwordFields.length && !passwords.length) { - // The page does not have any forms with password fields. Use the first password field on the page and the - // input field just before it as the username. + // in the event that password fields exist but weren't processed within form elements. + const isPasswordGeneration = + options.inlineMenuFillType === InlineMenuFillTypes.PasswordGeneration; + const isCurrentPasswordUpdate = + options.inlineMenuFillType === InlineMenuFillTypes.CurrentPasswordUpdate; - pf = passwordFields[0]; - passwords.push(pf); + // For password generation or current password update, include all password fields from the same form + // This ensures we have access to all fields regardless of their login/registration classification + if ((isPasswordGeneration || isCurrentPasswordUpdate) && focusedField) { + // Add all password fields from the same form as the focused field + const focusedFieldForm = focusedField.form; - if (login.username && pf.elementNumber > 0) { - username = this.findUsernameField(pageDetails, pf, false, false, true); + // Check both login and registration fields to ensure we get all password fields + const allPasswordFields = [...loginPasswordFields, ...registrationPasswordFields]; + allPasswordFields.forEach((passField) => { + if (passField.form === focusedFieldForm) { + passwords.push(passField); + } + }); + } - if (username) { - usernames.push(username); + // If we didn't add any passwords above (either not password generation/update or no matching fields), + // select matching password if focused, otherwise first in prioritized list. + if (!passwords.length) { + const passwordFieldToUse = focusedField + ? prioritizedPasswordFields.find(passwordMatchesFocused) || prioritizedPasswordFields[0] + : prioritizedPasswordFields[0]; + + if (passwordFieldToUse) { + passwords.push(passwordFieldToUse); } } - if (options.allowTotpAutofill && login.totp && pf.elementNumber > 0) { - totp = this.findTotpField(pageDetails, pf, false, false, true); + // Handle username and TOTP for the first password field + const firstPasswordField = passwords[0]; + if (firstPasswordField) { + if (login.username && firstPasswordField.elementNumber > 0) { + username = getUsernameForPassword(firstPasswordField, true); + if (username) { + usernames.set(username.opid, username); + } + } - if (totp) { - totps.push(totp); + if (options.allowTotpAutofill && login.totp && firstPasswordField.elementNumber > 0) { + totp = + isFocusedTotpField && passwordMatchesFocused(firstPasswordField) + ? focusedField + : this.findTotpField(pageDetails, firstPasswordField, false, false, true); + if (totp) { + totps.push(totp); + } } } } @@ -937,7 +1056,7 @@ export default class AutofillService implements AutofillServiceInterface { totps.push(field); return; case isFillableUsernameField: - usernames.push(field); + usernames.set(field.opid, field); return; default: return; @@ -946,9 +1065,10 @@ export default class AutofillService implements AutofillServiceInterface { } const formElementsSet = new Set(); - usernames.forEach((u) => { - // eslint-disable-next-line - if (filledFields.hasOwnProperty(u.opid)) { + const usernamesToFill = focusedUsernameField ? [focusedUsernameField] : [...usernames.values()]; + + usernamesToFill.forEach((u) => { + if (Object.prototype.hasOwnProperty.call(filledFields, u.opid)) { return; } @@ -2251,6 +2371,38 @@ export default class AutofillService implements AutofillServiceInterface { return arr; } + /** + * Determines if a password field is part of a registration/signup form. + * @param {AutofillPageDetails} pageDetails + * @param {AutofillField} passwordField + * @returns {boolean} + * @private + */ + private isRegistrationPasswordField( + pageDetails: AutofillPageDetails, + passwordField: AutofillField, + ): boolean { + if (!passwordField.form || !pageDetails.forms) { + return false; + } + + const form = pageDetails.forms[passwordField.form]; + if (!form) { + return false; + } + + const formIdentifierValues = [ + form.htmlID?.toLowerCase?.(), + form.htmlName?.toLowerCase?.(), + passwordField?.htmlID?.toLowerCase?.(), + passwordField?.htmlName?.toLowerCase?.(), + ].filter(Boolean); + + return formIdentifierValues.some((value) => + AutoFillConstants.RegistrationKeywords.some((keyword) => value.includes(keyword)), + ); + } + /** * Accepts a pageDetails object with a list of fields and returns a list of * fields that are likely to be username fields. @@ -2270,6 +2422,8 @@ export default class AutofillService implements AutofillServiceInterface { withoutForm: boolean, ): AutofillField | null { let usernameField: AutofillField = null; + let usernameFieldInSameForm: AutofillField = null; + for (let i = 0; i < pageDetails.fields.length; i++) { const f = pageDetails.fields[i]; if (AutofillService.forCustomFieldsOnly(f)) { @@ -2282,22 +2436,36 @@ export default class AutofillService implements AutofillServiceInterface { const includesUsernameFieldName = this.findMatchingFieldIndex(f, AutoFillConstants.UsernameFieldNames) > -1; + // only consider fields in same form if both have non-null form values + // null forms are treated as separate + const isInSameForm = + f.form != null && passwordField.form != null && f.form === passwordField.form; + + // An email or tel field in the same form as the password field is likely a qualified + // candidate for autofill, even if visibility checks are unreliable + const isQualifiedUsernameField = isInSameForm && (f.type === "email" || f.type === "tel"); if ( !f.disabled && (canBeReadOnly || !f.readonly) && - (withoutForm || f.form === passwordField.form || includesUsernameFieldName) && - (canBeHidden || f.viewable) && + (withoutForm || isInSameForm || includesUsernameFieldName) && + (canBeHidden || f.viewable || isQualifiedUsernameField) && (f.type === "text" || f.type === "email" || f.type === "tel") ) { - usernameField = f; - // We found an exact match. No need to keep looking. - if (includesUsernameFieldName) { - break; + // Prioritize fields in the same form as the password field + if (isInSameForm) { + usernameFieldInSameForm = f; + if (includesUsernameFieldName) { + return f; + } + } else { + usernameField = f; } } } - return usernameField; + + // Prefer username field in same form, fall back to any username field + return usernameFieldInSameForm || usernameField; } /** 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 9ee329fa150..66a692dbe20 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 @@ -395,7 +395,7 @@ describe("CollectAutofillContentService", () => { }); }); - it("sets the noFieldsFound property to true if the page has no forms or fields", async function () { + it("sets the noFieldsFond property to true if the page has no forms or fields", async function () { document.body.innerHTML = ""; collectAutofillContentService["noFieldsFound"] = false; jest.spyOn(collectAutofillContentService as any, "buildAutofillFormsData"); @@ -2649,33 +2649,4 @@ describe("CollectAutofillContentService", () => { ); }); }); - - describe("processMutations", () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); - - it("will require an update to page details if shadow DOM is present", () => { - jest - .spyOn(domQueryService as any, "checkPageContainsShadowDom") - .mockImplementationOnce(() => true); - - collectAutofillContentService["requirePageDetailsUpdate"] = jest.fn(); - - collectAutofillContentService["mutationsQueue"] = [[], []]; - - collectAutofillContentService["processMutations"](); - - jest.runOnlyPendingTimers(); - - expect(domQueryService.checkPageContainsShadowDom).toHaveBeenCalled(); - expect(collectAutofillContentService["mutationsQueue"]).toHaveLength(0); - expect(collectAutofillContentService["requirePageDetailsUpdate"]).toHaveBeenCalled(); - }); - }); }); 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 2ddee289044..6f2c00a4dd4 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -433,7 +433,6 @@ export class CollectAutofillContentService implements CollectAutofillContentServ /** * Caches the autofill field element and its data. - * Will not cache the element if the index is less than 0. * * @param index - The index of the autofill field element * @param element - The autofill field element to cache @@ -444,10 +443,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ element: ElementWithOpId, autofillFieldData: AutofillField, ) { - if (index < 0) { - return; - } - + // Always cache the element, even if index is -1 (for dynamically added fields) this.autofillFieldElements.set(element, autofillFieldData); } @@ -1001,13 +997,6 @@ export class CollectAutofillContentService implements CollectAutofillContentServ * within an idle callback to help with performance and prevent excessive updates. */ private processMutations = () => { - // If the page contains shadow DOM, we require a page details update from the autofill service. - // Will wait for an idle moment on main thread to execute, unless timeout has passed. - requestIdleCallbackPolyfill( - () => this.domQueryService.checkPageContainsShadowDom() && this.requirePageDetailsUpdate(), - { timeout: 500 }, - ); - const queueLength = this.mutationsQueue.length; for (let queueIndex = 0; queueIndex < queueLength; queueIndex++) { @@ -1030,13 +1019,13 @@ export class CollectAutofillContentService implements CollectAutofillContentServ * Triggers several flags that indicate that a collection of page details should * occur again on a subsequent call after a mutation has been observed in the DOM. */ - private requirePageDetailsUpdate = () => { + private flagPageDetailsUpdateIsRequired() { this.domRecentlyMutated = true; if (this.autofillOverlayContentService) { this.autofillOverlayContentService.pageDetailsUpdateRequired = true; } this.noFieldsFound = false; - }; + } /** * Processes all mutation records encountered by the mutation observer. @@ -1064,7 +1053,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ (this.isAutofillElementNodeMutated(mutation.removedNodes, true) || this.isAutofillElementNodeMutated(mutation.addedNodes)) ) { - this.requirePageDetailsUpdate(); + this.flagPageDetailsUpdateIsRequired(); return; } @@ -1196,7 +1185,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ private setupOverlayListenersOnMutatedElements(mutatedElements: Node[]) { for (let elementIndex = 0; elementIndex < mutatedElements.length; elementIndex++) { const node = mutatedElements[elementIndex]; - const buildAutofillFieldItem = () => { + const buildAutofillFieldItem = async () => { if ( !this.isNodeFormFieldElement(node) || this.autofillFieldElements.get(node as ElementWithOpId) @@ -1206,7 +1195,17 @@ export class CollectAutofillContentService implements CollectAutofillContentServ // We are setting this item to a -1 index because we do not know its position in the DOM. // This value should be updated with the next call to collect page details. - void this.buildAutofillFieldItem(node as ElementWithOpId, -1); + const formFieldElement = node as ElementWithOpId; + const autofillField = await this.buildAutofillFieldItem(formFieldElement, -1); + + // Set up overlay listeners for the new field if we have the overlay service + if (autofillField && this.autofillOverlayContentService) { + this.setupOverlayOnField(formFieldElement, autofillField); + + if (this.domRecentlyMutated) { + this.updateAutofillElementsAfterMutation(); + } + } }; requestIdleCallbackPolyfill(buildAutofillFieldItem, { timeout: 1000 }); diff --git a/apps/browser/src/autofill/services/dom-element-visibility.service.ts b/apps/browser/src/autofill/services/dom-element-visibility.service.ts index bd75cb55ba5..21f024a510c 100644 --- a/apps/browser/src/autofill/services/dom-element-visibility.service.ts +++ b/apps/browser/src/autofill/services/dom-element-visibility.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { AutofillInlineMenuContentService } from "../overlay/inline-menu/abstractions/autofill-inline-menu-content.service"; import { FillableFormFieldElement, FormFieldElement } from "../types"; @@ -202,7 +200,7 @@ class DomElementVisibilityService implements DomElementVisibilityServiceInterfac const closestParentLabel = elementAtCenterPoint?.parentElement?.closest("label"); - return targetElementLabelsSet.has(closestParentLabel); + return closestParentLabel ? targetElementLabelsSet.has(closestParentLabel) : false; } } diff --git a/apps/browser/src/autofill/services/dom-query.service.spec.ts b/apps/browser/src/autofill/services/dom-query.service.spec.ts index 87645c98a45..53862aef735 100644 --- a/apps/browser/src/autofill/services/dom-query.service.spec.ts +++ b/apps/browser/src/autofill/services/dom-query.service.spec.ts @@ -72,6 +72,7 @@ describe("DomQueryService", () => { }); it("queries form field elements that are nested within multiple ShadowDOM elements", () => { + domQueryService["pageContainsShadowDom"] = true; const root = document.createElement("div"); const shadowRoot1 = root.attachShadow({ mode: "open" }); const root2 = document.createElement("div"); @@ -94,6 +95,7 @@ describe("DomQueryService", () => { }); it("will fallback to using the TreeWalker API if a depth larger than 4 ShadowDOM elements is encountered", () => { + domQueryService["pageContainsShadowDom"] = true; const root = document.createElement("div"); const shadowRoot1 = root.attachShadow({ mode: "open" }); const root2 = document.createElement("div"); diff --git a/apps/browser/src/autofill/services/dom-query.service.ts b/apps/browser/src/autofill/services/dom-query.service.ts index b681e8e9fbb..932bbe47f90 100644 --- a/apps/browser/src/autofill/services/dom-query.service.ts +++ b/apps/browser/src/autofill/services/dom-query.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { EVENTS, MAX_DEEP_QUERY_RECURSION_DEPTH } from "@bitwarden/common/autofill/constants"; import { nodeIsElement } from "../utils"; @@ -7,7 +5,8 @@ import { nodeIsElement } from "../utils"; import { DomQueryService as DomQueryServiceInterface } from "./abstractions/dom-query.service"; export class DomQueryService implements DomQueryServiceInterface { - private pageContainsShadowDom: boolean; + /** Non-null asserted. */ + private pageContainsShadowDom!: boolean; private ignoredTreeWalkerNodes = new Set([ "svg", "script", @@ -79,9 +78,8 @@ export class DomQueryService implements DomQueryServiceInterface { /** * Checks if the page contains any shadow DOM elements. */ - checkPageContainsShadowDom = (): boolean => { + checkPageContainsShadowDom = (): void => { this.pageContainsShadowDom = this.queryShadowRoots(globalThis.document.body, true).length > 0; - return this.pageContainsShadowDom; }; /** @@ -110,7 +108,7 @@ export class DomQueryService implements DomQueryServiceInterface { ): T[] { let elements = this.queryElements(root, queryString); - const shadowRoots = this.pageContainsShadowDom ? this.recursivelyQueryShadowRoots(root) : []; + const shadowRoots = this.recursivelyQueryShadowRoots(root); for (let index = 0; index < shadowRoots.length; index++) { const shadowRoot = shadowRoots[index]; elements = elements.concat(this.queryElements(shadowRoot, queryString)); @@ -153,6 +151,10 @@ export class DomQueryService implements DomQueryServiceInterface { root: Document | ShadowRoot | Element, depth: number = 0, ): ShadowRoot[] { + if (!this.pageContainsShadowDom) { + return []; + } + if (depth >= MAX_DEEP_QUERY_RECURSION_DEPTH) { throw new Error("Max recursion depth reached"); } @@ -217,13 +219,12 @@ export class DomQueryService implements DomQueryServiceInterface { if ((chrome as any).dom?.openOrClosedShadowRoot) { try { return (chrome as any).dom.openOrClosedShadowRoot(node); - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (error) { + } catch { return null; } } + // Firefox-specific equivalent of `openOrClosedShadowRoot` return (node as any).openOrClosedShadowRoot; } @@ -276,7 +277,7 @@ export class DomQueryService implements DomQueryServiceInterface { ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT, ); - let currentNode = treeWalker?.currentNode; + let currentNode: Node | null = treeWalker?.currentNode; while (currentNode) { if (filterCallback(currentNode)) { diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts index ed8e41df8ba..f7c46a9fa77 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; import { getSubmitButtonKeywordsSet, sendExtensionMessage } from "../utils"; @@ -162,12 +160,14 @@ export class InlineMenuFieldQualificationService private isExplicitIdentityEmailField(field: AutofillField): boolean { const matchFieldAttributeValues = [field.type, field.htmlName, field.htmlID, field.placeholder]; for (let attrIndex = 0; attrIndex < matchFieldAttributeValues.length; attrIndex++) { - if (!matchFieldAttributeValues[attrIndex]) { + const attributeValueToMatch = matchFieldAttributeValues[attrIndex]; + + if (!attributeValueToMatch) { continue; } for (let keywordIndex = 0; keywordIndex < matchFieldAttributeValues.length; keywordIndex++) { - if (this.newEmailFieldKeywords.has(matchFieldAttributeValues[attrIndex])) { + if (this.newEmailFieldKeywords.has(attributeValueToMatch)) { return true; } } @@ -210,10 +210,7 @@ export class InlineMenuFieldQualificationService } constructor() { - void Promise.all([ - sendExtensionMessage("getInlineMenuFieldQualificationFeatureFlag"), - sendExtensionMessage("getUserPremiumStatus"), - ]).then(([fieldQualificationFlag, premiumStatus]) => { + void sendExtensionMessage("getUserPremiumStatus").then((premiumStatus) => { this.premiumEnabled = !!premiumStatus?.result; }); } @@ -263,7 +260,13 @@ export class InlineMenuFieldQualificationService return true; } - const parentForm = pageDetails.forms[field.form]; + let parentForm; + + const fieldForm = field.form; + + if (fieldForm) { + parentForm = pageDetails.forms[fieldForm]; + } // If the field does not have a parent form if (!parentForm) { @@ -321,7 +324,13 @@ export class InlineMenuFieldQualificationService return false; } - const parentForm = pageDetails.forms[field.form]; + let parentForm; + + const fieldForm = field.form; + + if (fieldForm) { + parentForm = pageDetails.forms[fieldForm]; + } if (!parentForm) { // If the field does not have a parent form, but we can identify that the page contains at least @@ -374,7 +383,13 @@ export class InlineMenuFieldQualificationService field: AutofillField, pageDetails: AutofillPageDetails, ): boolean { - const parentForm = pageDetails.forms[field.form]; + let parentForm; + + const fieldForm = field.form; + + if (fieldForm) { + parentForm = pageDetails.forms[fieldForm]; + } // If the provided field is set with an autocomplete value of "current-password", we should assume that // the page developer intends for this field to be interpreted as a password field for a login form. @@ -476,7 +491,13 @@ export class InlineMenuFieldQualificationService // If the field is not explicitly set as a username field, we need to qualify // the field based on the other fields that are present on the page. - const parentForm = pageDetails.forms[field.form]; + let parentForm; + + const fieldForm = field.form; + + if (fieldForm) { + parentForm = pageDetails.forms[fieldForm]; + } const passwordFieldsInPageDetails = pageDetails.fields.filter(this.isCurrentPasswordField); if (this.isNewsletterForm(parentForm)) { @@ -919,8 +940,10 @@ export class InlineMenuFieldQualificationService * @param field - The field to validate */ isUsernameField = (field: AutofillField): boolean => { + const fieldType = field.type; if ( - !this.usernameFieldTypes.has(field.type) || + !fieldType || + !this.usernameFieldTypes.has(fieldType) || this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet) || this.fieldHasDisqualifyingAttributeValue(field) ) { @@ -1026,7 +1049,13 @@ export class InlineMenuFieldQualificationService const testedValues = [field.htmlID, field.htmlName, field.placeholder]; for (let i = 0; i < testedValues.length; i++) { - if (this.valueIsLikePassword(testedValues[i])) { + const attributeValueToMatch = testedValues[i]; + + if (!attributeValueToMatch) { + continue; + } + + if (this.valueIsLikePassword(attributeValueToMatch)) { return true; } } @@ -1101,7 +1130,9 @@ export class InlineMenuFieldQualificationService * @param excludedTypes - The set of excluded types */ private isExcludedFieldType(field: AutofillField, excludedTypes: Set): boolean { - if (excludedTypes.has(field.type)) { + const fieldType = field.type; + + if (fieldType && excludedTypes.has(fieldType)) { return true; } @@ -1116,12 +1147,14 @@ export class InlineMenuFieldQualificationService private isSearchField(field: AutofillField): boolean { const matchFieldAttributeValues = [field.type, field.htmlName, field.htmlID, field.placeholder]; for (let attrIndex = 0; attrIndex < matchFieldAttributeValues.length; attrIndex++) { - if (!matchFieldAttributeValues[attrIndex]) { + const attributeValueToMatch = matchFieldAttributeValues[attrIndex]; + + if (!attributeValueToMatch) { continue; } // Separate camel case words and case them to lower case values - const camelCaseSeparatedFieldAttribute = matchFieldAttributeValues[attrIndex] + const camelCaseSeparatedFieldAttribute = attributeValueToMatch .replace(/([a-z])([A-Z])/g, "$1 $2") .toLowerCase(); // Split the attribute by non-alphabetical characters to get the keywords @@ -1168,7 +1201,7 @@ export class InlineMenuFieldQualificationService this.submitButtonKeywordsMap.set(element, Array.from(keywordsSet).join(",")); } - return this.submitButtonKeywordsMap.get(element); + return this.submitButtonKeywordsMap.get(element) || ""; } /** @@ -1222,8 +1255,9 @@ export class InlineMenuFieldQualificationService ]; const keywordsSet = new Set(); for (let i = 0; i < keywords.length; i++) { - if (keywords[i] && typeof keywords[i] === "string") { - let keywordEl = keywords[i].toLowerCase(); + const attributeValue = keywords[i]; + if (attributeValue && typeof attributeValue === "string") { + let keywordEl = attributeValue.toLowerCase(); keywordsSet.add(keywordEl); // Remove hyphens from all potential keywords, we want to treat these as a single word. @@ -1253,7 +1287,7 @@ export class InlineMenuFieldQualificationService } const mapValues = this.autofillFieldKeywordsMap.get(autofillFieldData); - return returnStringValue ? mapValues.stringValue : mapValues.keywordsSet; + return mapValues ? (returnStringValue ? mapValues.stringValue : mapValues.keywordsSet) : ""; } /** diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts index 9edcdbb3a95..1f2b23021f4 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts @@ -2,7 +2,7 @@ import { mock } from "jest-mock-extended"; import { EVENTS } from "@bitwarden/common/autofill/constants"; -import AutofillScript, { FillScript, FillScriptActions } from "../models/autofill-script"; +import AutofillScript, { FillScript, FillScriptActionTypes } from "../models/autofill-script"; import { mockQuerySelectorAllDefinedCall } from "../spec/testing-utils"; import { FillableFormFieldElement, FormElementWithAttribute, FormFieldElement } from "../types"; @@ -26,7 +26,6 @@ const eventsToTest = [ EVENTS.CHANGE, EVENTS.INPUT, EVENTS.KEYDOWN, - EVENTS.KEYPRESS, EVENTS.KEYUP, "blur", "click", @@ -95,15 +94,14 @@ describe("InsertAutofillContentService", () => { ); fillScript = { script: [ - ["click_on_opid", "username"], - ["focus_by_opid", "username"], - ["fill_by_opid", "username", "test"], + [FillScriptActionTypes.click_on_opid, "username"], + [FillScriptActionTypes.focus_by_opid, "username"], + [FillScriptActionTypes.fill_by_opid, "username", "test"], ], properties: { delay_between_operations: 20, }, - metadata: {}, - autosubmit: null, + autosubmit: [], savedUrls: ["https://bitwarden.com"], untrustedIframe: false, itemType: "login", @@ -218,28 +216,18 @@ describe("InsertAutofillContentService", () => { await insertAutofillContentService.fillForm(fillScript); - expect(insertAutofillContentService["userCancelledInsecureUrlAutofill"]).toHaveBeenCalled(); - expect( - insertAutofillContentService["userCancelledUntrustedIframeAutofill"], - ).toHaveBeenCalled(); expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenCalledTimes(3); expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith( 1, fillScript.script[0], - 0, - fillScript.script, ); expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith( 2, fillScript.script[1], - 1, - fillScript.script, ); expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith( 3, fillScript.script[2], - 2, - fillScript.script, ); }); }); @@ -384,42 +372,62 @@ describe("InsertAutofillContentService", () => { }); it("returns early if no opid is provided", async () => { - const action = "fill_by_opid"; + const action = FillScriptActionTypes.fill_by_opid; const opid = ""; const value = "value"; const scriptAction: FillScript = [action, opid, value]; jest.spyOn(insertAutofillContentService["autofillInsertActions"], action); - await insertAutofillContentService["runFillScriptAction"](scriptAction, 0); + await insertAutofillContentService["runFillScriptAction"](scriptAction); jest.advanceTimersByTime(20); expect(insertAutofillContentService["autofillInsertActions"][action]).not.toHaveBeenCalled(); }); describe("given a valid fill script action and opid", () => { - const fillScriptActions: FillScriptActions[] = [ - "fill_by_opid", - "click_on_opid", - "focus_by_opid", - ]; - fillScriptActions.forEach((action) => { - it(`triggers a ${action} action`, () => { - const opid = "opid"; - const value = "value"; - const scriptAction: FillScript = [action, opid, value]; - jest.spyOn(insertAutofillContentService["autofillInsertActions"], action); + it(`triggers a fill_by_opid action`, () => { + const action = FillScriptActionTypes.fill_by_opid; + const opid = "opid"; + const value = "value"; + const scriptAction: FillScript = [action, opid, value]; + jest.spyOn(insertAutofillContentService["autofillInsertActions"], action); - // 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 - insertAutofillContentService["runFillScriptAction"](scriptAction, 0); - jest.advanceTimersByTime(20); + void insertAutofillContentService["runFillScriptAction"](scriptAction); + jest.advanceTimersByTime(20); - expect( - insertAutofillContentService["autofillInsertActions"][action], - ).toHaveBeenCalledWith({ - opid, - value, - }); + expect(insertAutofillContentService["autofillInsertActions"][action]).toHaveBeenCalledWith({ + opid, + value, + }); + }); + + it(`triggers a click_on_opid action`, () => { + const action = FillScriptActionTypes.click_on_opid; + const opid = "opid"; + const value = "value"; + const scriptAction: FillScript = [action, opid, value]; + jest.spyOn(insertAutofillContentService["autofillInsertActions"], action); + + void insertAutofillContentService["runFillScriptAction"](scriptAction); + jest.advanceTimersByTime(20); + + expect(insertAutofillContentService["autofillInsertActions"][action]).toHaveBeenCalledWith({ + opid, + }); + }); + + it(`triggers a focus_by_opid action`, () => { + const action = FillScriptActionTypes.focus_by_opid; + const opid = "opid"; + const value = "value"; + const scriptAction: FillScript = [action, opid, value]; + jest.spyOn(insertAutofillContentService["autofillInsertActions"], action); + + void insertAutofillContentService["runFillScriptAction"](scriptAction); + jest.advanceTimersByTime(20); + + expect(insertAutofillContentService["autofillInsertActions"][action]).toHaveBeenCalledWith({ + opid, }); }); }); @@ -623,14 +631,12 @@ describe("InsertAutofillContentService", () => { }); }); - it("will set the `value` attribute of any passed input or textarea elements", () => { - document.body.innerHTML = ``; + it("will set the `value` attribute of any passed input or textarea elements if the value differs", () => { + document.body.innerHTML = ``; const value1 = "test"; const value2 = "test2"; const textInputElement = document.getElementById("username") as HTMLInputElement; - textInputElement.value = value1; const textareaElement = document.getElementById("bio") as HTMLTextAreaElement; - textareaElement.value = value2; jest.spyOn(insertAutofillContentService as any, "handleInsertValueAndTriggerSimulatedEvents"); insertAutofillContentService["insertValueIntoField"](textInputElement, value1); @@ -647,6 +653,45 @@ describe("InsertAutofillContentService", () => { insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"], ).toHaveBeenCalledWith(textareaElement, expect.any(Function)); }); + + it("will NOT set the `value` attribute of any passed input or textarea elements if they already have values matching the passed value", () => { + document.body.innerHTML = ``; + const value1 = "test"; + const value2 = "test2"; + const textInputElement = document.getElementById("username") as HTMLInputElement; + textInputElement.value = value1; + const textareaElement = document.getElementById("bio") as HTMLTextAreaElement; + textareaElement.value = value2; + jest.spyOn(insertAutofillContentService as any, "handleInsertValueAndTriggerSimulatedEvents"); + + insertAutofillContentService["insertValueIntoField"](textInputElement, value1); + + expect(textInputElement.value).toBe(value1); + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"], + ).not.toHaveBeenCalled(); + + insertAutofillContentService["insertValueIntoField"](textareaElement, value2); + + expect(textareaElement.value).toBe(value2); + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"], + ).not.toHaveBeenCalled(); + }); + + it("skips filling when the field already has the target value", () => { + const value = "test"; + document.body.innerHTML = ``; + const element = document.getElementById("username") as FillableFormFieldElement; + jest.spyOn(insertAutofillContentService as any, "handleInsertValueAndTriggerSimulatedEvents"); + + insertAutofillContentService["insertValueIntoField"](element, value); + + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"], + ).not.toHaveBeenCalled(); + expect(element.value).toBe(value); + }); }); describe("handleInsertValueAndTriggerSimulatedEvents", () => { @@ -1014,13 +1059,13 @@ describe("InsertAutofillContentService", () => { }); describe("simulateUserKeyboardEventInteractions", () => { - it("will trigger `keydown`, `keypress`, and `keyup` events on the passed element", () => { + it("will trigger `keydown` and `keyup` events on the passed element", () => { const inputElement = document.querySelector('input[type="text"]') as HTMLInputElement; jest.spyOn(inputElement, "dispatchEvent"); insertAutofillContentService["simulateUserKeyboardEventInteractions"](inputElement); - [EVENTS.KEYDOWN, EVENTS.KEYPRESS, EVENTS.KEYUP].forEach((eventName) => { + [EVENTS.KEYDOWN, EVENTS.KEYUP].forEach((eventName) => { expect(inputElement.dispatchEvent).toHaveBeenCalledWith( new KeyboardEvent(eventName, { bubbles: true }), ); diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.ts index 6034563a947..4b7f699fecb 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.ts @@ -1,8 +1,10 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { EVENTS, TYPE_CHECK } from "@bitwarden/common/autofill/constants"; -import AutofillScript, { AutofillInsertActions, FillScript } from "../models/autofill-script"; +import AutofillScript, { + AutofillInsertActions, + FillScript, + FillScriptActionTypes, +} from "../models/autofill-script"; import { FormFieldElement } from "../types"; import { currentlyInSandboxedIframe, @@ -49,8 +51,9 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf return; } - const fillActionPromises = fillScript.script.map(this.runFillScriptAction); - await Promise.all(fillActionPromises); + for (let index = 0; index < fillScript.script.length; index++) { + await this.runFillScriptAction(fillScript.script[index]); + } } /** @@ -115,27 +118,28 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf /** * Runs the autofill action based on the action type and the opid. * Each action is subsequently delayed by 20 milliseconds. - * @param {"click_on_opid" | "focus_by_opid" | "fill_by_opid"} action - * @param {string} opid - * @param {string} value - * @param {number} actionIndex + * @param {FillScript} [action, opid, value] * @returns {Promise} * @private */ - private runFillScriptAction = ( - [action, opid, value]: FillScript, - actionIndex: number, - ): Promise => { + private runFillScriptAction = ([action, opid, value]: FillScript): Promise => { if (!opid || !this.autofillInsertActions[action]) { - return; + return Promise.resolve(); } const delayActionsInMilliseconds = 20; return new Promise((resolve) => setTimeout(() => { - this.autofillInsertActions[action]({ opid, value }); + if (action === FillScriptActionTypes.fill_by_opid && !!value?.length) { + this.autofillInsertActions.fill_by_opid({ opid, value }); + } else if (action === FillScriptActionTypes.click_on_opid) { + this.autofillInsertActions.click_on_opid({ opid }); + } else if (action === FillScriptActionTypes.focus_by_opid) { + this.autofillInsertActions.focus_by_opid({ opid }); + } + resolve(); - }, delayActionsInMilliseconds * actionIndex), + }, delayActionsInMilliseconds), ); }; @@ -157,7 +161,10 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf */ private handleClickOnFieldByOpidAction(opid: string) { const element = this.collectAutofillContentService.getAutofillFieldElementByOpid(opid); - this.triggerClickOnElement(element); + + if (element) { + this.triggerClickOnElement(element); + } } /** @@ -170,6 +177,10 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf private handleFocusOnFieldByOpidAction(opid: string) { const element = this.collectAutofillContentService.getAutofillFieldElementByOpid(opid); + if (!element) { + return; + } + if (document.activeElement === element) { element.blur(); } @@ -186,13 +197,19 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf * @private */ private insertValueIntoField(element: FormFieldElement | null, value: string) { + if (!element || !value) { + return; + } + const elementCanBeReadonly = elementIsInputElement(element) || elementIsTextAreaElement(element); const elementCanBeFilled = elementCanBeReadonly || elementIsSelectElement(element); + const elementValue = (element as HTMLInputElement)?.value || element?.innerText || ""; + + const elementAlreadyHasTheValue = !!(elementValue?.length && elementValue === value); if ( - !element || - !value || + elementAlreadyHasTheValue || (elementCanBeReadonly && element.readOnly) || (elementCanBeFilled && element.disabled) ) { @@ -293,7 +310,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf * @private */ private triggerClickOnElement(element?: HTMLElement): void { - if (typeof element?.click !== TYPE_CHECK.FUNCTION) { + if (!element || typeof element.click !== TYPE_CHECK.FUNCTION) { return; } @@ -308,7 +325,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf * @private */ private triggerFocusOnElement(element: HTMLElement | undefined, shouldResetValue = false): void { - if (typeof element?.focus !== TYPE_CHECK.FUNCTION) { + if (!element || typeof element.focus !== TYPE_CHECK.FUNCTION) { return; } @@ -344,7 +361,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf * @private */ private simulateUserKeyboardEventInteractions(element: FormFieldElement): void { - const simulatedKeyboardEvents = [EVENTS.KEYDOWN, EVENTS.KEYPRESS, EVENTS.KEYUP]; + const simulatedKeyboardEvents = [EVENTS.KEYDOWN, EVENTS.KEYUP]; for (let index = 0; index < simulatedKeyboardEvents.length; index++) { element.dispatchEvent(new KeyboardEvent(simulatedKeyboardEvents[index], { bubbles: true })); } diff --git a/apps/browser/src/autofill/shared/styles/variables.scss b/apps/browser/src/autofill/shared/styles/variables.scss index 1e804ed8fd2..f356eb86f3a 100644 --- a/apps/browser/src/autofill/shared/styles/variables.scss +++ b/apps/browser/src/autofill/shared/styles/variables.scss @@ -1,6 +1,6 @@ $dark-icon-themes: "theme_dark"; -$font-family-sans-serif: Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif; +$font-family-sans-serif: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; $font-family-source-code-pro: "Source Code Pro", monospace; $font-size-base: 14px; diff --git a/apps/browser/src/autofill/spec/autofill-mocks.ts b/apps/browser/src/autofill/spec/autofill-mocks.ts index d1e127227c6..423ba3dd0fe 100644 --- a/apps/browser/src/autofill/spec/autofill-mocks.ts +++ b/apps/browser/src/autofill/spec/autofill-mocks.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { mock } from "jest-mock-extended"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -144,7 +142,6 @@ export function createAutofillScriptMock( return { autosubmit: null, - metadata: {}, properties: { delay_between_operations: 20, }, @@ -178,6 +175,7 @@ export function createInitAutofillInlineMenuButtonMessageMock( styleSheetUrl: "https://jest-testing-website.com", authStatus: AuthenticationStatus.Unlocked, portKey: "portKey", + token: "test-token", ...customFields, }; } @@ -215,6 +213,7 @@ export function createInitAutofillInlineMenuListMessageMock( theme: ThemeTypes.Light, authStatus: AuthenticationStatus.Unlocked, portKey: "portKey", + token: "test-token", inlineMenuFillType: CipherType.Login, ciphers: [ createAutofillOverlayCipherDataMock(1, { @@ -299,7 +298,7 @@ export function createMutationRecordMock(customFields = {}): MutationRecord { oldValue: "default-oldValue", previousSibling: null, removedNodes: mock(), - target: null, + target: mock(), type: "attributes", ...customFields, }; diff --git a/apps/browser/src/autofill/spec/testing-utils.ts b/apps/browser/src/autofill/spec/testing-utils.ts index 0082f022fb6..20416413d25 100644 --- a/apps/browser/src/autofill/spec/testing-utils.ts +++ b/apps/browser/src/autofill/spec/testing-utils.ts @@ -1,5 +1,7 @@ import { mock } from "jest-mock-extended"; +import { BrowserApi } from "../../platform/browser/browser-api"; + export function triggerTestFailure() { expect(true).toBe("Test has failed."); } @@ -11,7 +13,11 @@ export function flushPromises() { }); } -export function postWindowMessage(data: any, origin = "https://localhost/", source = window) { +export function postWindowMessage( + data: any, + origin: string = BrowserApi.getRuntimeURL("")?.slice(0, -1), + source: Window | MessageEventSource | null = window, +) { globalThis.dispatchEvent(new MessageEvent("message", { data, origin, source })); } diff --git a/apps/browser/src/background/commands.background.ts b/apps/browser/src/background/commands.background.ts index 3e6e86cd3d7..696fd5c4f05 100644 --- a/apps/browser/src/background/commands.background.ts +++ b/apps/browser/src/background/commands.background.ts @@ -1,9 +1,13 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { firstValueFrom } from "rxjs"; + +import { LockService } from "@bitwarden/auth/common"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ExtensionCommand, ExtensionCommandType } from "@bitwarden/common/autofill/constants"; -import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; // FIXME (PM-22628): Popup imports are forbidden in background @@ -21,9 +25,10 @@ export default class CommandsBackground { constructor( private main: MainBackground, private platformUtilsService: PlatformUtilsService, - private vaultTimeoutService: VaultTimeoutService, private authService: AuthService, private generatePasswordToClipboard: () => Promise, + private accountService: AccountService, + private lockService: LockService, ) { this.isSafari = this.platformUtilsService.isSafari(); this.isVivaldi = this.platformUtilsService.isVivaldi(); @@ -72,9 +77,11 @@ export default class CommandsBackground { case "open_popup": await this.openPopup(); break; - case "lock_vault": - await this.vaultTimeoutService.lock(); + case "lock_vault": { + const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + await this.lockService.lock(activeUserId); break; + } default: break; } diff --git a/apps/browser/src/background/idle.background.ts b/apps/browser/src/background/idle.background.ts index 2de4b48a9c0..66a5604a8ba 100644 --- a/apps/browser/src/background/idle.background.ts +++ b/apps/browser/src/background/idle.background.ts @@ -1,5 +1,6 @@ import { firstValueFrom } from "rxjs"; +import { LockService, LogoutService } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { VaultTimeoutAction, @@ -8,6 +9,7 @@ import { VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications"; +import { UserId } from "@bitwarden/user-core"; const IdleInterval = 60 * 5; // 5 minutes @@ -21,6 +23,8 @@ export default class IdleBackground { private serverNotificationsService: ServerNotificationsService, private accountService: AccountService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, + private lockService: LockService, + private logoutService: LogoutService, ) { this.idle = chrome.idle || (browser != null ? browser.idle : null); } @@ -61,9 +65,9 @@ export default class IdleBackground { this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(userId), ); if (action === VaultTimeoutAction.LogOut) { - await this.vaultTimeoutService.logOut(userId); + await this.logoutService.logout(userId as UserId, "vaultTimeout"); } else { - await this.vaultTimeoutService.lock(userId); + await this.lockService.lock(userId as UserId); } } } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 0820f605a0a..78b5e323798 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -20,8 +20,9 @@ import { AuthRequestService, AuthRequestServiceAbstraction, DefaultAuthRequestApiService, - DefaultLockService, + DefaultLogoutService, InternalUserDecryptionOptionsServiceAbstraction, + LockService, LoginEmailServiceAbstraction, LogoutReason, UserDecryptionOptionsService, @@ -98,8 +99,11 @@ import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarde import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; +import { PinStateService } from "@bitwarden/common/key-management/pin/pin-state.service.implementation"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service"; import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service"; import { DefaultVaultTimeoutSettingsService, @@ -127,7 +131,7 @@ import { } from "@bitwarden/common/platform/abstractions/storage.service"; import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/platform/abstractions/system.service"; import { ActionsService } from "@bitwarden/common/platform/actions/actions-service"; -import { IpcService } from "@bitwarden/common/platform/ipc"; +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"; @@ -219,8 +223,10 @@ import { UsernameGenerationServiceAbstraction, } from "@bitwarden/generator-legacy"; import { + DefaultImportMetadataService, ImportApiService, ImportApiServiceAbstraction, + ImportMetadataServiceAbstraction, ImportService, ImportServiceAbstraction, } from "@bitwarden/importer-core"; @@ -264,6 +270,7 @@ import { } from "@bitwarden/vault-export-core"; import { AuthStatusBadgeUpdaterService } from "../auth/services/auth-status-badge-updater.service"; +import { ExtensionLockService } from "../auth/services/extension-lock.service"; import { OverlayNotificationsBackground as OverlayNotificationsBackgroundInterface } from "../autofill/background/abstractions/overlay-notifications.background"; import { OverlayBackground as OverlayBackgroundInterface } from "../autofill/background/abstractions/overlay.background"; import { AutoSubmitLoginBackground } from "../autofill/background/auto-submit-login.background"; @@ -287,6 +294,7 @@ import { AutofillBadgeUpdaterService } from "../autofill/services/autofill-badge import AutofillService from "../autofill/services/autofill.service"; import { InlineMenuFieldQualificationService } from "../autofill/services/inline-menu-field-qualification.service"; import { SafariApp } from "../browser/safariApp"; +import { PhishingDataService } from "../dirt/phishing-detection/services/phishing-data.service"; import { PhishingDetectionService } from "../dirt/phishing-detection/services/phishing-detection.service"; import { BackgroundBrowserBiometricsService } from "../key-management/biometrics/background-browser-biometrics.service"; import VaultTimeoutService from "../key-management/vault-timeout/vault-timeout.service"; @@ -294,6 +302,7 @@ import { BrowserActionsService } from "../platform/actions/browser-actions.servi import { DefaultBadgeBrowserApi } from "../platform/badge/badge-browser-api"; import { BadgeService } from "../platform/badge/badge.service"; import { BrowserApi } from "../platform/browser/browser-api"; +import BrowserPopupUtils from "../platform/browser/browser-popup-utils"; import { flagEnabled } from "../platform/flags"; import { IpcBackgroundService } from "../platform/ipc/ipc-background.service"; import { IpcContentScriptManagerService } from "../platform/ipc/ipc-content-script-manager.service"; @@ -312,6 +321,7 @@ import I18nService from "../platform/services/i18n.service"; import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service"; import { BackgroundPlatformUtilsService } from "../platform/services/platform-utils/background-platform-utils.service"; import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service"; +import { PopupRouterCacheBackgroundService } from "../platform/services/popup-router-cache-background.service"; import { PopupViewCacheBackgroundService } from "../platform/services/popup-view-cache-background.service"; import { BrowserSdkLoadService } from "../platform/services/sdk/browser-sdk-load.service"; import { BackgroundTaskSchedulerService } from "../platform/services/task-scheduler/background-task-scheduler.service"; @@ -354,6 +364,7 @@ export default class MainBackground { folderService: InternalFolderServiceAbstraction; userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction; collectionService: CollectionService; + lockService: LockService; vaultTimeoutService?: VaultTimeoutService; vaultTimeoutSettingsService: VaultTimeoutSettingsService; passwordGenerationService: PasswordGenerationServiceAbstraction; @@ -366,6 +377,7 @@ export default class MainBackground { authService: AuthServiceAbstraction; loginEmailService: LoginEmailServiceAbstraction; importApiService: ImportApiServiceAbstraction; + importMetadataService: ImportMetadataServiceAbstraction; importService: ImportServiceAbstraction; exportApiService: VaultExportApiService; exportService: VaultExportServiceAbstraction; @@ -452,6 +464,7 @@ export default class MainBackground { taskService: TaskService; cipherEncryptionService: CipherEncryptionService; private restrictedItemTypesService: RestrictedItemTypesService; + private securityStateService: SecurityStateService; ipcContentScriptManagerService: IpcContentScriptManagerService; ipcService: IpcService; @@ -479,18 +492,12 @@ export default class MainBackground { private nativeMessagingBackground: NativeMessagingBackground; private popupViewCacheBackgroundService: PopupViewCacheBackgroundService; + private popupRouterCacheBackgroundService: PopupRouterCacheBackgroundService; + + // DIRT + private phishingDataService: PhishingDataService; constructor() { - // Services - const lockedCallback = async (userId: UserId) => { - await this.refreshMenu(true); - if (this.systemService != null) { - await this.systemService.clearPendingClipboard(); - await this.biometricsService.setShouldAutopromptNow(false); - await this.processReloadService.startProcessReload(this.authService); - } - }; - const logoutCallback = async (logoutReason: LogoutReason, userId?: UserId) => await this.logout(logoutReason, userId); @@ -541,7 +548,7 @@ export default class MainBackground { this.memoryStorageForStateProviders = new BrowserMemoryStorageService(); // mv3 stores to storage.session this.memoryStorageService = this.memoryStorageForStateProviders; } else { - this.memoryStorageForStateProviders = new BackgroundMemoryStorageService(); // mv2 stores to memory + this.memoryStorageForStateProviders = new BackgroundMemoryStorageService(this.logService); // mv2 stores to memory this.memoryStorageService = this.memoryStorageForStateProviders; } @@ -668,11 +675,16 @@ export default class MainBackground { logoutCallback, ); + this.securityStateService = new DefaultSecurityStateService(this.stateProvider); + this.popupViewCacheBackgroundService = new PopupViewCacheBackgroundService( messageListener, this.globalStateProvider, this.taskSchedulerService, ); + this.popupRouterCacheBackgroundService = new PopupRouterCacheBackgroundService( + this.globalStateProvider, + ); this.migrationRunner = new MigrationRunner( this.storageService, @@ -689,9 +701,7 @@ export default class MainBackground { this.masterPasswordService = new MasterPasswordService( this.stateProvider, - this.stateService, this.keyGenerationService, - this.encryptService, this.logService, this.cryptoFunctionService, this.accountService, @@ -701,18 +711,7 @@ export default class MainBackground { this.kdfConfigService = new DefaultKdfConfigService(this.stateProvider); - this.pinService = new PinService( - this.accountService, - this.cryptoFunctionService, - this.encryptService, - this.kdfConfigService, - this.keyGenerationService, - this.logService, - this.stateProvider, - ); - this.keyService = new DefaultKeyService( - this.pinService, this.masterPasswordService, this.keyGenerationService, this.cryptoFunctionService, @@ -725,15 +724,19 @@ export default class MainBackground { this.kdfConfigService, ); + const pinStateService = new PinStateService(this.stateProvider); + this.appIdService = new AppIdService(this.storageService, this.logService); - this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider); + this.userDecryptionOptionsService = new UserDecryptionOptionsService( + this.singleUserStateProvider, + ); this.organizationService = new DefaultOrganizationService(this.stateProvider); this.policyService = new DefaultPolicyService(this.stateProvider, this.organizationService); this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService( this.accountService, - this.pinService, + pinStateService, this.userDecryptionOptionsService, this.keyService, this.tokenService, @@ -744,15 +747,6 @@ export default class MainBackground { VaultTimeoutStringType.OnRestart, // default vault timeout ); - this.biometricsService = new BackgroundBrowserBiometricsService( - runtimeNativeMessagingBackground, - this.logService, - this.keyService, - this.biometricStateService, - this.messagingService, - this.vaultTimeoutSettingsService, - ); - this.apiService = new ApiService( this.tokenService, this.platformUtilsService, @@ -830,10 +824,33 @@ export default class MainBackground { this.accountService, this.kdfConfigService, this.keyService, + this.securityStateService, + this.apiService, this.stateProvider, this.configService, ); + this.pinService = new PinService( + this.accountService, + this.encryptService, + this.kdfConfigService, + this.keyGenerationService, + this.logService, + this.keyService, + this.sdkService, + pinStateService, + ); + + this.biometricsService = new BackgroundBrowserBiometricsService( + runtimeNativeMessagingBackground, + this.logService, + this.keyService, + this.biometricStateService, + this.messagingService, + this.vaultTimeoutSettingsService, + this.pinService, + ); + this.passwordStrengthService = new PasswordStrengthService(); this.passwordGenerationService = legacyPasswordGenerationServiceFactory( @@ -844,8 +861,6 @@ export default class MainBackground { this.stateProvider, ); - this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider); - this.devicesApiService = new DevicesApiServiceImplementation(this.apiService); this.deviceTrustService = new DeviceTrustService( this.keyGenerationService, @@ -861,6 +876,7 @@ export default class MainBackground { this.userDecryptionOptionsService, this.logService, this.configService, + this.accountService, ); this.devicesService = new DevicesServiceImplementation( @@ -879,6 +895,7 @@ export default class MainBackground { this.apiService, this.stateProvider, this.authRequestApiService, + this.accountService, ); this.billingAccountProfileStateService = new DefaultBillingAccountProfileStateService( @@ -908,7 +925,11 @@ export default class MainBackground { this.userVerificationApiService = new UserVerificationApiService(this.apiService); - this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); + this.domainSettingsService = new DefaultDomainSettingsService( + this.stateProvider, + this.policyService, + this.accountService, + ); this.themeStateService = new DefaultThemeStateService(this.globalStateProvider); @@ -959,30 +980,11 @@ export default class MainBackground { this.restrictedItemTypesService, ); - this.vaultTimeoutService = new VaultTimeoutService( - this.accountService, - this.masterPasswordService, - this.cipherService, - this.folderService, - this.collectionService, - this.platformUtilsService, - this.messagingService, - this.searchService, - this.stateService, - this.tokenService, - this.authService, - this.vaultTimeoutSettingsService, - this.stateEventRunnerService, - this.taskSchedulerService, - this.logService, - this.biometricsService, - lockedCallback, - logoutCallback, - ); this.containerService = new ContainerService(this.keyService, this.encryptService); this.sendStateProvider = new SendStateProvider(this.stateProvider); this.sendService = new SendService( + this.accountService, this.keyService, this.i18nService, this.keyGenerationService, @@ -998,7 +1000,6 @@ export default class MainBackground { this.avatarService = new AvatarService(this.apiService, this.stateProvider); this.providerService = new ProviderService(this.stateProvider); - this.syncService = new DefaultSyncService( this.masterPasswordService, this.accountService, @@ -1024,6 +1025,8 @@ export default class MainBackground { this.tokenService, this.authService, this.stateProvider, + this.securityStateService, + this.kdfConfigService, ); this.syncServiceListener = new SyncServiceListener( @@ -1079,6 +1082,18 @@ export default class MainBackground { this.importApiService = new ImportApiService(this.apiService); + this.importMetadataService = new DefaultImportMetadataService( + createSystemServiceProvider( + new KeyServiceLegacyEncryptorProvider(this.encryptService, this.keyService), + this.stateProvider, + this.policyService, + buildExtensionRegistry(), + this.logService, + this.platformUtilsService, + this.configService, + ), + ); + this.importService = new ImportService( this.cipherService, this.folderService, @@ -1090,15 +1105,6 @@ export default class MainBackground { this.pinService, this.accountService, this.restrictedItemTypesService, - createSystemServiceProvider( - new KeyServiceLegacyEncryptorProvider(this.encryptService, this.keyService), - this.stateProvider, - this.policyService, - buildExtensionRegistry(), - this.logService, - this.platformUtilsService, - this.configService, - ), ); this.individualVaultExportService = new IndividualVaultExportService( @@ -1185,7 +1191,7 @@ export default class MainBackground { logoutCallback, this.messagingService, this.accountService, - new SignalRConnectionService(this.apiService, this.logService), + new SignalRConnectionService(this.apiService, this.logService, this.platformUtilsService), this.authService, this.webPushConnectionService, this.authRequestAnsweringService, @@ -1214,6 +1220,12 @@ export default class MainBackground { const systemUtilsServiceReloadCallback = async () => { await this.taskSchedulerService.clearAllScheduledTasks(); + + // Close browser action popup before reloading to prevent zombie popup with invalidated context. + // The 'reloadProcess' message is sent by ProcessReloadService before this callback runs, + // and popups will close themselves upon receiving it. Poll to verify popup is actually closed. + await BrowserPopupUtils.waitForAllPopupsClose(); + BrowserApi.reloadExtension(); }; @@ -1231,6 +1243,7 @@ export default class MainBackground { this.biometricStateService, this.accountService, this.logService, + this.authService, ); // Background @@ -1244,7 +1257,36 @@ export default class MainBackground { this.authService, ); - const lockService = new DefaultLockService(this.accountService, this.vaultTimeoutService); + const logoutService = new DefaultLogoutService(this.messagingService); + this.lockService = new ExtensionLockService( + this.accountService, + this.biometricsService, + this.vaultTimeoutSettingsService, + logoutService, + this.messagingService, + this.searchService, + this.folderService, + this.masterPasswordService, + this.stateEventRunnerService, + this.cipherService, + this.authService, + this.systemService, + this.processReloadService, + this.logService, + this.keyService, + this, + ); + + this.vaultTimeoutService = new VaultTimeoutService( + this.accountService, + this.platformUtilsService, + this.authService, + this.vaultTimeoutSettingsService, + this.taskSchedulerService, + this.logService, + this.lockService, + logoutService, + ); this.runtimeBackground = new RuntimeBackground( this, @@ -1258,7 +1300,7 @@ export default class MainBackground { this.configService, messageListener, this.accountService, - lockService, + this.lockService, this.billingAccountProfileStateService, this.browserInitialInstallService, ); @@ -1278,9 +1320,10 @@ export default class MainBackground { this.commandsBackground = new CommandsBackground( this, this.platformUtilsService, - this.vaultTimeoutService, this.authService, () => this.generatePasswordToClipboard(), + this.accountService, + this.lockService, ); this.taskService = new DefaultTaskService( @@ -1365,6 +1408,8 @@ export default class MainBackground { this.serverNotificationsService, this.accountService, this.vaultTimeoutSettingsService, + this.lockService, + logoutService, ); this.usernameGenerationService = legacyUsernameGenerationServiceFactory( @@ -1414,17 +1459,30 @@ export default class MainBackground { this.inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); - PhishingDetectionService.initialize( - this.configService, - this.auditService, - this.logService, - this.storageService, + this.phishingDataService = new PhishingDataService( + this.apiService, this.taskSchedulerService, - this.eventCollectionService, + this.globalStateProvider, + this.logService, + this.platformUtilsService, + ); + + PhishingDetectionService.initialize( + this.accountService, + this.billingAccountProfileStateService, + this.configService, + this.logService, + this.phishingDataService, + messageListener, ); this.ipcContentScriptManagerService = new IpcContentScriptManagerService(this.configService); - this.ipcService = new IpcBackgroundService(this.platformUtilsService, this.logService); + const ipcSessionRepository = new IpcSessionRepository(this.stateProvider); + this.ipcService = new IpcBackgroundService( + this.platformUtilsService, + this.logService, + ipcSessionRepository, + ); this.endUserNotificationService = new DefaultEndUserNotificationService( this.stateProvider, @@ -1435,7 +1493,6 @@ export default class MainBackground { ); this.badgeService = new BadgeService( - this.stateProvider, new DefaultBadgeBrowserApi(this.platformUtilsService), this.logService, ); @@ -1461,6 +1518,7 @@ 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. @@ -1481,6 +1539,7 @@ export default class MainBackground { (this.eventUploadService as EventUploadService).init(true); this.popupViewCacheBackgroundService.startObservingMessages(); + this.popupRouterCacheBackgroundService.init(); await this.vaultTimeoutService.init(true); this.fido2Background.init(); @@ -1664,11 +1723,10 @@ export default class MainBackground { await Promise.all([ this.keyService.clearKeys(userBeingLoggedOut), this.cipherService.clear(userBeingLoggedOut), - // ! DO NOT REMOVE folderService.clear ! For more information see PM-25660 this.folderService.clear(userBeingLoggedOut), - this.vaultTimeoutSettingsService.clear(userBeingLoggedOut), this.biometricStateService.logout(userBeingLoggedOut), this.popupViewCacheBackgroundService.clearState(), + this.pinService.logout(userBeingLoggedOut), /* We intentionally do not clear: * - autofillSettingsService * - badgeSettingsService @@ -1704,7 +1762,7 @@ export default class MainBackground { } await this.mainContextMenuHandler?.noAccess(); await this.systemService.clearPendingClipboard(); - await this.processReloadService.startProcessReload(this.authService); + await this.processReloadService.startProcessReload(); } private async needsStorageReseed(userId: UserId): Promise { @@ -1925,7 +1983,6 @@ export default class MainBackground { this.badgeService, this.accountService, this.cipherService, - this.logService, this.taskService, ); diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index d7aef0db375..597babdc777 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -145,6 +145,7 @@ export default class RuntimeBackground { if (totpCode != null) { this.platformUtilsService.copyToClipboard(totpCode); } + await this.main.updateOverlayCiphers(); break; } case ExtensionCommand.AutofillCard: { @@ -255,8 +256,11 @@ export default class RuntimeBackground { case "addToLockedVaultPendingNotifications": this.lockedVaultPendingNotifications.push(msg.data); break; + case "abandonAutofillPendingNotifications": + this.lockedVaultPendingNotifications = []; + break; case "lockVault": - await this.main.vaultTimeoutService.lock(msg.userId); + await this.lockService.lock(msg.userId); break; case "lockAll": { @@ -264,6 +268,14 @@ export default class RuntimeBackground { this.messagingService.send("lockAllFinished", { requestId: msg.requestId }); } break; + case "lockUser": + { + await this.lockService.lock(msg.userId); + this.messagingService.send("lockUserFinished", { + requestId: msg.requestId, + }); + } + break; case "logout": await this.main.logout(msg.expired, msg.userId); break; @@ -281,14 +293,24 @@ export default class RuntimeBackground { case "openPopup": await this.openPopup(); break; - case VaultMessages.OpenAtRiskPasswords: + case VaultMessages.OpenAtRiskPasswords: { + if (await this.shouldRejectManyOriginMessage(msg)) { + return; + } + await this.main.openAtRisksPasswordsPage(); this.announcePopupOpen(); break; - case VaultMessages.OpenBrowserExtensionToUrl: + } + case VaultMessages.OpenBrowserExtensionToUrl: { + if (await this.shouldRejectManyOriginMessage(msg)) { + return; + } + await this.main.openTheExtensionToPage(msg.url); this.announcePopupOpen(); break; + } case "bgUpdateContextMenu": case "editedCipher": case "addedCipher": @@ -300,10 +322,7 @@ export default class RuntimeBackground { break; } case "authResult": { - const env = await firstValueFrom(this.environmentService.environment$); - const vaultUrl = env.getWebVaultUrl(); - - if (msg.referrer == null || Utils.getHostname(vaultUrl) !== msg.referrer) { + if (!(await this.isValidVaultReferrer(msg.referrer))) { return; } @@ -322,10 +341,7 @@ export default class RuntimeBackground { break; } case "webAuthnResult": { - const env = await firstValueFrom(this.environmentService.environment$); - const vaultUrl = env.getWebVaultUrl(); - - if (msg.referrer == null || Utils.getHostname(vaultUrl) !== msg.referrer) { + if (!(await this.isValidVaultReferrer(msg.referrer))) { return; } @@ -360,6 +376,48 @@ export default class RuntimeBackground { } } + /** + * For messages that can originate from a vault host page or extension, validate referrer or external + * + * @param message + * @returns true if message fails validation + */ + private async shouldRejectManyOriginMessage(message: { + webExtSender: chrome.runtime.MessageSender; + }): Promise { + const isValidVaultReferrer = await this.isValidVaultReferrer( + Utils.getHostname(message?.webExtSender?.origin), + ); + + if (isValidVaultReferrer) { + return false; + } + + return isExternalMessage(message); + } + + /** + * Validates a message's referrer matches the configured web vault hostname. + * + * @param referrer - hostname from message source + * @returns true if referrer matches web vault + */ + 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); + + if (!vaultHostname) { + return false; + } + + return vaultHostname === referrer; + } + private async autofillPage(tabToAutoFill: chrome.tabs.Tab) { const totpCode = await this.autofillService.doAutoFill({ tab: tabToAutoFill, diff --git a/apps/browser/src/billing/popup/settings/premium-v2.component.html b/apps/browser/src/billing/popup/settings/premium-v2.component.html index 4f87a0f6781..47d72751af3 100644 --- a/apps/browser/src/billing/popup/settings/premium-v2.component.html +++ b/apps/browser/src/billing/popup/settings/premium-v2.component.html @@ -6,7 +6,7 @@
-

{{ "premiumFeatures" | i18n }}

+

{{ "premiumFeatures" | i18n }}

diff --git a/apps/browser/src/billing/popup/settings/premium-v2.component.ts b/apps/browser/src/billing/popup/settings/premium-v2.component.ts index fde44688349..b858b74242d 100644 --- a/apps/browser/src/billing/popup/settings/premium-v2.component.ts +++ b/apps/browser/src/billing/popup/settings/premium-v2.component.ts @@ -26,6 +26,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; +// 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-premium", templateUrl: "premium-v2.component.html", diff --git a/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.html b/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.html deleted file mode 100644 index 5ea79c3f840..00000000000 --- a/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.html +++ /dev/null @@ -1,4 +0,0 @@ -{{ "phishingPageLearnWhy"| i18n}} - - {{ "learnMore" | i18n }} - diff --git a/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.ts b/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.ts deleted file mode 100644 index 1a1e6059204..00000000000 --- a/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.ts +++ /dev/null @@ -1,16 +0,0 @@ -// eslint-disable-next-line no-restricted-imports -import { CommonModule } from "@angular/common"; -// eslint-disable-next-line no-restricted-imports -import { Component } from "@angular/core"; - -import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ButtonModule } from "@bitwarden/components"; - -@Component({ - standalone: true, - templateUrl: "learn-more-component.html", - imports: [CommonModule, CommonModule, JslibModule, ButtonModule], -}) -export class LearnMoreComponent { - constructor() {} -} diff --git a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.html b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.html deleted file mode 100644 index f6e3baf8766..00000000000 --- a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.html +++ /dev/null @@ -1,13 +0,0 @@ -
- - {{ "phishingPageTitle" | i18n }} - - - - - -
diff --git a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts deleted file mode 100644 index dc6ab2d329e..00000000000 --- a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts +++ /dev/null @@ -1,58 +0,0 @@ -// eslint-disable-next-line no-restricted-imports -import { CommonModule } from "@angular/common"; -// eslint-disable-next-line no-restricted-imports -import { Component, OnDestroy } from "@angular/core"; -// eslint-disable-next-line no-restricted-imports -import { ActivatedRoute, RouterModule } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; - -import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { - AsyncActionsModule, - ButtonModule, - CheckboxModule, - FormFieldModule, - IconModule, - LinkModule, -} from "@bitwarden/components"; - -import { PhishingDetectionService } from "../services/phishing-detection.service"; - -@Component({ - standalone: true, - templateUrl: "phishing-warning.component.html", - imports: [ - CommonModule, - IconModule, - JslibModule, - LinkModule, - FormFieldModule, - AsyncActionsModule, - CheckboxModule, - ButtonModule, - RouterModule, - ], -}) -export class PhishingWarning implements OnDestroy { - phishingHost = ""; - - private destroy$ = new Subject(); - - constructor(private activatedRoute: ActivatedRoute) { - this.activatedRoute.queryParamMap.pipe(takeUntil(this.destroy$)).subscribe((params) => { - this.phishingHost = params.get("phishingHost") || ""; - }); - } - - async closeTab() { - await PhishingDetectionService.requestClosePhishingWarningPage(); - } - async continueAnyway() { - await PhishingDetectionService.requestContinueToDangerousUrl(); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } -} diff --git a/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.html b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.html new file mode 100644 index 00000000000..7675add73d7 --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.html @@ -0,0 +1,46 @@ +
+
+ +

{{ "phishingPageTitleV2" | i18n }}

+
+ +
+ +

{{ "phishingPageSummary" | i18n }}

+ + + {{ phishingHostname$ | async }} + + + +

+ {{ "phishingPageExplanation1" | i18n }}Phishing.Database{{ "phishingPageExplanation2" | i18n }} +

+ + + {{ "phishingPageLearnMore" | i18n }} + +
+ +
+ + +
+
diff --git a/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts new file mode 100644 index 00000000000..d8e9895237c --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts @@ -0,0 +1,76 @@ +import { CommonModule } from "@angular/common"; +import { Component, inject } from "@angular/core"; +import { ActivatedRoute, RouterModule } from "@angular/router"; +import { firstValueFrom, map } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api"; +import { + AsyncActionsModule, + ButtonModule, + CheckboxModule, + FormFieldModule, + IconModule, + IconTileComponent, + LinkModule, + CalloutComponent, + TypographyModule, +} from "@bitwarden/components"; +import { MessageSender } from "@bitwarden/messaging"; + +import { + PHISHING_DETECTION_CANCEL_COMMAND, + PHISHING_DETECTION_CONTINUE_COMMAND, +} from "../services/phishing-detection.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: "dirt-phishing-warning", + standalone: true, + templateUrl: "phishing-warning.component.html", + imports: [ + CommonModule, + IconModule, + JslibModule, + LinkModule, + FormFieldModule, + AsyncActionsModule, + CheckboxModule, + ButtonModule, + RouterModule, + IconTileComponent, + CalloutComponent, + TypographyModule, + ], +}) +// FIXME(https://bitwarden.atlassian.net/browse/PM-28231): Use Component suffix +// eslint-disable-next-line @angular-eslint/component-class-suffix +export class PhishingWarning { + private activatedRoute = inject(ActivatedRoute); + private messageSender = inject(MessageSender); + + private phishingUrl$ = this.activatedRoute.queryParamMap.pipe( + map((params) => params.get("phishingUrl") || ""), + ); + protected phishingHostname$ = this.phishingUrl$.pipe(map((url) => new URL(url).hostname)); + + async closeTab() { + const tabId = await this.getTabId(); + this.messageSender.send(PHISHING_DETECTION_CANCEL_COMMAND, { + tabId, + }); + } + async continueAnyway() { + const url = await firstValueFrom(this.phishingUrl$); + const tabId = await this.getTabId(); + this.messageSender.send(PHISHING_DETECTION_CONTINUE_COMMAND, { + tabId, + url, + }); + } + + private async getTabId() { + return BrowserApi.getCurrentTab()?.then((tab) => tab.id); + } +} diff --git a/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.stories.ts b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.stories.ts new file mode 100644 index 00000000000..32b3c102c36 --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.stories.ts @@ -0,0 +1,130 @@ +import { ActivatedRoute, RouterModule } from "@angular/router"; +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { BehaviorSubject, of } from "rxjs"; + +import { DeactivatedOrg } 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"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { AnonLayoutComponent, I18nMockService } from "@bitwarden/components"; +import { MessageSender } from "@bitwarden/messaging"; + +import { PhishingWarning } from "./phishing-warning.component"; +import { ProtectedByComponent } from "./protected-by-component"; + +class MockPlatformUtilsService implements Partial { + getApplicationVersion = () => Promise.resolve("Version 2024.1.1"); + getClientType = () => ClientType.Web; +} + +/** + * Helper function to create ActivatedRoute mock with query parameters + */ +function mockActivatedRoute(queryParams: Record) { + return { + provide: ActivatedRoute, + useValue: { + queryParamMap: of({ + get: (key: string) => queryParams[key] || null, + }), + queryParams: of(queryParams), + }, + }; +} + +type StoryArgs = { + phishingHost: string; +}; + +export default { + title: "Browser/DIRT/Phishing Warning", + component: PhishingWarning, + decorators: [ + moduleMetadata({ + imports: [AnonLayoutComponent, ProtectedByComponent, RouterModule], + providers: [ + { + provide: PlatformUtilsService, + useClass: MockPlatformUtilsService, + }, + { + provide: MessageSender, + useValue: { + // eslint-disable-next-line no-console + send: (...args: any[]) => console.debug("MessageSender called with:", args), + } as Partial, + }, + { + provide: I18nService, + useFactory: () => + new I18nMockService({ + accessing: "Accessing", + appLogoLabel: "Bitwarden logo", + phishingPageTitleV2: "Phishing attempt detected", + phishingPageCloseTabV2: "Close this tab", + phishingPageSummary: + "The site you are attempting to visit is a known malicious site and a security risk.", + phishingPageContinueV2: "Continue to this site (not recommended)", + phishingPageExplanation1: "This site was found in ", + phishingPageExplanation2: + ", an open-source list of known phishing sites used for stealing personal and sensitive information.", + phishingPageLearnMore: "Learn more about phishing detection", + protectedBy: (product) => `Protected by ${product}`, + learnMore: "Learn more", + danger: "error", + }), + }, + { + provide: EnvironmentService, + useValue: { + environment$: new BehaviorSubject({ + getHostname() { + return "bitwarden.com"; + }, + }).asObservable(), + }, + }, + mockActivatedRoute({ phishingUrl: "http://malicious-example.com" }), + ], + }), + ], + render: (args) => ({ + props: args, + template: /*html*/ ` + + + + + `, + }), + args: { + pageIcon: DeactivatedOrg, + }, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + decorators: [ + moduleMetadata({ + providers: [mockActivatedRoute({ phishingUrl: "http://malicious-example.com" })], + }), + ], +}; + +export const LongHostname: Story = { + decorators: [ + moduleMetadata({ + providers: [ + mockActivatedRoute({ + phishingUrl: + "http://verylongsuspiciousphishingdomainnamethatmightwrapmaliciousexample.com", + }), + ], + }), + ], +}; diff --git a/apps/browser/src/dirt/phishing-detection/popup/protected-by-component.html b/apps/browser/src/dirt/phishing-detection/popup/protected-by-component.html new file mode 100644 index 00000000000..6c55097ade3 --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/popup/protected-by-component.html @@ -0,0 +1 @@ +{{ "protectedBy" | i18n: "Bitwarden phishing blocker" }} diff --git a/apps/browser/src/dirt/phishing-detection/popup/protected-by-component.ts b/apps/browser/src/dirt/phishing-detection/popup/protected-by-component.ts new file mode 100644 index 00000000000..8da916af5e6 --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/popup/protected-by-component.ts @@ -0,0 +1,15 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ButtonModule, LinkModule } from "@bitwarden/components"; + +// 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: "dirt-phishing-protected-by", + standalone: true, + templateUrl: "protected-by-component.html", + imports: [CommonModule, CommonModule, JslibModule, ButtonModule, LinkModule], +}) +export class ProtectedByComponent {} diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts new file mode 100644 index 00000000000..94f3e99f8be --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts @@ -0,0 +1,158 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + DefaultTaskSchedulerService, + TaskSchedulerService, +} from "@bitwarden/common/platform/scheduling"; +import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; +import { LogService } from "@bitwarden/logging"; + +import { PhishingDataService, PhishingData, PHISHING_DOMAINS_KEY } from "./phishing-data.service"; + +describe("PhishingDataService", () => { + let service: PhishingDataService; + let apiService: MockProxy; + 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 fetchChecksumSpy: jest.SpyInstance; + let fetchDomainsSpy: jest.SpyInstance; + + beforeEach(() => { + jest.useFakeTimers(); + apiService = mock(); + logService = mock(); + + platformUtilsService = mock(); + platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0"); + + taskSchedulerService = new DefaultTaskSchedulerService(logService); + + service = new PhishingDataService( + apiService, + taskSchedulerService, + stateProvider, + logService, + platformUtilsService, + ); + + fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingDomainsChecksum"); + fetchDomainsSpy = jest.spyOn(service as any, "fetchPhishingDomains"); + }); + + describe("isPhishingDomains", () => { + it("should detect a phishing domain", async () => { + setMockState({ + domains: ["phish.com", "badguy.net"], + timestamp: Date.now(), + checksum: "abc123", + applicationVersion: "1.0.0", + }); + const url = new URL("http://phish.com"); + const result = await service.isPhishingDomain(url); + expect(result).toBe(true); + }); + + it("should not detect a safe domain", async () => { + setMockState({ + domains: ["phish.com", "badguy.net"], + timestamp: Date.now(), + checksum: "abc123", + applicationVersion: "1.0.0", + }); + const url = new URL("http://safe.com"); + const result = await service.isPhishingDomain(url); + expect(result).toBe(false); + }); + + it("should match against root domain", async () => { + setMockState({ + domains: ["phish.com", "badguy.net"], + timestamp: Date.now(), + checksum: "abc123", + applicationVersion: "1.0.0", + }); + const url = new URL("http://phish.com/about"); + const result = await service.isPhishingDomain(url); + expect(result).toBe(true); + }); + + it("should not error on empty state", async () => { + setMockState(undefined as any); + const url = new URL("http://phish.com/about"); + const result = await service.isPhishingDomain(url); + expect(result).toBe(false); + }); + }); + + describe("getNextDomains", () => { + it("refetches all domains if applicationVersion has changed", async () => { + const prev: PhishingData = { + domains: ["a.com"], + timestamp: Date.now() - 60000, + checksum: "old", + applicationVersion: "1.0.0", + }; + fetchChecksumSpy.mockResolvedValue("new"); + fetchDomainsSpy.mockResolvedValue(["d.com", "e.com"]); + platformUtilsService.getApplicationVersion.mockResolvedValue("2.0.0"); + + const result = await service.getNextDomains(prev); + + expect(result!.domains).toEqual(["d.com", "e.com"]); + expect(result!.checksum).toBe("new"); + expect(result!.applicationVersion).toBe("2.0.0"); + }); + + it("only updates timestamp if checksum matches", async () => { + const prev: PhishingData = { + domains: ["a.com"], + timestamp: Date.now() - 60000, + checksum: "abc", + applicationVersion: "1.0.0", + }; + fetchChecksumSpy.mockResolvedValue("abc"); + const result = await service.getNextDomains(prev); + expect(result!.domains).toEqual(prev.domains); + expect(result!.checksum).toBe("abc"); + expect(result!.timestamp).not.toBe(prev.timestamp); + }); + + it("patches daily domains if cache is fresh", async () => { + const prev: PhishingData = { + domains: ["a.com"], + timestamp: Date.now() - 60000, + checksum: "old", + applicationVersion: "1.0.0", + }; + fetchChecksumSpy.mockResolvedValue("new"); + fetchDomainsSpy.mockResolvedValue(["b.com", "c.com"]); + const result = await service.getNextDomains(prev); + expect(result!.domains).toEqual(["a.com", "b.com", "c.com"]); + expect(result!.checksum).toBe("new"); + }); + + it("fetches all domains if cache is old", async () => { + const prev: PhishingData = { + domains: ["a.com"], + timestamp: Date.now() - 2 * 24 * 60 * 60 * 1000, + checksum: "old", + applicationVersion: "1.0.0", + }; + fetchChecksumSpy.mockResolvedValue("new"); + fetchDomainsSpy.mockResolvedValue(["d.com", "e.com"]); + const result = await service.getNextDomains(prev); + expect(result!.domains).toEqual(["d.com", "e.com"]); + expect(result!.checksum).toBe("new"); + }); + }); +}); 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 new file mode 100644 index 00000000000..6e1bf07c647 --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts @@ -0,0 +1,223 @@ +import { + catchError, + EMPTY, + first, + firstValueFrom, + map, + retry, + share, + startWith, + Subject, + switchMap, + tap, + timer, +} from "rxjs"; + +import { devFlagEnabled, devFlagValue } from "@bitwarden/browser/platform/flags"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ScheduledTaskNames, TaskSchedulerService } from "@bitwarden/common/platform/scheduling"; +import { LogService } from "@bitwarden/logging"; +import { GlobalStateProvider, KeyDefinition, PHISHING_DETECTION_DISK } from "@bitwarden/state"; + +export type PhishingData = { + domains: string[]; + timestamp: number; + checksum: string; + + /** + * 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 domains. + */ + applicationVersion: string; +}; + +export const PHISHING_DOMAINS_KEY = new KeyDefinition( + PHISHING_DETECTION_DISK, + "phishingDomains", + { + deserializer: (value: PhishingData) => + value ?? { domains: [], timestamp: 0, checksum: "", applicationVersion: "" }, + }, +); + +/** Coordinates fetching, caching, and patching of known phishing domains */ +export class PhishingDataService { + private static readonly RemotePhishingDatabaseUrl = + "https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/master/phishing-domains-ACTIVE.txt"; + private static readonly RemotePhishingDatabaseChecksumUrl = + "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.md5"; + private static readonly RemotePhishingDatabaseTodayUrl = + "https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/refs/heads/master/phishing-domains-NEW-today.txt"; + + private _testDomains = this.getTestDomains(); + private _cachedState = this.globalStateProvider.get(PHISHING_DOMAINS_KEY); + private _domains$ = this._cachedState.state$.pipe( + map( + (state) => + new Set( + (state?.domains?.filter((line) => line.trim().length > 0) ?? []).concat( + this._testDomains, + "phishing.testcategory.com", // Included for QA to test in prod + ), + ), + ), + ); + + // How often are new domains added to the remote? + readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours + + private _triggerUpdate$ = new Subject(); + update$ = this._triggerUpdate$.pipe( + startWith(undefined), // Always emit once + tap(() => this.logService.info(`[PhishingDataService] Update triggered...`)), + switchMap(() => + this._cachedState.state$.pipe( + first(), // Only take the first value to avoid an infinite loop when updating the cache below + switchMap(async (cachedState) => { + const next = await this.getNextDomains(cachedState); + if (next) { + await this._cachedState.update(() => next); + this.logService.info(`[PhishingDataService] cache updated`); + } + }), + retry({ + count: 3, + delay: (err, count) => { + this.logService.error( + `[PhishingDataService] Unable to update domains. Attempt ${count}.`, + err, + ); + return timer(5 * 60 * 1000); // 5 minutes + }, + resetOnSuccess: true, + }), + catchError( + ( + err: unknown /** Eslint actually crashed if you remove this type: https://github.com/cartant/eslint-plugin-rxjs/issues/122 */, + ) => { + this.logService.error( + "[PhishingDataService] Retries unsuccessful. Unable to update domains.", + err, + ); + return EMPTY; + }, + ), + ), + ), + share(), + ); + + constructor( + private apiService: ApiService, + private taskSchedulerService: TaskSchedulerService, + private globalStateProvider: GlobalStateProvider, + private logService: LogService, + private platformUtilsService: PlatformUtilsService, + ) { + this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.phishingDomainUpdate, () => { + this._triggerUpdate$.next(); + }); + this.taskSchedulerService.setInterval( + ScheduledTaskNames.phishingDomainUpdate, + this.UPDATE_INTERVAL_DURATION, + ); + } + + /** + * Checks if the given URL is a known phishing domain + * + * @param url The URL to check + * @returns True if the URL is a known phishing domain, false otherwise + */ + async isPhishingDomain(url: URL): Promise { + const domains = await firstValueFrom(this._domains$); + const result = domains.has(url.hostname); + if (result) { + return true; + } + return false; + } + + async getNextDomains(prev: PhishingData | null): Promise { + prev = prev ?? { domains: [], 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.fetchPhishingDomainsChecksum(); + 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 domains and append + const isOneDayOldMax = prevAge <= this.UPDATE_INTERVAL_DURATION; + if (isOneDayOldMax && applicationVersion === prev.applicationVersion) { + const dailyDomains: string[] = await this.fetchPhishingDomains( + PhishingDataService.RemotePhishingDatabaseTodayUrl, + ); + this.logService.info( + `[PhishingDataService] ${dailyDomains.length} new phishing domains added`, + ); + return { + domains: prev.domains.concat(dailyDomains), + checksum: remoteChecksum, + timestamp, + applicationVersion, + }; + } + + // Approach 2: Fetch all domains + const domains = await this.fetchPhishingDomains(PhishingDataService.RemotePhishingDatabaseUrl); + return { + domains, + timestamp, + checksum: remoteChecksum, + applicationVersion, + }; + } + + private async fetchPhishingDomainsChecksum() { + const response = await this.apiService.nativeFetch( + new Request(PhishingDataService.RemotePhishingDatabaseChecksumUrl), + ); + if (!response.ok) { + throw new Error(`[PhishingDataService] Failed to fetch checksum: ${response.status}`); + } + return response.text(); + } + + private async fetchPhishingDomains(url: string) { + const response = await this.apiService.nativeFetch(new Request(url)); + + if (!response.ok) { + throw new Error(`[PhishingDataService] Failed to fetch domains: ${response.status}`); + } + + return response.text().then((text) => text.split("\n")); + } + + private getTestDomains() { + const flag = devFlagEnabled("testPhishingUrls"); + if (!flag) { + return []; + } + + const domains = devFlagValue("testPhishingUrls") as unknown[]; + if (domains && domains instanceof Array) { + this.logService.debug( + "[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing domains:", + domains, + ); + return domains as string[]; + } + return []; + } +} diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts index 8d3c3ec5b31..e33b4b1b4f1 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts @@ -1,51 +1,86 @@ -import { of } from "rxjs"; +import { mock, MockProxy } from "jest-mock-extended"; +import { Observable, of } from "rxjs"; -import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; -import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling/task-scheduler.service"; +import { MessageListener } from "@bitwarden/messaging"; +import { PhishingDataService } from "./phishing-data.service"; import { PhishingDetectionService } from "./phishing-detection.service"; describe("PhishingDetectionService", () => { - let auditService: AuditService; - let logService: LogService; - let storageService: AbstractStorageService; - let taskSchedulerService: TaskSchedulerService; + let accountService: AccountService; + let billingAccountProfileStateService: BillingAccountProfileStateService; let configService: ConfigService; - let eventCollectionService: EventCollectionService; + let logService: LogService; + let phishingDataService: MockProxy; + let messageListener: MockProxy; beforeEach(() => { - auditService = { getKnownPhishingDomains: jest.fn() } as any; - logService = { info: jest.fn(), debug: jest.fn(), warning: jest.fn(), error: jest.fn() } as any; - storageService = { get: jest.fn(), save: jest.fn() } as any; - taskSchedulerService = { registerTaskHandler: jest.fn(), setInterval: jest.fn() } as any; + accountService = { getAccount$: jest.fn(() => of(null)) } as any; + billingAccountProfileStateService = {} as any; configService = { getFeatureFlag$: jest.fn(() => of(false)) } as any; - eventCollectionService = {} as any; + logService = { info: jest.fn(), debug: jest.fn(), warning: jest.fn(), error: jest.fn() } as any; + phishingDataService = mock(); + messageListener = mock({ + messages$(_commandDefinition) { + return new Observable(); + }, + }); }); it("should initialize without errors", () => { expect(() => { PhishingDetectionService.initialize( + accountService, + billingAccountProfileStateService, configService, - auditService, logService, - storageService, - taskSchedulerService, - eventCollectionService, + phishingDataService, + messageListener, ); }).not.toThrow(); }); - it("should detect phishing domains", () => { - PhishingDetectionService["_knownPhishingDomains"].add("phishing.com"); - const url = new URL("https://phishing.com"); - expect(PhishingDetectionService.isPhishingDomain(url)).toBe(true); - const safeUrl = new URL("https://safe.com"); - expect(PhishingDetectionService.isPhishingDomain(safeUrl)).toBe(false); - }); + // TODO + // it("should enable phishing detection for premium account", (done) => { + // const premiumAccount = { id: "user1" }; + // accountService = { activeAccount$: of(premiumAccount) } as any; + // configService = { getFeatureFlag$: jest.fn(() => of(true)) } as any; + // billingAccountProfileStateService = { + // hasPremiumFromAnySource$: jest.fn(() => of(true)), + // } as any; - // Add more tests for other methods as needed + // // Run the initialization + // PhishingDetectionService.initialize( + // accountService, + // billingAccountProfileStateService, + // configService, + // logService, + // phishingDataService, + // messageListener, + // ); + // }); + + // TODO + // it("should not enable phishing detection for non-premium account", (done) => { + // const nonPremiumAccount = { id: "user2" }; + // accountService = { activeAccount$: of(nonPremiumAccount) } as any; + // configService = { getFeatureFlag$: jest.fn(() => of(true)) } as any; + // billingAccountProfileStateService = { + // hasPremiumFromAnySource$: jest.fn(() => of(false)), + // } as any; + + // // Run the initialization + // PhishingDetectionService.initialize( + // accountService, + // billingAccountProfileStateService, + // configService, + // logService, + // phishingDataService, + // messageListener, + // ); + // }); }); 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 1497ac96dba..4917e740be8 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,685 +1,193 @@ -import { concatMap, delay, Subject, Subscription } from "rxjs"; +import { + combineLatest, + concatMap, + distinctUntilChanged, + EMPTY, + filter, + map, + merge, + of, + Subject, + switchMap, + tap, +} from "rxjs"; -import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { 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 { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; -import { devFlagEnabled, devFlagValue } from "@bitwarden/common/platform/misc/flags"; -import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling"; -import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling/task-scheduler.service"; +import { CommandDefinition, MessageListener } from "@bitwarden/messaging"; import { BrowserApi } from "../../../platform/browser/browser-api"; -import { - CaughtPhishingDomain, - isPhishingDetectionMessage, - PhishingDetectionMessage, - PhishingDetectionNavigationEvent, - PhishingDetectionTabId, -} from "./phishing-detection.types"; +import { PhishingDataService } from "./phishing-data.service"; + +type PhishingDetectionNavigationEvent = { + tabId: number; + changeInfo: chrome.tabs.OnUpdatedInfo; + tab: chrome.tabs.Tab; +}; + +/** + * Sends a message to the phishing detection service to continue to the caught url + */ +export const PHISHING_DETECTION_CONTINUE_COMMAND = new CommandDefinition<{ + tabId: number; + url: string; +}>("phishing-detection-continue"); + +/** + * Sends a message to the phishing detection service to close the warning page + */ +export const PHISHING_DETECTION_CANCEL_COMMAND = new CommandDefinition<{ + tabId: number; +}>("phishing-detection-cancel"); export class PhishingDetectionService { - private static readonly _UPDATE_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours in milliseconds - private static readonly _RETRY_INTERVAL = 5 * 60 * 1000; // 5 minutes - private static readonly _MAX_RETRIES = 3; - private static readonly _STORAGE_KEY = "phishing_domains_cache"; - private static _auditService: AuditService; - private static _logService: LogService; - private static _storageService: AbstractStorageService; - private static _taskSchedulerService: TaskSchedulerService; - private static _updateCacheSubscription: Subscription | null = null; - private static _retrySubscription: Subscription | null = null; - private static _navigationEventsSubject = new Subject(); - private static _navigationEvents: Subscription | null = null; - private static _knownPhishingDomains = new Set(); - private static _caughtTabs: Map = new Map(); - private static _isInitialized = false; - private static _isUpdating = false; - private static _retryCount = 0; - private static _lastUpdateTime: number = 0; + private static _tabUpdated$ = new Subject(); + private static _ignoredHostnames = new Set(); + private static _didInit = false; static initialize( + accountService: AccountService, + billingAccountProfileStateService: BillingAccountProfileStateService, configService: ConfigService, - auditService: AuditService, logService: LogService, - storageService: AbstractStorageService, - taskSchedulerService: TaskSchedulerService, - eventCollectionService: EventCollectionService, - ): void { - this._auditService = auditService; - this._logService = logService; - this._storageService = storageService; - this._taskSchedulerService = taskSchedulerService; + phishingDataService: PhishingDataService, + messageListener: MessageListener, + ) { + if (this._didInit) { + logService.debug("[PhishingDetectionService] Initialize already called. Aborting."); + return; + } - logService.info("[PhishingDetectionService] Initialize called"); + logService.debug("[PhishingDetectionService] Initialize called. Checking prerequisites..."); - configService - .getFeatureFlag$(FeatureFlag.PhishingDetection) + BrowserApi.addListener(chrome.tabs.onUpdated, this._handleTabUpdated.bind(this)); + + const onContinueCommand$ = messageListener.messages$(PHISHING_DETECTION_CONTINUE_COMMAND).pipe( + tap((message) => + logService.debug(`[PhishingDetectionService] user selected continue for ${message.url}`), + ), + concatMap(async (message) => { + const url = new URL(message.url); + this._ignoredHostnames.add(url.hostname); + await BrowserApi.navigateTabToUrl(message.tabId, url); + }), + ); + + const onTabUpdated$ = this._tabUpdated$.pipe( + filter( + (navEvent) => + navEvent.changeInfo.status === "complete" && + !!navEvent.tab.url && + !this._isExtensionPage(navEvent.tab.url), + ), + map(({ tab, tabId }) => { + const url = new URL(tab.url!); + return { tabId, url, ignored: this._ignoredHostnames.has(url.hostname) }; + }), + distinctUntilChanged( + (prev, curr) => + prev.url.toString() === curr.url.toString() && + prev.tabId === curr.tabId && + prev.ignored === curr.ignored, + ), + tap((event) => logService.debug(`[PhishingDetectionService] processing event:`, event)), + concatMap(async ({ tabId, url, ignored }) => { + if (ignored) { + // The next time this host is visited, block again + this._ignoredHostnames.delete(url.hostname); + return; + } + const isPhishing = await phishingDataService.isPhishingDomain(url); + if (!isPhishing) { + return; + } + + const phishingWarningPage = new URL( + BrowserApi.getRuntimeURL("popup/index.html#/security/phishing-warning") + + `?phishingUrl=${url.toString()}`, + ); + await BrowserApi.navigateTabToUrl(tabId, phishingWarningPage); + }), + ); + + const onCancelCommand$ = messageListener + .messages$(PHISHING_DETECTION_CANCEL_COMMAND) + .pipe(switchMap((message) => BrowserApi.closeTab(message.tabId))); + + const activeAccountHasAccess$ = combineLatest([ + accountService.activeAccount$, + configService.getFeatureFlag$(FeatureFlag.PhishingDetection), + ]).pipe( + switchMap(([account, featureEnabled]) => { + if (!account) { + logService.debug("[PhishingDetectionService] No active account."); + return of(false); + } + return billingAccountProfileStateService + .hasPremiumFromAnySource$(account.id) + .pipe(map((hasPremium) => hasPremium && featureEnabled)); + }), + ); + + const initSub = activeAccountHasAccess$ .pipe( - concatMap(async (enabled) => { - if (!enabled) { - logService.info( - "[PhishingDetectionService] Phishing detection feature flag is disabled.", + distinctUntilChanged(), + switchMap((activeUserHasAccess) => { + if (!activeUserHasAccess) { + logService.debug( + "[PhishingDetectionService] User does not have access to phishing detection service.", ); - this._cleanup(); + return EMPTY; } else { - // Enable phishing detection service - logService.info("[PhishingDetectionService] Enabling phishing detection service"); - await this._setup(); + logService.debug("[PhishingDetectionService] Enabling phishing detection service"); + return merge( + phishingDataService.update$, + onContinueCommand$, + onTabUpdated$, + onCancelCommand$, + ); } }), ) .subscribe(); - } - /** - * Checks if the given URL is a known phishing domain - * - * @param url The URL to check - * @returns True if the URL is a known phishing domain, false otherwise - */ - static isPhishingDomain(url: URL): boolean { - const result = this._knownPhishingDomains.has(url.hostname); - if (result) { - this._logService.debug("[PhishingDetectionService] Caught phishing domain:", url.hostname); - return true; - } - return false; - } + this._didInit = true; + return () => { + initSub.unsubscribe(); + this._didInit = false; - /** - * Sends a message to the phishing detection service to close the warning page - */ - static requestClosePhishingWarningPage(): void { - void chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Close }); - } - - /** - * Sends a message to the phishing detection service to continue to the caught url - */ - static async requestContinueToDangerousUrl() { - void chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Continue }); - } - - /** - * Continues to the dangerous URL if the user has requested it - * - * @param tabId The ID of the tab to continue to the dangerous URL - */ - static async _continueToDangerousUrl(tabId: PhishingDetectionTabId): Promise { - const caughtTab = this._caughtTabs.get(tabId); - if (caughtTab) { - this._logService.info( - "[PhishingDetectionService] Continuing to known phishing domain: ", - caughtTab, - caughtTab.url.href, + // Manually type cast to satisfy the listener signature due to the mixture + // of static and instance methods in this class. To be fixed when refactoring + // this class to be instance-based while providing a singleton instance in usage + BrowserApi.removeListener( + chrome.tabs.onUpdated, + PhishingDetectionService._handleTabUpdated as (...args: readonly unknown[]) => unknown, ); - await BrowserApi.navigateTabToUrl(tabId, caughtTab.url); - } else { - this._logService.warning("[PhishingDetectionService] No caught domain to continue to"); - } + }; } - /** - * Initializes the phishing detection service, setting up listeners and registering tasks - */ - private static async _setup(): Promise { - if (this._isInitialized) { - this._logService.info("[PhishingDetectionService] Already initialized, skipping setup."); - return; - } - - this._isInitialized = true; - this._setupListeners(); - - // Register the update task - this._taskSchedulerService.registerTaskHandler( - ScheduledTaskNames.phishingDomainUpdate, - async () => { - try { - await this._fetchKnownPhishingDomains(); - } catch (error) { - this._logService.error( - "[PhishingDetectionService] Failed to update phishing domains in task handler:", - error, - ); - } - }, - ); - - // Initial load of cached domains - await this._loadCachedDomains(); - - // Set up periodic updates every 24 hours - this._setupPeriodicUpdates(); - this._logService.debug("[PhishingDetectionService] Phishing detection feature is initialized."); - } - - /** - * Sets up listeners for messages from the web page and web navigation events - */ - private static _setupListeners(): void { - // Setup listeners from web page/content script - BrowserApi.addListener(chrome.runtime.onMessage, this._handleExtensionMessage.bind(this)); - BrowserApi.addListener(chrome.tabs.onReplaced, this._handleReplacementEvent.bind(this)); - BrowserApi.addListener(chrome.tabs.onUpdated, this._handleNavigationEvent.bind(this)); - - // When a navigation event occurs, check if a replace event for the same tabId exists, - // and call the replace handler before handling navigation. - this._navigationEvents = this._navigationEventsSubject - .pipe( - delay(100), // Delay slightly to allow replace events to be caught - ) - .subscribe(({ tabId, changeInfo, tab }) => { - void this._processNavigation(tabId, changeInfo, tab); - }); - } - - /** - * Handles messages from the phishing warning page - * - * @returns true if the message was handled, false otherwise - */ - private static _handleExtensionMessage( - message: unknown, - sender: chrome.runtime.MessageSender, - ): boolean { - if (!isPhishingDetectionMessage(message)) { - return false; - } - const isValidSender = sender && sender.tab && sender.tab.id; - const senderTabId = isValidSender ? sender?.tab?.id : null; - - // Only process messages from tab navigation - if (senderTabId == null) { - return false; - } - - // Handle Dangerous Continue to Phishing Domain - if (message.command === PhishingDetectionMessage.Continue) { - this._logService.debug( - "[PhishingDetectionService] User requested continue to phishing domain on tab: ", - senderTabId, - ); - - this._setCaughtTabContinue(senderTabId); - void this._continueToDangerousUrl(senderTabId); - return true; - } - - // Handle Close Phishing Warning Page - if (message.command === PhishingDetectionMessage.Close) { - this._logService.debug( - "[PhishingDetectionService] User requested to close phishing warning page on tab: ", - senderTabId, - ); - - void BrowserApi.closeTab(senderTabId); - this._removeCaughtTab(senderTabId); - return true; - } - - return false; - } - - /** - * Filter out navigation events that are to warning pages or not complete, check for phishing domains, - * then handle the navigation appropriately. - */ - private static async _processNavigation( - tabId: number, - changeInfo: chrome.tabs.OnUpdatedInfo, - tab: chrome.tabs.Tab, - ): Promise { - if (changeInfo.status !== "complete" || !tab.url) { - // Not a complete navigation or no URL to check - return; - } - // Check if navigating to a warning page to ignore - const isWarningPage = this._isWarningPage(tabId, tab.url); - if (isWarningPage) { - this._logService.debug( - `[PhishingDetectionService] Ignoring navigation to warning page for tab ${tabId}: ${tab.url}`, - ); - return; - } - - // Check if tab is navigating to a phishing url and handle navigation - this._checkTabForPhishing(tabId, new URL(tab.url)); - await this._handleTabNavigation(tabId); - } - - private static _handleNavigationEvent( + private static _handleTabUpdated( tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo, tab: chrome.tabs.Tab, ): boolean { - this._navigationEventsSubject.next({ tabId, changeInfo, tab }); + this._tabUpdated$.next({ tabId, changeInfo, tab }); // Return value for supporting BrowserApi event listener signature return true; } - /** - * Handles a replace event in Safari when redirecting to a warning page - * - * @returns true if the replacement was handled, false otherwise - */ - private static _handleReplacementEvent(newTabId: number, originalTabId: number): boolean { - if (this._caughtTabs.has(originalTabId)) { - this._logService.debug( - `[PhishingDetectionService] Handling original tab ${originalTabId} changing to new tab ${newTabId}`, - ); - - // Handle replacement - const originalCaughtTab = this._caughtTabs.get(originalTabId); - if (originalCaughtTab) { - this._caughtTabs.set(newTabId, originalCaughtTab); - this._caughtTabs.delete(originalTabId); - } else { - this._logService.debug( - `[PhishingDetectionService] Original caught tab not found, ignoring replacement.`, - ); - } - return true; - } - return false; - } - - /** - * Adds a tab to the caught tabs map with the requested continue status set to false - * - * @param tabId The ID of the tab that was caught - * @param url The URL of the tab that was caught - * @param redirectedTo The URL that the tab was redirected to - */ - private static _addCaughtTab(tabId: PhishingDetectionTabId, url: URL) { - const redirectedTo = this._createWarningPageUrl(url); - const newTab = { url, warningPageUrl: redirectedTo, requestedContinue: false }; - - this._caughtTabs.set(tabId, newTab); - this._logService.debug("[PhishingDetectionService] Tracking new tab:", tabId, newTab); - } - - /** - * Removes a tab from the caught tabs map - * - * @param tabId The ID of the tab to remove - */ - private static _removeCaughtTab(tabId: PhishingDetectionTabId) { - this._logService.debug("[PhishingDetectionService] Removing tab from tracking: ", tabId); - this._caughtTabs.delete(tabId); - } - - /** - * Sets the requested continue status for a caught tab - * - * @param tabId The ID of the tab to set the continue status for - */ - private static _setCaughtTabContinue(tabId: PhishingDetectionTabId) { - const caughtTab = this._caughtTabs.get(tabId); - if (caughtTab) { - this._caughtTabs.set(tabId, { - url: caughtTab.url, - warningPageUrl: caughtTab.warningPageUrl, - requestedContinue: true, - }); - } - } - - /** - * Checks if the tab should continue to a dangerous domain - * - * @param tabId Tab to check if a domain was caught - * @returns True if the user requested to continue to the phishing domain - */ - private static _continueToCaughtDomain(tabId: PhishingDetectionTabId) { - const caughtDomain = this._caughtTabs.get(tabId); - const hasRequestedContinue = caughtDomain?.requestedContinue; - return caughtDomain && hasRequestedContinue; - } - - /** - * Checks if the tab is going to a phishing domain and updates the caught tabs map - * - * @param tabId Tab to check for phishing domain - * @param url URL of the tab to check - */ - private static _checkTabForPhishing(tabId: PhishingDetectionTabId, url: URL) { - // Check if the tab already being tracked - const caughtTab = this._caughtTabs.get(tabId); - - const isPhishing = this.isPhishingDomain(url); - this._logService.debug( - `[PhishingDetectionService] Checking for phishing url. Result: ${isPhishing} on ${url}`, - ); - - // Add a new caught tab - if (!caughtTab && isPhishing) { - this._addCaughtTab(tabId, url); - } - - // The tab was caught before but has an updated url - if (caughtTab && caughtTab.url.href !== url.href) { - if (isPhishing) { - this._logService.debug( - "[PhishingDetectionService] Caught tab going to a new phishing domain:", - caughtTab.url, - ); - // The tab can be treated as a new tab, clear the old one and reset - this._removeCaughtTab(tabId); - this._addCaughtTab(tabId, url); - } else { - this._logService.debug( - "[PhishingDetectionService] Caught tab navigating away from a phishing domain", - ); - // The tab is safe - this._removeCaughtTab(tabId); - } - } - } - - /** - * Handles a phishing tab for redirection to a warning page if the user has not requested to continue - * - * @param tabId Tab to handle - * @param url URL of the tab - */ - private static async _handleTabNavigation(tabId: PhishingDetectionTabId) { - const caughtTab = this._caughtTabs.get(tabId); - - if (caughtTab && !this._continueToCaughtDomain(tabId)) { - await this._redirectToWarningPage(tabId); - } - } - - private static _isWarningPage(tabId: number, url: string): boolean { - const caughtTab = this._caughtTabs.get(tabId); - return !!caughtTab && caughtTab.warningPageUrl.href === url; - } - - /** - * Constructs the phishing warning page URL with the caught URL as a query parameter - * - * @param caughtUrl The URL that was caught as phishing - * @returns The complete URL to the phishing warning page - */ - private static _createWarningPageUrl(caughtUrl: URL) { - const phishingWarningPage = BrowserApi.getRuntimeURL( - "popup/index.html#/security/phishing-warning", - ); - const pageWithViewData = `${phishingWarningPage}?phishingHost=${caughtUrl.hostname}`; - this._logService.debug( - "[PhishingDetectionService] Created phishing warning page url:", - pageWithViewData, - ); - return new URL(pageWithViewData); - } - - /** - * Redirects the tab to the phishing warning page - * - * @param tabId The ID of the tab to redirect - */ - private static async _redirectToWarningPage(tabId: number) { - const tabToRedirect = this._caughtTabs.get(tabId); - - if (tabToRedirect) { - this._logService.info("[PhishingDetectionService] Redirecting to warning page"); - await BrowserApi.navigateTabToUrl(tabId, tabToRedirect.warningPageUrl); - } else { - this._logService.warning("[PhishingDetectionService] No caught tab found for redirection"); - } - } - - /** - * Sets up periodic updates for phishing domains - */ - private static _setupPeriodicUpdates() { - // Clean up any existing subscriptions - if (this._updateCacheSubscription) { - this._updateCacheSubscription.unsubscribe(); - } - if (this._retrySubscription) { - this._retrySubscription.unsubscribe(); - } - - this._updateCacheSubscription = this._taskSchedulerService.setInterval( - ScheduledTaskNames.phishingDomainUpdate, - this._UPDATE_INTERVAL, - ); - } - - /** - * Schedules a retry for updating phishing domains if the update fails - */ - private static _scheduleRetry() { - // If we've exceeded max retries, stop retrying - if (this._retryCount >= this._MAX_RETRIES) { - this._logService.warning( - `[PhishingDetectionService] Max retries (${this._MAX_RETRIES}) reached for phishing domain update. Will try again in ${this._UPDATE_INTERVAL / (1000 * 60 * 60)} hours.`, - ); - this._retryCount = 0; - if (this._retrySubscription) { - this._retrySubscription.unsubscribe(); - this._retrySubscription = null; - } - return; - } - - // Clean up existing retry subscription if any - if (this._retrySubscription) { - this._retrySubscription.unsubscribe(); - } - - // Increment retry count - this._retryCount++; - - // Schedule a retry in 5 minutes - this._retrySubscription = this._taskSchedulerService.setInterval( - ScheduledTaskNames.phishingDomainUpdate, - this._RETRY_INTERVAL, - ); - - this._logService.info( - `[PhishingDetectionService] Scheduled retry ${this._retryCount}/${this._MAX_RETRIES} for phishing domain update in ${this._RETRY_INTERVAL / (1000 * 60)} minutes`, - ); - } - - /** - * Handles adding test phishing URLs from dev flags for testing purposes - */ - private static _handleTestUrls() { - if (devFlagEnabled("testPhishingUrls")) { - const testPhishingUrls = devFlagValue("testPhishingUrls"); - this._logService.debug( - "[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing domains:", - testPhishingUrls, - ); - if (testPhishingUrls && testPhishingUrls instanceof Array) { - testPhishingUrls.forEach((domain) => { - if (domain && typeof domain === "string") { - this._knownPhishingDomains.add(domain); - } - }); - } - } - } - - /** - * Loads cached phishing domains from storage - * If no cache exists or it is expired, fetches the latest domains - */ - private static async _loadCachedDomains() { - try { - const cachedData = await this._storageService.get<{ domains: string[]; timestamp: number }>( - this._STORAGE_KEY, - ); - if (cachedData) { - this._logService.info("[PhishingDetectionService] Phishing cachedData exists"); - const phishingDomains = cachedData.domains || []; - - this._setKnownPhishingDomains(phishingDomains); - this._handleTestUrls(); - } - - // If cache is empty or expired, trigger an immediate update - if ( - this._knownPhishingDomains.size === 0 || - Date.now() - this._lastUpdateTime >= this._UPDATE_INTERVAL - ) { - await this._fetchKnownPhishingDomains(); - } - } catch (error) { - this._logService.error( - "[PhishingDetectionService] Failed to load cached phishing domains:", - error, - ); - this._handleTestUrls(); - } - } - - /** - * Fetches the latest known phishing domains from the audit service - * Updates the cache and handles retries if necessary - */ - static async _fetchKnownPhishingDomains(): Promise { - let domains: string[] = []; - - // Prevent concurrent updates - if (this._isUpdating) { - this._logService.warning( - "[PhishingDetectionService] Update already in progress, skipping...", - ); - return; - } - - try { - this._logService.info("[PhishingDetectionService] Starting phishing domains update..."); - this._isUpdating = true; - domains = await this._auditService.getKnownPhishingDomains(); - this._setKnownPhishingDomains(domains); - - await this._saveDomains(); - - this._resetRetry(); - this._isUpdating = false; - - this._logService.info("[PhishingDetectionService] Successfully fetched domains"); - } catch (error) { - this._logService.error( - "[PhishingDetectionService] Failed to fetch known phishing domains.", - error, - ); - - this._scheduleRetry(); - this._isUpdating = false; - - throw error; - } - } - - /** - * Saves the known phishing domains to storage - * Caches the updated domains and updates the last update time - */ - private static async _saveDomains() { - try { - // Cache the updated domains - await this._storageService.save(this._STORAGE_KEY, { - domains: Array.from(this._knownPhishingDomains), - timestamp: this._lastUpdateTime, - }); - this._logService.info( - `[PhishingDetectionService] Updated phishing domains cache with ${this._knownPhishingDomains.size} domains`, - ); - } catch (error) { - this._logService.error( - "[PhishingDetectionService] Failed to save known phishing domains.", - error, - ); - this._scheduleRetry(); - throw error; - } - } - - /** - * Resets the retry count and clears the retry subscription - */ - private static _resetRetry(): void { - this._logService.info( - `[PhishingDetectionService] Resetting retry count and clearing retry subscription.`, - ); - // Reset retry count and clear retry subscription on success - this._retryCount = 0; - if (this._retrySubscription) { - this._retrySubscription.unsubscribe(); - this._retrySubscription = null; - } - } - - /** - * Adds phishing domains to the known phishing domains set - * Clears old domains to prevent memory leaks - * - * @param domains Array of phishing domains to add - */ - private static _setKnownPhishingDomains(domains: string[]): void { - this._logService.debug( - `[PhishingDetectionService] Tracking ${domains.length} phishing domains`, - ); - - // Clear old domains to prevent memory leaks - this._knownPhishingDomains.clear(); - - domains.forEach((domain: string) => { - if (domain) { - this._knownPhishingDomains.add(domain); - } - }); - this._lastUpdateTime = Date.now(); - } - - /** - * Cleans up the phishing detection service - * Unsubscribes from all subscriptions and clears caches - */ - private static _cleanup() { - if (this._updateCacheSubscription) { - this._updateCacheSubscription.unsubscribe(); - this._updateCacheSubscription = null; - } - if (this._retrySubscription) { - this._retrySubscription.unsubscribe(); - this._retrySubscription = null; - } - if (this._navigationEvents) { - this._navigationEvents.unsubscribe(); - this._navigationEvents = null; - } - this._knownPhishingDomains.clear(); - this._caughtTabs.clear(); - this._lastUpdateTime = 0; - this._isUpdating = false; - this._isInitialized = false; - this._retryCount = 0; - - // Manually type cast to satisfy the listener signature due to the mixture - // of static and instance methods in this class. To be fixed when refactoring - // this class to be instance-based while providing a singleton instance in usage - BrowserApi.removeListener( - chrome.runtime.onMessage, - PhishingDetectionService._handleExtensionMessage as (...args: readonly unknown[]) => unknown, - ); - BrowserApi.removeListener( - chrome.tabs.onReplaced, - PhishingDetectionService._handleReplacementEvent as (...args: readonly unknown[]) => unknown, - ); - BrowserApi.removeListener( - chrome.tabs.onUpdated, - PhishingDetectionService._handleNavigationEvent as (...args: readonly unknown[]) => unknown, + private static _isExtensionPage(url: string): boolean { + // Check against all common extension protocols + return ( + url.startsWith("chrome-extension://") || + url.startsWith("moz-extension://") || + url.startsWith("safari-extension://") || + url.startsWith("safari-web-extension://") ); } } diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.types.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.types.ts deleted file mode 100644 index 21793616241..00000000000 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.types.ts +++ /dev/null @@ -1,35 +0,0 @@ -export const PhishingDetectionMessage = Object.freeze({ - Close: "phishing-detection-close", - Continue: "phishing-detection-continue", -} as const); - -export type PhishingDetectionMessageTypes = - (typeof PhishingDetectionMessage)[keyof typeof PhishingDetectionMessage]; - -export function isPhishingDetectionMessage( - input: unknown, -): input is { command: PhishingDetectionMessageTypes } { - if (!!input && typeof input === "object" && "command" in input) { - const command = (input as Record)["command"]; - if (typeof command === "string") { - return Object.values(PhishingDetectionMessage).includes( - command as PhishingDetectionMessageTypes, - ); - } - } - return false; -} - -export type PhishingDetectionTabId = number; - -export type CaughtPhishingDomain = { - url: URL; - warningPageUrl: URL; - requestedContinue: boolean; -}; - -export type PhishingDetectionNavigationEvent = { - tabId: number; - changeInfo: chrome.tabs.OnUpdatedInfo; - tab: chrome.tabs.Tab; -}; diff --git a/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.spec.ts b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.spec.ts index 4017953ee28..5bbba77d12e 100644 --- a/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.spec.ts +++ b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.spec.ts @@ -1,5 +1,6 @@ import { mock } from "jest-mock-extended"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -18,6 +19,7 @@ describe("background browser biometrics service tests", function () { const biometricStateService = mock(); const messagingService = mock(); const vaultTimeoutSettingsService = mock(); + const pinService = mock(); beforeEach(() => { jest.resetAllMocks(); @@ -28,6 +30,7 @@ describe("background browser biometrics service tests", function () { biometricStateService, messagingService, vaultTimeoutSettingsService, + pinService, ); }); 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 8f755cfeda6..c8be58b0bde 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 @@ -1,6 +1,7 @@ import { combineLatest, timer } from "rxjs"; import { filter, concatMap } from "rxjs/operators"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -29,6 +30,7 @@ export class BackgroundBrowserBiometricsService extends BiometricsService { private biometricStateService: BiometricStateService, private messagingService: MessagingService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, + private pinService: PinServiceAbstraction, ) { super(); // Always connect to the native messaging background if biometrics are enabled, not just when it is used @@ -101,6 +103,7 @@ export class BackgroundBrowserBiometricsService extends BiometricsService { if (await this.keyService.validateUserKey(userKey, userId)) { await this.biometricStateService.setBiometricUnlockEnabled(true); await this.keyService.setUserKey(userKey, userId); + await this.pinService.userUnlocked(userId); // to update badge and other things this.messagingService.send("switchAccount", { userId }); return userKey; diff --git a/apps/browser/src/key-management/key-connector/remove-password.component.ts b/apps/browser/src/key-management/key-connector/remove-password.component.ts index 915effc8c33..c4077a1eca9 100644 --- a/apps/browser/src/key-management/key-connector/remove-password.component.ts +++ b/apps/browser/src/key-management/key-connector/remove-password.component.ts @@ -4,6 +4,8 @@ import { Component } from "@angular/core"; import { RemovePasswordComponent as BaseRemovePasswordComponent } from "@bitwarden/key-management-ui"; +// 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-remove-password", templateUrl: "remove-password.component.html", diff --git a/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts new file mode 100644 index 00000000000..297718687eb --- /dev/null +++ b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts @@ -0,0 +1,58 @@ +import { defer, Observable, of } from "rxjs"; + +import { + VaultTimeout, + VaultTimeoutOption, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui"; + +export class BrowserSessionTimeoutSettingsComponentService + implements SessionTimeoutSettingsComponentService +{ + availableTimeoutOptions$: Observable = defer(() => { + const options: VaultTimeoutOption[] = [ + { name: this.i18nService.t("immediately"), value: 0 }, + { name: this.i18nService.t("oneMinute"), value: 1 }, + { name: this.i18nService.t("fiveMinutes"), value: 5 }, + { name: this.i18nService.t("fifteenMinutes"), value: 15 }, + { name: this.i18nService.t("thirtyMinutes"), value: 30 }, + { name: this.i18nService.t("oneHour"), value: 60 }, + { name: this.i18nService.t("fourHours"), value: 240 }, + ]; + + const showOnLocked = + !this.platformUtilsService.isFirefox() && + !this.platformUtilsService.isSafari() && + !(this.platformUtilsService.isOpera() && navigator.platform === "MacIntel"); + + if (showOnLocked) { + options.push({ + name: this.i18nService.t("onLocked"), + value: VaultTimeoutStringType.OnLocked, + }); + } + + options.push( + { name: this.i18nService.t("onRestart"), value: VaultTimeoutStringType.OnRestart }, + { name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never }, + ); + + return of(options); + }); + + constructor( + private readonly i18nService: I18nService, + private readonly platformUtilsService: PlatformUtilsService, + private readonly messagingService: MessagingService, + ) {} + + onTimeoutSave(timeout: VaultTimeout): void { + if (timeout === VaultTimeoutStringType.Never) { + this.messagingService.send("bgReseedStorage"); + } + } +} diff --git a/apps/browser/src/key-management/vault-timeout/foreground-vault-timeout.service.ts b/apps/browser/src/key-management/vault-timeout/foreground-vault-timeout.service.ts index 5003dfd5b29..8bad50bfae9 100644 --- a/apps/browser/src/key-management/vault-timeout/foreground-vault-timeout.service.ts +++ b/apps/browser/src/key-management/vault-timeout/foreground-vault-timeout.service.ts @@ -2,19 +2,10 @@ // @ts-strict-ignore import { VaultTimeoutService as BaseVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout/abstractions/vault-timeout.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { UserId } from "@bitwarden/common/types/guid"; export class ForegroundVaultTimeoutService implements BaseVaultTimeoutService { constructor(protected messagingService: MessagingService) {} // should only ever run in background async checkVaultTimeout(): Promise {} - - async lock(userId?: UserId): Promise { - this.messagingService.send("lockVault", { userId }); - } - - async logOut(userId?: string): Promise { - this.messagingService.send("logout", { userId }); - } } diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 2a45c846060..1651f616e03 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.9.0", + "version": "2025.12.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 6eeac8c8b39..67399192b64 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.9.0", + "version": "2025.12.0", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", @@ -164,7 +164,8 @@ "overlay/menu.html", "popup/fonts/*" ], - "matches": [""] + "matches": [""], + "use_dynamic_url": true } ], "__firefox__browser_specific_settings": { diff --git a/apps/browser/src/platform/badge/badge-browser-api.ts b/apps/browser/src/platform/badge/badge-browser-api.ts index 79b50970400..80f84c3b46e 100644 --- a/apps/browser/src/platform/badge/badge-browser-api.ts +++ b/apps/browser/src/platform/badge/badge-browser-api.ts @@ -1,4 +1,16 @@ -import { concat, defer, filter, map, merge, Observable, shareReplay, switchMap } from "rxjs"; +import { + concat, + concatMap, + defer, + filter, + map, + merge, + Observable, + of, + pairwise, + shareReplay, + switchMap, +} from "rxjs"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -28,13 +40,37 @@ function tabFromChromeTab(tab: chrome.tabs.Tab): Tab { } export interface BadgeBrowserApi { - activeTabsUpdated$: Observable; + /** + * An observable that emits all currently active tabs whenever one or more active tabs change. + */ + activeTabs$: Observable; + /** + * An observable that emits tab events such as updates and activations. + */ + tabEvents$: Observable; + + /** + * Set the badge state for a specific tab. + * If the tabId is undefined the state will be applied to the browser action in general. + */ setState(state: RawBadgeState, tabId?: number): Promise; - getTabs(): Promise; - getActiveTabs(): Promise; } +export type TabEvent = + | { + type: "updated"; + tab: Tab; + } + | { + type: "activated"; + tab: Tab; + } + | { + type: "deactivated"; + tabId: number; + }; + export class DefaultBadgeBrowserApi implements BadgeBrowserApi { private badgeAction = BrowserApi.getBrowserAction(); private sidebarAction = BrowserApi.getSidebarAction(self); @@ -44,18 +80,25 @@ export class DefaultBadgeBrowserApi implements BadgeBrowserApi { shareReplay({ bufferSize: 1, refCount: true }), ); - activeTabsUpdated$ = concat( - defer(async () => await this.getActiveTabs()), + private createdOrUpdatedTabEvents$ = concat( + defer(async () => await this.getActiveTabs()).pipe( + switchMap((activeTabs) => { + const tabEvents: TabEvent[] = activeTabs.map((tab) => ({ + type: "activated", + tab, + })); + return of(...tabEvents); + }), + ), merge( this.onTabActivated$.pipe( - switchMap(async (activeInfo) => { - const tab = await BrowserApi.getTab(activeInfo.tabId); - - if (tab == undefined || tab.id == undefined || tab.url == undefined) { - return []; - } - - return [tabFromChromeTab(tab)]; + switchMap(async (activeInfo) => await BrowserApi.getTab(activeInfo.tabId)), + filter( + (tab): tab is chrome.tabs.Tab => + !(tab == undefined || tab.id == undefined || tab.url == undefined), + ), + switchMap(async (tab) => { + return { type: "activated", tab: tabFromChromeTab(tab) } satisfies TabEvent; }), ), fromChromeEvent(chrome.tabs.onUpdated).pipe( @@ -64,22 +107,58 @@ export class DefaultBadgeBrowserApi implements BadgeBrowserApi { // Only emit if the url was updated changeInfo.url != undefined, ), - map(([_tabId, _changeInfo, tab]) => [tabFromChromeTab(tab)]), + map( + ([_tabId, _changeInfo, tab]) => + ({ type: "updated", tab: tabFromChromeTab(tab) }) satisfies TabEvent, + ), ), fromChromeEvent(chrome.webNavigation.onCommitted).pipe( + filter(([details]) => details.transitionType === "reload"), map(([details]) => { - const toReturn: Tab[] = - details.transitionType === "reload" ? [{ tabId: details.tabId, url: details.url }] : []; - return toReturn; + return { + type: "updated", + tab: { tabId: details.tabId, url: details.url }, + } satisfies TabEvent; }), ), // NOTE: We're only sharing the active tab changes, not the full list of active tabs. // This is so that any new subscriber will get the latest active tabs immediately, but // doesn't re-subscribe to chrome events. ).pipe(shareReplay({ bufferSize: 1, refCount: true })), - ).pipe(filter((tabs) => tabs.length > 0)); + ); - async getActiveTabs(): Promise { + tabEvents$ = merge( + this.createdOrUpdatedTabEvents$, + this.createdOrUpdatedTabEvents$.pipe( + concatMap(async () => { + return this.getActiveTabs(); + }), + pairwise(), + map(([previousTabs, currentTabs]) => { + const previousTabIds = previousTabs.map((t) => t.tabId); + const currentTabIds = currentTabs.map((t) => t.tabId); + + const deactivatedTabIds = previousTabIds.filter((id) => !currentTabIds.includes(id)); + + return deactivatedTabIds.map( + (tabId) => + ({ + type: "deactivated", + tabId, + }) satisfies TabEvent, + ); + }), + switchMap((events) => of(...events)), + ), + ); + + activeTabs$ = this.tabEvents$.pipe( + concatMap(async () => { + return this.getActiveTabs(); + }), + ); + + private async getActiveTabs(): Promise { const tabs = await BrowserApi.getActiveTabs(); return tabs.filter((tab) => tab.id != undefined && tab.url != undefined).map(tabFromChromeTab); } @@ -96,10 +175,6 @@ export class DefaultBadgeBrowserApi implements BadgeBrowserApi { ]); } - async getTabs(): Promise { - return (await BrowserApi.tabsQuery({})).map((tab) => tab.id).filter((tab) => tab !== undefined); - } - private setIcon(icon: IconPaths, tabId?: number) { return Promise.all([this.setActionIcon(icon, tabId), this.setSidebarActionIcon(icon, tabId)]); } diff --git a/apps/browser/src/platform/badge/badge.service.spec.ts b/apps/browser/src/platform/badge/badge.service.spec.ts index d17e5dc0b5f..815941541e6 100644 --- a/apps/browser/src/platform/badge/badge.service.spec.ts +++ b/apps/browser/src/platform/badge/badge.service.spec.ts @@ -1,11 +1,10 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { Subscription } from "rxjs"; +import { EMPTY, Observable, of, Subscription } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { FakeAccountService, FakeStateProvider } from "@bitwarden/common/spec"; import { RawBadgeState } from "./badge-browser-api"; -import { BadgeService } from "./badge.service"; +import { BadgeService, BadgeStateFunction } from "./badge.service"; import { DefaultBadgeState } from "./consts"; import { BadgeIcon } from "./icon"; import { BadgeStatePriority } from "./priority"; @@ -14,7 +13,6 @@ import { MockBadgeBrowserApi } from "./test/mock-badge-browser-api"; describe("BadgeService", () => { let badgeApi: MockBadgeBrowserApi; - let stateProvider: FakeStateProvider; let logService!: MockProxy; let badgeService!: BadgeService; @@ -22,626 +20,834 @@ describe("BadgeService", () => { beforeEach(() => { badgeApi = new MockBadgeBrowserApi(); - stateProvider = new FakeStateProvider(new FakeAccountService({})); logService = mock(); - badgeService = new BadgeService(stateProvider, badgeApi, logService); + badgeService = new BadgeService(badgeApi, logService, 0); }); afterEach(() => { badgeServiceSubscription?.unsubscribe(); }); - describe("calling without tabId", () => { - const tabId = 1; - - describe("given a single tab is open", () => { - beforeEach(() => { - badgeApi.tabs = [tabId]; - badgeApi.setActiveTabs([tabId]); - badgeServiceSubscription = badgeService.startListening(); - }); - - it("sets provided state when no other state has been set", async () => { - const state: BadgeState = { - text: "text", - backgroundColor: "color", - icon: BadgeIcon.Locked, - }; - - await badgeService.setState("state-name", BadgeStatePriority.Default, state); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual(state); - }); - - it("sets default values when none are provided", async () => { - // This is a bit of a weird thing to do, but I don't think it's something we need to prohibit - const state: BadgeState = {}; - - await badgeService.setState("state-name", BadgeStatePriority.Default, state); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); - }); - - it("merges states when multiple same-priority states have been set", async () => { - await badgeService.setState("state-1", BadgeStatePriority.Default, { text: "text" }); - await badgeService.setState("state-2", BadgeStatePriority.Default, { - backgroundColor: "#fff", - }); - await badgeService.setState("state-3", BadgeStatePriority.Default, { - icon: BadgeIcon.Locked, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - const expectedState: RawBadgeState = { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }; - expect(badgeApi.specificStates[tabId]).toEqual(expectedState); - }); - - it("overrides previous lower-priority state when higher-priority state is set", async () => { - await badgeService.setState("state-1", BadgeStatePriority.Low, { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }); - await badgeService.setState("state-2", BadgeStatePriority.Default, { - text: "override", - }); - await badgeService.setState("state-3", BadgeStatePriority.High, { - backgroundColor: "#aaa", - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - const expectedState: RawBadgeState = { - text: "override", - backgroundColor: "#aaa", - icon: BadgeIcon.Locked, - }; - expect(badgeApi.specificStates[tabId]).toEqual(expectedState); - }); - - it("removes override when a previously high-priority state is cleared", async () => { - await badgeService.setState("state-1", BadgeStatePriority.Low, { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }); - await badgeService.setState("state-2", BadgeStatePriority.Default, { - text: "override", - }); - await badgeService.clearState("state-2"); - - await new Promise((resolve) => setTimeout(resolve, 0)); - const expectedState: RawBadgeState = { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }; - expect(badgeApi.specificStates[tabId]).toEqual(expectedState); - }); - - it("sets default values when all states have been cleared", async () => { - await badgeService.setState("state-1", BadgeStatePriority.Low, { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }); - await badgeService.setState("state-2", BadgeStatePriority.Default, { - text: "override", - }); - await badgeService.setState("state-3", BadgeStatePriority.High, { - backgroundColor: "#aaa", - }); - await badgeService.clearState("state-1"); - await badgeService.clearState("state-2"); - await badgeService.clearState("state-3"); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); - }); - - it("sets default value high-priority state contains Unset", async () => { - await badgeService.setState("state-1", BadgeStatePriority.Low, { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }); - await badgeService.setState("state-3", BadgeStatePriority.High, { - icon: Unset, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - const expectedState: RawBadgeState = { - text: "text", - backgroundColor: "#fff", - icon: DefaultBadgeState.icon, - }; - expect(badgeApi.specificStates[tabId]).toEqual(expectedState); - }); - - it("ignores medium-priority Unset when high-priority contains a value", async () => { - await badgeService.setState("state-1", BadgeStatePriority.Low, { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }); - await badgeService.setState("state-3", BadgeStatePriority.Default, { - icon: Unset, - }); - await badgeService.setState("state-3", BadgeStatePriority.High, { - icon: BadgeIcon.Unlocked, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - const expectedState: RawBadgeState = { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Unlocked, - }; - expect(badgeApi.specificStates[tabId]).toEqual(expectedState); - }); - }); - - describe("given multiple tabs are open, only one active", () => { - const tabId = 1; - const tabIds = [1, 2, 3]; - - beforeEach(() => { - badgeApi.tabs = tabIds; - badgeApi.setActiveTabs([tabId]); - badgeServiceSubscription = badgeService.startListening(); - }); - - it("sets general state for active tab when no other state has been set", async () => { - const state: BadgeState = { - text: "text", - backgroundColor: "color", - icon: BadgeIcon.Locked, - }; - - await badgeService.setState("state-name", BadgeStatePriority.Default, state); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates).toEqual({ - 1: state, - 2: undefined, - 3: undefined, - }); - }); - - it("only updates the active tab when setting state", async () => { - const state: BadgeState = { - text: "text", - backgroundColor: "color", - icon: BadgeIcon.Locked, - }; - badgeApi.setState.mockReset(); - - await badgeService.setState("state-1", BadgeStatePriority.Default, state, tabId); - await badgeService.setState("state-2", BadgeStatePriority.Default, state, 2); - await badgeService.setState("state-2", BadgeStatePriority.Default, state, 2); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.setState).toHaveBeenCalledTimes(1); - }); - }); - - describe("given multiple tabs are open and multiple are active", () => { - const activeTabIds = [1, 2]; - const tabIds = [1, 2, 3]; - - beforeEach(() => { - badgeApi.tabs = tabIds; - badgeApi.setActiveTabs(activeTabIds); - badgeServiceSubscription = badgeService.startListening(); - }); - - it("sets general state for active tabs when no other state has been set", async () => { - const state: BadgeState = { - text: "text", - backgroundColor: "color", - icon: BadgeIcon.Locked, - }; - - await badgeService.setState("state-name", BadgeStatePriority.Default, state); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates).toEqual({ - 1: state, - 2: state, - 3: undefined, - }); - }); - - it("only updates the active tabs when setting general state", async () => { - const state: BadgeState = { - text: "text", - backgroundColor: "color", - icon: BadgeIcon.Locked, - }; - badgeApi.setState.mockReset(); - - await badgeService.setState("state-1", BadgeStatePriority.Default, state, 1); - await badgeService.setState("state-2", BadgeStatePriority.Default, state, 2); - await badgeService.setState("state-3", BadgeStatePriority.Default, state, 3); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.setState).toHaveBeenCalledTimes(2); - }); - }); - }); - - describe("calling with tabId", () => { - describe("given a single tab is open", () => { + describe("static state", () => { + describe("calling without tabId", () => { const tabId = 1; - beforeEach(() => { - badgeApi.tabs = [tabId]; - badgeApi.setActiveTabs([tabId]); - badgeServiceSubscription = badgeService.startListening(); - }); - - it("sets provided state when no other state has been set", async () => { - const state: BadgeState = { - text: "text", - backgroundColor: "color", - icon: BadgeIcon.Locked, - }; - - await badgeService.setState("state-name", BadgeStatePriority.Default, state, tabId); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual(state); - }); - - it("sets default values when none are provided", async () => { - // This is a bit of a weird thing to do, but I don't think it's something we need to prohibit - const state: BadgeState = {}; - - await badgeService.setState("state-name", BadgeStatePriority.Default, state, tabId); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); - }); - - it("merges tabId specific state with general states", async () => { - await badgeService.setState("general-state", BadgeStatePriority.Default, { text: "text" }); - await badgeService.setState( - "specific-state", - BadgeStatePriority.Default, - { - backgroundColor: "#fff", - }, - tabId, - ); - await badgeService.setState("general-state-2", BadgeStatePriority.Default, { - icon: BadgeIcon.Locked, + describe("given a single tab is open", () => { + beforeEach(() => { + badgeApi.tabs = [tabId]; + badgeApi.setActiveTabs([tabId]); + badgeServiceSubscription = badgeService.startListening(); }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual({ - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }); - }); - - it("merges states when multiple same-priority states with the same tabId have been set", async () => { - await badgeService.setState("state-1", BadgeStatePriority.Default, { text: "text" }, tabId); - await badgeService.setState( - "state-2", - BadgeStatePriority.Default, - { - backgroundColor: "#fff", - }, - tabId, - ); - await badgeService.setState( - "state-3", - BadgeStatePriority.Default, - { + it("sets provided state when no other state has been set", async () => { + const state: BadgeState = { + text: "text", + backgroundColor: "color", icon: BadgeIcon.Locked, - }, - tabId, - ); + }; - await new Promise((resolve) => setTimeout(resolve, 0)); - const expectedState: RawBadgeState = { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }; - expect(badgeApi.specificStates[tabId]).toEqual(expectedState); - }); + await badgeService.setState( + "state-name", + GeneralStateFunction(BadgeStatePriority.Default, state), + ); - it("overrides previous lower-priority state when higher-priority state with the same tabId is set", async () => { - await badgeService.setState( - "state-1", - BadgeStatePriority.Low, - { + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual(state); + }); + + it("sets default values when none are provided", async () => { + // This is a bit of a weird thing to do, but I don't think it's something we need to prohibit + const state: BadgeState = {}; + + await badgeService.setState( + "state-name", + GeneralStateFunction(BadgeStatePriority.Default, state), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); + }); + + it("sets default values even if state function never emits", async () => { + badgeService.setState("state-name", (_tab) => EMPTY); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); + }); + + it("merges states when multiple same-priority states have been set", async () => { + await badgeService.setState( + "state-1", + GeneralStateFunction(BadgeStatePriority.Default, { text: "text" }), + ); + await badgeService.setState( + "state-2", + GeneralStateFunction(BadgeStatePriority.Default, { + backgroundColor: "#fff", + }), + ); + await badgeService.setState( + "state-3", + GeneralStateFunction(BadgeStatePriority.Default, { + icon: BadgeIcon.Locked, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { text: "text", backgroundColor: "#fff", icon: BadgeIcon.Locked, - }, - tabId, - ); - await badgeService.setState( - "state-2", - BadgeStatePriority.Default, - { + }; + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); + }); + + it("overrides previous lower-priority state when higher-priority state is set", async () => { + await badgeService.setState( + "state-1", + GeneralStateFunction(BadgeStatePriority.Low, { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }), + ); + await badgeService.setState( + "state-2", + GeneralStateFunction(BadgeStatePriority.Default, { + text: "override", + }), + ); + await badgeService.setState( + "state-3", + GeneralStateFunction(BadgeStatePriority.High, { + backgroundColor: "#aaa", + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { text: "override", - }, - tabId, - ); - await badgeService.setState( - "state-3", - BadgeStatePriority.High, - { backgroundColor: "#aaa", - }, - tabId, - ); + icon: BadgeIcon.Locked, + }; + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); + }); - await new Promise((resolve) => setTimeout(resolve, 0)); - const expectedState: RawBadgeState = { - text: "override", - backgroundColor: "#aaa", - icon: BadgeIcon.Locked, - }; - expect(badgeApi.specificStates[tabId]).toEqual(expectedState); - }); + it("removes override when a previously high-priority state is cleared", async () => { + await badgeService.setState( + "state-1", + GeneralStateFunction(BadgeStatePriority.Low, { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }), + ); + await badgeService.setState( + "state-2", + GeneralStateFunction(BadgeStatePriority.Default, { + text: "override", + }), + ); + await badgeService.clearState("state-2"); - it("overrides lower-priority tab-specific state when higher-priority general state is set", async () => { - await badgeService.setState( - "state-1", - BadgeStatePriority.Low, - { + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { text: "text", backgroundColor: "#fff", icon: BadgeIcon.Locked, - }, - tabId, - ); - await badgeService.setState("state-2", BadgeStatePriority.Default, { - text: "override", - }); - await badgeService.setState("state-3", BadgeStatePriority.High, { - backgroundColor: "#aaa", + }; + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual({ - text: "override", - backgroundColor: "#aaa", - icon: BadgeIcon.Locked, - }); - }); + it("sets default values when all states have been cleared", async () => { + await badgeService.setState( + "state-1", + GeneralStateFunction(BadgeStatePriority.Low, { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }), + ); + await badgeService.setState( + "state-2", + GeneralStateFunction(BadgeStatePriority.Default, { + text: "override", + }), + ); + await badgeService.setState( + "state-3", + GeneralStateFunction(BadgeStatePriority.High, { + backgroundColor: "#aaa", + }), + ); + await badgeService.clearState("state-1"); + await badgeService.clearState("state-2"); + await badgeService.clearState("state-3"); - it("removes override when a previously high-priority state with the same tabId is cleared", async () => { - await badgeService.setState( - "state-1", - BadgeStatePriority.Low, - { + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); + }); + + it("sets default value high-priority state contains Unset", async () => { + await badgeService.setState( + "state-1", + GeneralStateFunction(BadgeStatePriority.Low, { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }), + ); + await badgeService.setState( + "state-3", + GeneralStateFunction(BadgeStatePriority.High, { + icon: Unset, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { text: "text", backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }, - tabId, - ); - await badgeService.setState( - "state-2", - BadgeStatePriority.Default, - { - text: "override", - }, - tabId, - ); - await badgeService.clearState("state-2"); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual({ - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, + icon: DefaultBadgeState.icon, + }; + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); }); - }); - it("sets default state when all states with the same tabId have been cleared", async () => { - await badgeService.setState( - "state-1", - BadgeStatePriority.Low, - { + it("ignores medium-priority Unset when high-priority contains a value", async () => { + await badgeService.setState( + "state-1", + GeneralStateFunction(BadgeStatePriority.Low, { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }), + ); + await badgeService.setState( + "state-3", + GeneralStateFunction(BadgeStatePriority.Default, { + icon: Unset, + }), + ); + await badgeService.setState( + "state-3", + GeneralStateFunction(BadgeStatePriority.High, { + icon: BadgeIcon.Unlocked, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { text: "text", backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }, - tabId, - ); - await badgeService.setState( - "state-2", - BadgeStatePriority.Default, - { - text: "override", - }, - tabId, - ); - await badgeService.setState( - "state-3", - BadgeStatePriority.High, - { - backgroundColor: "#aaa", - }, - tabId, - ); - await badgeService.clearState("state-1"); - await badgeService.clearState("state-2"); - await badgeService.clearState("state-3"); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); - }); - - it("sets default value when high-priority state contains Unset", async () => { - await badgeService.setState( - "state-1", - BadgeStatePriority.Low, - { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }, - tabId, - ); - await badgeService.setState( - "state-3", - BadgeStatePriority.High, - { - icon: Unset, - }, - tabId, - ); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual({ - text: "text", - backgroundColor: "#fff", - icon: DefaultBadgeState.icon, - }); - }); - - it("ignores medium-priority Unset when high-priority contains a value", async () => { - await badgeService.setState( - "state-1", - BadgeStatePriority.Low, - { - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Locked, - }, - tabId, - ); - await badgeService.setState( - "state-3", - BadgeStatePriority.Default, - { - icon: Unset, - }, - tabId, - ); - await badgeService.setState( - "state-3", - BadgeStatePriority.High, - { icon: BadgeIcon.Unlocked, - }, - tabId, - ); + }; + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); + }); + }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates[tabId]).toEqual({ - text: "text", - backgroundColor: "#fff", - icon: BadgeIcon.Unlocked, + describe("given multiple tabs are open, only one active", () => { + const tabId = 1; + const tabIds = [1, 2, 3]; + + beforeEach(() => { + badgeApi.tabs = tabIds; + badgeApi.setActiveTabs([tabId]); + badgeServiceSubscription = badgeService.startListening(); + }); + + it("sets general state for active tab when no other state has been set", async () => { + const state: BadgeState = { + text: "text", + backgroundColor: "color", + icon: BadgeIcon.Locked, + }; + + await badgeService.setState( + "state-name", + GeneralStateFunction(BadgeStatePriority.Default, state), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates).toEqual({ + 1: state, + 2: undefined, + 3: undefined, + }); + }); + + it("only updates the active tab when setting state", async () => { + const state: BadgeState = { + text: "text", + backgroundColor: "color", + icon: BadgeIcon.Locked, + }; + await badgeService.setState( + "state-1", + TabSpecificStateFunction(BadgeStatePriority.Default, state, tabId), + ); + await badgeService.setState( + "state-2", + TabSpecificStateFunction(BadgeStatePriority.Default, state, 2), + ); + await badgeService.setState( + "state-2", + TabSpecificStateFunction(BadgeStatePriority.Default, state, 2), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + badgeApi.setState.mockReset(); + badgeApi.updateTab(tabId); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.setState).toHaveBeenCalledTimes(1); + }); + }); + + describe("given multiple tabs are open and multiple are active", () => { + const activeTabIds = [1, 2]; + const tabIds = [1, 2, 3]; + + beforeEach(() => { + badgeApi.tabs = tabIds; + badgeApi.setActiveTabs(activeTabIds); + badgeServiceSubscription = badgeService.startListening(); + }); + + it("sets general state for active tabs when no other state has been set", async () => { + const state: BadgeState = { + text: "text", + backgroundColor: "color", + icon: BadgeIcon.Locked, + }; + + await badgeService.setState( + "state-name", + GeneralStateFunction(BadgeStatePriority.Default, state), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates).toEqual({ + 1: state, + 2: state, + 3: undefined, + }); + }); + + it("only updates the active tabs when setting general state", async () => { + const state: BadgeState = { + text: "text", + backgroundColor: "color", + icon: BadgeIcon.Locked, + }; + + await badgeService.setState( + "state-1", + TabSpecificStateFunction(BadgeStatePriority.Default, state, 1), + ); + await badgeService.setState( + "state-2", + TabSpecificStateFunction(BadgeStatePriority.Default, state, 2), + ); + await badgeService.setState( + "state-3", + TabSpecificStateFunction(BadgeStatePriority.Default, state, 3), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + badgeApi.setState.mockReset(); + badgeApi.updateTab(activeTabIds[0]); + badgeApi.updateTab(activeTabIds[1]); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.setState).toHaveBeenCalledTimes(2); }); }); }); - describe("given multiple tabs are open, only one active", () => { - const tabId = 1; - const tabIds = [1, 2, 3]; + describe("setting tab-specific states", () => { + describe("given a single tab is open", () => { + const tabId = 1; - beforeEach(() => { - badgeApi.tabs = tabIds; - badgeApi.setActiveTabs([tabId]); - badgeServiceSubscription = badgeService.startListening(); - }); - - it("sets tab-specific state for provided tab", async () => { - const generalState: BadgeState = { - text: "general-text", - backgroundColor: "general-color", - icon: BadgeIcon.Unlocked, - }; - const specificState: BadgeState = { - text: "tab-text", - icon: BadgeIcon.Locked, - }; - - await badgeService.setState("general-state", BadgeStatePriority.Default, generalState); - await badgeService.setState( - "tab-state", - BadgeStatePriority.Default, - specificState, - tabIds[0], - ); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates).toEqual({ - [tabIds[0]]: { ...specificState, backgroundColor: "general-color" }, - [tabIds[1]]: undefined, - [tabIds[2]]: undefined, + beforeEach(() => { + badgeApi.tabs = [tabId]; + badgeApi.setActiveTabs([tabId]); + badgeServiceSubscription = badgeService.startListening(); }); - }); - }); - describe("given multiple tabs are open and multiple are active", () => { - const activeTabIds = [1, 2]; - const tabIds = [1, 2, 3]; + it("sets provided state when no other state has been set", async () => { + const state: BadgeState = { + text: "text", + backgroundColor: "color", + icon: BadgeIcon.Locked, + }; - beforeEach(() => { - badgeApi.tabs = tabIds; - badgeApi.setActiveTabs(activeTabIds); - badgeServiceSubscription = badgeService.startListening(); - }); + await badgeService.setState( + "state-name", + TabSpecificStateFunction(BadgeStatePriority.Default, state, tabId), + ); - it("sets general state for all active tabs when no other state has been set", async () => { - const generalState: BadgeState = { - text: "general-text", - backgroundColor: "general-color", - icon: BadgeIcon.Unlocked, - }; + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual(state); + }); - await badgeService.setState("general-state", BadgeStatePriority.Default, generalState); + it("sets default values when none are provided", async () => { + // This is a bit of a weird thing to do, but I don't think it's something we need to prohibit + const state: BadgeState = {}; - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates).toEqual({ - [tabIds[0]]: generalState, - [tabIds[1]]: generalState, - [tabIds[2]]: undefined, + await badgeService.setState( + "state-name", + TabSpecificStateFunction(BadgeStatePriority.Default, state, tabId), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); + }); + + it("merges tabId specific state with general states", async () => { + await badgeService.setState( + "general-state", + TabSpecificStateFunction( + BadgeStatePriority.Default, + { + text: "text", + }, + tabId, + ), + ); + await badgeService.setState( + "specific-state", + TabSpecificStateFunction( + BadgeStatePriority.Default, + { + backgroundColor: "#fff", + }, + tabId, + ), + ); + await badgeService.setState( + "general-state-2", + GeneralStateFunction(BadgeStatePriority.Default, { + icon: BadgeIcon.Locked, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual({ + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }); + }); + + it("merges states when multiple same-priority states with the same tabId have been set", async () => { + await badgeService.setState( + "state-1", + TabSpecificStateFunction(BadgeStatePriority.Default, { text: "text" }, tabId), + ); + await badgeService.setState( + "state-2", + TabSpecificStateFunction( + BadgeStatePriority.Default, + { + backgroundColor: "#fff", + }, + tabId, + ), + ); + await badgeService.setState( + "state-3", + TabSpecificStateFunction( + BadgeStatePriority.Default, + { + icon: BadgeIcon.Locked, + }, + tabId, + ), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }; + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); + }); + + it("overrides previous lower-priority state when higher-priority state with the same tabId is set", async () => { + await badgeService.setState( + "state-1", + TabSpecificStateFunction( + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ), + ); + await badgeService.setState( + "state-2", + TabSpecificStateFunction( + BadgeStatePriority.Default, + { + text: "override", + }, + tabId, + ), + ); + await badgeService.setState( + "state-3", + TabSpecificStateFunction( + BadgeStatePriority.High, + { + backgroundColor: "#aaa", + }, + tabId, + ), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { + text: "override", + backgroundColor: "#aaa", + icon: BadgeIcon.Locked, + }; + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); + }); + + it("overrides lower-priority tab-specific state when higher-priority general state is set", async () => { + await badgeService.setState( + "state-1", + TabSpecificStateFunction( + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ), + ); + await badgeService.setState( + "state-2", + GeneralStateFunction(BadgeStatePriority.Default, { + text: "override", + }), + ); + await badgeService.setState( + "state-3", + GeneralStateFunction(BadgeStatePriority.High, { + backgroundColor: "#aaa", + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual({ + text: "override", + backgroundColor: "#aaa", + icon: BadgeIcon.Locked, + }); + }); + + it("removes override when a previously high-priority state with the same tabId is cleared", async () => { + await badgeService.setState( + "state-1", + TabSpecificStateFunction( + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ), + ); + await badgeService.setState( + "state-2", + TabSpecificStateFunction( + BadgeStatePriority.Default, + { + text: "override", + }, + tabId, + ), + ); + await badgeService.clearState("state-2"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual({ + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }); + }); + + it("sets default state when all states with the same tabId have been cleared", async () => { + await badgeService.setState( + "state-1", + TabSpecificStateFunction( + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ), + ); + await badgeService.setState( + "state-2", + TabSpecificStateFunction( + BadgeStatePriority.Default, + { + text: "override", + }, + tabId, + ), + ); + await badgeService.setState( + "state-3", + TabSpecificStateFunction( + BadgeStatePriority.High, + { + backgroundColor: "#aaa", + }, + tabId, + ), + ); + await badgeService.clearState("state-1"); + await badgeService.clearState("state-2"); + await badgeService.clearState("state-3"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); + }); + + it("sets default value when high-priority state contains Unset", async () => { + await badgeService.setState( + "state-1", + TabSpecificStateFunction( + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ), + ); + await badgeService.setState( + "state-3", + TabSpecificStateFunction( + BadgeStatePriority.High, + { + icon: Unset, + }, + tabId, + ), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual({ + text: "text", + backgroundColor: "#fff", + icon: DefaultBadgeState.icon, + }); + }); + + it("ignores medium-priority Unset when high-priority contains a value", async () => { + await badgeService.setState( + "state-1", + TabSpecificStateFunction( + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ), + ); + await badgeService.setState( + "state-3", + TabSpecificStateFunction( + BadgeStatePriority.Default, + { + icon: Unset, + }, + tabId, + ), + ); + await badgeService.setState( + "state-3", + TabSpecificStateFunction( + BadgeStatePriority.High, + { + icon: BadgeIcon.Unlocked, + }, + tabId, + ), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates[tabId]).toEqual({ + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Unlocked, + }); }); }); - it("sets tab-specific state for provided tab", async () => { - const generalState: BadgeState = { - text: "general-text", - backgroundColor: "general-color", - icon: BadgeIcon.Unlocked, - }; - const specificState: BadgeState = { - text: "tab-text", - icon: BadgeIcon.Locked, - }; + describe("given multiple tabs are open, only one active", () => { + const tabId = 1; + const tabIds = [1, 2, 3]; - await badgeService.setState("general-state", BadgeStatePriority.Default, generalState); - await badgeService.setState( - "tab-state", - BadgeStatePriority.Default, - specificState, - tabIds[0], - ); + beforeEach(() => { + badgeApi.tabs = tabIds; + badgeApi.setActiveTabs([tabId]); + badgeServiceSubscription = badgeService.startListening(); + }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(badgeApi.specificStates).toEqual({ - [tabIds[0]]: { ...specificState, backgroundColor: "general-color" }, - [tabIds[1]]: generalState, - [tabIds[2]]: undefined, + it("sets tab-specific state for provided tab", async () => { + const generalState: BadgeState = { + text: "general-text", + backgroundColor: "general-color", + icon: BadgeIcon.Unlocked, + }; + const specificState: BadgeState = { + text: "tab-text", + icon: BadgeIcon.Locked, + }; + + await badgeService.setState( + "general-state", + GeneralStateFunction(BadgeStatePriority.Default, generalState), + ); + await badgeService.setState( + "tab-state", + TabSpecificStateFunction(BadgeStatePriority.Default, specificState, tabIds[0]), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates).toEqual({ + [tabIds[0]]: { ...specificState, backgroundColor: "general-color" }, + [tabIds[1]]: undefined, + [tabIds[2]]: undefined, + }); + }); + }); + + describe("given multiple tabs are open and multiple are active", () => { + const activeTabIds = [1, 2]; + const tabIds = [1, 2, 3]; + + beforeEach(() => { + badgeApi.tabs = tabIds; + badgeApi.setActiveTabs(activeTabIds); + badgeServiceSubscription = badgeService.startListening(); + }); + + it("sets general state for all active tabs when no other state has been set", async () => { + const generalState: BadgeState = { + text: "general-text", + backgroundColor: "general-color", + icon: BadgeIcon.Unlocked, + }; + + await badgeService.setState( + "general-state", + GeneralStateFunction(BadgeStatePriority.Default, generalState), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates).toEqual({ + [tabIds[0]]: generalState, + [tabIds[1]]: generalState, + [tabIds[2]]: undefined, + }); + }); + + it("sets tab-specific state for provided tab", async () => { + const generalState: BadgeState = { + text: "general-text", + backgroundColor: "general-color", + icon: BadgeIcon.Unlocked, + }; + const specificState: BadgeState = { + text: "tab-text", + icon: BadgeIcon.Locked, + }; + + await badgeService.setState( + "general-state", + GeneralStateFunction(BadgeStatePriority.Default, generalState), + ); + await badgeService.setState( + "tab-state", + TabSpecificStateFunction(BadgeStatePriority.Default, specificState, tabIds[0]), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.specificStates).toEqual({ + [tabIds[0]]: { ...specificState, backgroundColor: "general-color" }, + [tabIds[1]]: generalState, + [tabIds[2]]: undefined, + }); + }); + + it("unsubscribes from state function when tab is deactivated", async () => { + let subscriptions = 0; + badgeService.setState("state", (tab) => { + return new Observable(() => { + subscriptions++; + return () => { + subscriptions--; + }; + }); + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(subscriptions).toBe(activeTabIds.length); + + badgeApi.deactivateTab(activeTabIds[0]); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(subscriptions).toBe(activeTabIds.length - 1); }); }); }); }); }); + +/** + * Creates a dynamic state function that only provides a state for a specific tab. + */ +function TabSpecificStateFunction( + priority: BadgeStatePriority, + state: BadgeState, + tabId: number, +): BadgeStateFunction { + return (tab) => { + if (tab.tabId === tabId) { + return of({ + priority, + state, + }); + } + + return EMPTY; + }; +} + +/** + * Creates a dynamic state function that provides the same state for all tabs. + */ +function GeneralStateFunction(priority: BadgeStatePriority, state: BadgeState): BadgeStateFunction { + return (_tab) => + of({ + priority, + state, + }); +} diff --git a/apps/browser/src/platform/badge/badge.service.ts b/apps/browser/src/platform/badge/badge.service.ts index 5634aabec28..f6d799b2a80 100644 --- a/apps/browser/src/platform/badge/badge.service.ts +++ b/apps/browser/src/platform/badge/badge.service.ts @@ -1,69 +1,100 @@ -import { concatMap, filter, Subscription, withLatestFrom } from "rxjs"; +import { + BehaviorSubject, + combineLatest, + combineLatestWith, + concatMap, + debounceTime, + filter, + groupBy, + map, + mergeMap, + Observable, + of, + startWith, + Subscription, + switchMap, + takeUntil, +} from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { - BADGE_MEMORY, - GlobalState, - KeyDefinition, - StateProvider, -} from "@bitwarden/common/platform/state"; import { BadgeBrowserApi, RawBadgeState, Tab } from "./badge-browser-api"; import { DefaultBadgeState } from "./consts"; import { BadgeStatePriority } from "./priority"; import { BadgeState, Unset } from "./state"; -interface StateSetting { +const BADGE_UPDATE_DEBOUNCE_MS = 100; + +export interface BadgeStateSetting { priority: BadgeStatePriority; state: BadgeState; - tabId?: number; } -const BADGE_STATES = new KeyDefinition(BADGE_MEMORY, "badgeStates", { - deserializer: (value: Record) => value ?? {}, - cleanupDelayMs: 0, -}); +/** + * A function that returns the badge state for a specific tab. + * Return `undefined` to clear any previously set state for the tab. + */ +export type BadgeStateFunction = (tab: Tab) => Observable; export class BadgeService { - private serviceState: GlobalState>; - - /** - * An observable that emits whenever one or multiple tabs are updated and might need its state updated. - * Use this to know exactly which tabs to calculate the badge state for. - * This is not the same as `onActivated` which only emits when the active tab changes. - */ - activeTabsUpdated$ = this.badgeApi.activeTabsUpdated$; - - getActiveTabs(): Promise { - return this.badgeApi.getActiveTabs(); - } + private stateFunctions = new BehaviorSubject>({}); constructor( - private stateProvider: StateProvider, private badgeApi: BadgeBrowserApi, private logService: LogService, - ) { - this.serviceState = this.stateProvider.getGlobal(BADGE_STATES); - } + private debounceTimeMs: number = BADGE_UPDATE_DEBOUNCE_MS, + ) {} /** * Start listening for badge state changes. * Without this the service will not be able to update the badge state. */ startListening(): Subscription { - // React to tab changes - return this.badgeApi.activeTabsUpdated$ + // Default state function that always returns an empty state with lowest priority. + // This will ensure that there is always at least one state to consider when calculating the final badge state, + // so that the badge is cleared/set to default when no other states are set. + const defaultTabStateFunction: BadgeStateFunction = (_tab) => + of({ + priority: BadgeStatePriority.Low, + state: {}, + }); + + return this.badgeApi.tabEvents$ .pipe( - withLatestFrom(this.serviceState.state$), - filter(([activeTabs]) => activeTabs.length > 0), - concatMap(async ([activeTabs, serviceState]) => { - await Promise.all(activeTabs.map((tab) => this.updateBadge(serviceState, tab.tabId))); + groupBy((event) => (event.type === "deactivated" ? event.tabId : event.tab.tabId), { + duration: (group$) => + // Allow clean up of group when deactivated event arrives for this tabId + group$.pipe(filter((evt) => evt.type === "deactivated")), + }), + mergeMap((group$) => + group$.pipe( + // ignore deactivation events, only handle updates/activations + filter((evt) => evt.type !== "deactivated"), + map((evt) => evt.tab), + combineLatestWith(this.stateFunctions), + switchMap(([tab, dynamicStateFunctions]) => { + const functions = [...Object.values(dynamicStateFunctions), defaultTabStateFunction]; + + return combineLatest(functions.map((f) => f(tab).pipe(startWith(undefined)))).pipe( + map((states) => ({ + tab, + states: states.filter((s): s is BadgeStateSetting => s !== undefined), + })), + debounceTime(this.debounceTimeMs), + ); + }), + takeUntil(group$.pipe(filter((evt) => evt.type === "deactivated"))), + ), + ), + + concatMap(async (tabUpdate) => { + await this.updateBadge(tabUpdate.states, tabUpdate.tab.tabId); }), ) .subscribe({ error: (error: unknown) => { this.logService.error( - "Fatal error in badge service observable, badge will fail to update", + "BadgeService: Fatal error updating badge state. Badge will no longer be updated.", error, ); }, @@ -71,68 +102,45 @@ export class BadgeService { } /** - * Inform badge service of a new state that the badge should reflect. + * Register a function that takes an observable of active tab updates and returns an observable of state settings. + * This can be used to create dynamic badge states that react to tab changes. + * The returned observable should emit a new state setting whenever the badge state should be updated. * - * This will merge the new state with any existing states: + * This will merge all states: * - If the new state has a higher priority, it will override any lower priority states. * - If the new state has a lower priority, it will be ignored. * - If the name of the state is already in use, it will be updated. * - If the state has a `tabId` set, it will only apply to that tab. * - States with `tabId` can still be overridden by states without `tabId` if they have a higher priority. - * - * @param name The name of the state. This is used to identify the state and will be used to clear it later. - * @param priority The priority of the state (higher numbers are higher priority, but setting arbitrary numbers is not supported). - * @param state The state to set. - * @param tabId Limit this badge state to a specific tab. If this is not set, the state will be applied to all tabs. */ - async setState(name: string, priority: BadgeStatePriority, state: BadgeState, tabId?: number) { - const newServiceState = await this.serviceState.update((s) => ({ - ...s, - [name]: { priority, state, tabId }, - })); - await this.updateBadge(newServiceState, tabId); + setState(name: string, stateFunction: BadgeStateFunction) { + this.stateFunctions.next({ + ...this.stateFunctions.value, + [name]: stateFunction, + }); } /** - * Clear the state with the given name. + * Clear a state function previously registered with `setState`. * - * This will remove the state from the badge service and clear it from the badge. - * If the state is not found, nothing will happen. + * This will: + * - Stop the function from being called on future tab changes + * - Unsubscribe from any existing observables created by the function. + * - Clear any badge state previously set by the function. * - * @param name The name of the state to clear. + * @param name The name of the state function to clear. */ - async clearState(name: string) { - let clearedState: StateSetting | undefined; - - const newServiceState = await this.serviceState.update((s) => { - clearedState = s?.[name]; - - const newStates = { ...s }; - delete newStates[name]; - return newStates; - }); - - if (clearedState === undefined) { - return; - } - await this.updateBadge(newServiceState, clearedState.tabId); + clearState(name: string) { + const currentDynamicStateFunctions = this.stateFunctions.value; + const newDynamicStateFunctions = { ...currentDynamicStateFunctions }; + delete newDynamicStateFunctions[name]; + this.stateFunctions.next(newDynamicStateFunctions); } - private calculateState(states: Set, tabId?: number): RawBadgeState { - const sortedStates = [...states].sort((a, b) => a.priority - b.priority); + private calculateState(states: BadgeStateSetting[]): RawBadgeState { + const sortedStates = states.sort((a, b) => a.priority - b.priority); - let filteredStates = sortedStates; - if (tabId !== undefined) { - // Filter out states that are not applicable to the current tab. - // If a state has no tabId, it is considered applicable to all tabs. - // If a state has a tabId, it is only applicable to that tab. - filteredStates = sortedStates.filter((s) => s.tabId === tabId || s.tabId === undefined); - } else { - // If no tabId is provided, we only want states that are not tab-specific. - filteredStates = sortedStates.filter((s) => s.tabId === undefined); - } - - const mergedState = filteredStates + const mergedState = sortedStates .map((s) => s.state) .reduce>((acc: Partial, state: BadgeState) => { const newState = { ...acc }; @@ -156,43 +164,16 @@ export class BadgeService { * This will only update the badge if the active tab is the same as the tabId of the latest change. * If the active tab is not set, it will not update the badge. * - * @param activeTab The currently active tab. * @param serviceState The current state of the badge service. If this is null or undefined, an empty set will be assumed. * @param tabId Tab id for which the the latest state change applied to. Set this to activeTab.tabId to force an update. + * @param activeTabs The currently active tabs. If not provided, it will be fetched from the badge API. */ - private async updateBadge( - serviceState: Record | null | undefined, - tabId: number | undefined, - ) { - const activeTabs = await this.badgeApi.getActiveTabs(); - if (tabId !== undefined && !activeTabs.some((tab) => tab.tabId === tabId)) { - return; // No need to update the badge if the state is not for the active tab. - } - - const tabIdsToUpdate = tabId ? [tabId] : activeTabs.map((tab) => tab.tabId); - - for (const tabId of tabIdsToUpdate) { - if (tabId === undefined) { - continue; // Skip if tab id is undefined. - } - - const newBadgeState = this.calculateState(new Set(Object.values(serviceState ?? {})), tabId); - try { - await this.badgeApi.setState(newBadgeState, tabId); - } catch (error) { - this.logService.error("Failed to set badge state", error); - } - } - - if (tabId === undefined) { - // If no tabId was provided we should also update the general badge state - const newBadgeState = this.calculateState(new Set(Object.values(serviceState ?? {}))); - - try { - await this.badgeApi.setState(newBadgeState, tabId); - } catch (error) { - this.logService.error("Failed to set general badge state", error); - } + private async updateBadge(serviceState: BadgeStateSetting[], tabId: number) { + const newBadgeState = this.calculateState(serviceState); + try { + await this.badgeApi.setState(newBadgeState, tabId); + } catch (error) { + this.logService.error("Failed to set badge state", error); } } } diff --git a/apps/browser/src/platform/badge/scope.ts b/apps/browser/src/platform/badge/scope.ts new file mode 100644 index 00000000000..5d6cb8dd4e7 --- /dev/null +++ b/apps/browser/src/platform/badge/scope.ts @@ -0,0 +1,23 @@ +export const BadgeStateScope = { + /** + * The state is global and applies to all users. + */ + Global: { type: "global" } satisfies BadgeStateScope, + /** + * The state is for a specific user and only applies to that user when they are unlocked. + */ + UserUnlocked: (userId: string) => + ({ + type: "user_unlocked", + userId, + }) satisfies BadgeStateScope, +} as const; + +export type BadgeStateScope = + | { + type: "global"; + } + | { + type: "user_unlocked"; + userId: string; + }; diff --git a/apps/browser/src/platform/badge/state.ts b/apps/browser/src/platform/badge/state.ts index 0731ad81f41..ea6b52b28e4 100644 --- a/apps/browser/src/platform/badge/state.ts +++ b/apps/browser/src/platform/badge/state.ts @@ -1,6 +1,8 @@ import { BadgeIcon } from "./icon"; -export const Unset = Symbol("Unset badge state"); +const UnsetValue = Symbol("Unset badge state"); + +export const Unset = UnsetValue as typeof UnsetValue; export type Unset = typeof Unset; export type BadgeState = { diff --git a/apps/browser/src/platform/badge/test/mock-badge-browser-api.ts b/apps/browser/src/platform/badge/test/mock-badge-browser-api.ts index a1b79c29cb8..9f8db3f23ef 100644 --- a/apps/browser/src/platform/badge/test/mock-badge-browser-api.ts +++ b/apps/browser/src/platform/badge/test/mock-badge-browser-api.ts @@ -1,33 +1,50 @@ -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, concat, defer, of, Subject, switchMap } from "rxjs"; -import { BadgeBrowserApi, RawBadgeState, Tab } from "../badge-browser-api"; +import { BadgeBrowserApi, RawBadgeState, Tab, TabEvent } from "../badge-browser-api"; export class MockBadgeBrowserApi implements BadgeBrowserApi { - private _activeTabsUpdated$ = new BehaviorSubject([]); - activeTabsUpdated$ = this._activeTabsUpdated$.asObservable(); + private _activeTabs$ = new BehaviorSubject([]); + private _tabEvents$ = new Subject(); + activeTabs$ = this._activeTabs$.asObservable(); specificStates: Record = {}; generalState?: RawBadgeState; tabs: number[] = []; - activeTabs: number[] = []; - getActiveTabs(): Promise { - return Promise.resolve( - this.activeTabs.map( - (tabId) => - ({ - tabId, - url: `https://example.com/${tabId}`, - }) satisfies Tab, - ), - ); + tabEvents$ = concat( + defer(() => [this.activeTabs]).pipe( + switchMap((activeTabs) => { + const tabEvents: TabEvent[] = activeTabs.map((tab) => ({ + type: "activated", + tab, + })); + return of(...tabEvents); + }), + ), + this._tabEvents$.asObservable(), + ); + + get activeTabs() { + return this._activeTabs$.value; } setActiveTabs(tabs: number[]) { - this.activeTabs = tabs; - this._activeTabsUpdated$.next( - tabs.map((tabId) => ({ tabId, url: `https://example.com/${tabId}` })), - ); + this._activeTabs$.next(tabs.map((tabId) => ({ tabId, url: `https://example.com/${tabId}` }))); + + tabs.forEach((tabId) => { + this._tabEvents$.next({ + type: "activated", + tab: { tabId, url: `https://example.com/${tabId}` }, + }); + }); + } + + updateTab(tabId: number) { + this._tabEvents$.next({ type: "updated", tab: { tabId, url: `https://example.com/${tabId}` } }); + } + + deactivateTab(tabId: number) { + this._tabEvents$.next({ type: "deactivated", tabId }); } setState = jest.fn().mockImplementation((state: RawBadgeState, tabId?: number): Promise => { @@ -39,8 +56,4 @@ export class MockBadgeBrowserApi implements BadgeBrowserApi { return Promise.resolve(); }); - - getTabs(): Promise { - return Promise.resolve(this.tabs); - } } diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index 8a3dbafc5ce..cfc39fa18a1 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -5,6 +5,7 @@ import { Observable } from "rxjs"; import { BrowserClientVendors } from "@bitwarden/common/autofill/constants"; import { BrowserClientVendor } from "@bitwarden/common/autofill/types"; import { DeviceType } from "@bitwarden/common/enums"; +import { LogService } from "@bitwarden/logging"; import { isBrowserSafariApi } from "@bitwarden/platform"; import { TabMessage } from "../../types/tab-messages"; @@ -32,6 +33,53 @@ export class BrowserApi { return BrowserApi.manifestVersion === expectedVersion; } + /** + * Helper method that attempts to distinguish whether a message sender is internal to the extension or not. + * + * Currently this is done through source origin matching, and frameId checking (only top-level frames are internal). + * @param sender a message sender + * @param logger an optional logger to log validation results + * @returns whether or not the sender appears to be internal to the extension + */ + static senderIsInternal( + sender: chrome.runtime.MessageSender | undefined, + logger?: LogService, + ): boolean { + if (!sender?.origin) { + logger?.warning("[BrowserApi] Message sender has no origin"); + return false; + } + const extensionUrl = + (typeof chrome !== "undefined" && chrome.runtime?.getURL("")) || + (typeof browser !== "undefined" && browser.runtime?.getURL("")) || + ""; + + if (!extensionUrl) { + logger?.warning("[BrowserApi] Unable to determine extension URL"); + return false; + } + + // Normalize both URLs by removing trailing slashes + const normalizedOrigin = sender.origin.replace(/\/$/, "").toLowerCase(); + const normalizedExtensionUrl = extensionUrl.replace(/\/$/, "").toLowerCase(); + + if (!normalizedOrigin.startsWith(normalizedExtensionUrl)) { + logger?.warning( + `[BrowserApi] Message sender origin (${normalizedOrigin}) does not match extension URL (${normalizedExtensionUrl})`, + ); + return false; + } + + // We only send messages from the top-level frame, but frameId is only set if tab is set, which for popups it is not. + if ("frameId" in sender && sender.frameId !== 0) { + logger?.warning("[BrowserApi] Message sender is not from the top-level frame"); + return false; + } + + logger?.info("[BrowserApi] Message sender appears to be internal"); + return true; + } + /** * Gets all open browser windows, including their tabs. * @@ -220,11 +268,11 @@ export class BrowserApi { static async closeTab(tabId: number): Promise { if (tabId) { if (BrowserApi.isWebExtensionsApi) { - browser.tabs.remove(tabId).catch((error) => { + await browser.tabs.remove(tabId).catch((error) => { throw new Error("[BrowserApi] Failed to remove current tab: " + error.message); }); } else if (BrowserApi.isChromeApi) { - chrome.tabs.remove(tabId).catch((error) => { + await chrome.tabs.remove(tabId).catch((error) => { throw new Error("[BrowserApi] Failed to remove current tab: " + error.message); }); } @@ -240,7 +288,7 @@ export class BrowserApi { static async navigateTabToUrl(tabId: number, url: URL): Promise { if (tabId) { if (BrowserApi.isWebExtensionsApi) { - browser.tabs.update(tabId, { url: url.href }).catch((error) => { + await browser.tabs.update(tabId, { url: url.href }).catch((error) => { throw new Error("Failed to navigate tab to URL: " + error.message); }); } else if (BrowserApi.isChromeApi) { 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 9f9a6e313c8..6e2175e3a79 100644 --- a/apps/browser/src/platform/browser/browser-popup-utils.spec.ts +++ b/apps/browser/src/platform/browser/browser-popup-utils.spec.ts @@ -140,6 +140,11 @@ 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, @@ -150,6 +155,8 @@ describe("BrowserPopupUtils", () => { width: 380, }); 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 () => { @@ -267,6 +274,63 @@ describe("BrowserPopupUtils", () => { url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=123`, }); }); + + it("exits fullscreen and focuses popout window if the current window is fullscreen and platform is mac", async () => { + const url = "popup/index.html"; + jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false); + jest.spyOn(BrowserApi, "getPlatformInfo").mockReset().mockResolvedValueOnce({ + os: "mac", + arch: "x86-64", + nacl_arch: "x86-64", + }); + jest.spyOn(BrowserApi, "getWindow").mockReset().mockResolvedValueOnce({ + id: 1, + left: 100, + top: 100, + focused: false, + alwaysOnTop: false, + incognito: false, + width: 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", () => { @@ -337,6 +401,68 @@ describe("BrowserPopupUtils", () => { }); }); + describe("waitForAllPopupsClose", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("should resolve immediately if no popups are open", async () => { + jest.spyOn(BrowserApi, "isPopupOpen").mockResolvedValue(false); + + const promise = BrowserPopupUtils.waitForAllPopupsClose(); + jest.advanceTimersByTime(100); + + await expect(promise).resolves.toBeUndefined(); + expect(BrowserApi.isPopupOpen).toHaveBeenCalledTimes(1); + }); + + it("should resolve after timeout if popup never closes when using custom timeout", async () => { + jest.spyOn(BrowserApi, "isPopupOpen").mockResolvedValue(true); + + const promise = BrowserPopupUtils.waitForAllPopupsClose(500); + + // Advance past the timeout + jest.advanceTimersByTime(600); + + await expect(promise).resolves.toBeUndefined(); + }); + + it("should resolve after timeout if popup never closes when using default timeout", async () => { + jest.spyOn(BrowserApi, "isPopupOpen").mockResolvedValue(true); + + const promise = BrowserPopupUtils.waitForAllPopupsClose(); + + // Advance past the default timeout + jest.advanceTimersByTime(1100); + + await expect(promise).resolves.toBeUndefined(); + }); + + it("should stop polling after popup closes before timeout", async () => { + let callCount = 0; + jest.spyOn(BrowserApi, "isPopupOpen").mockImplementation(async () => { + callCount++; + return callCount <= 2; + }); + + const promise = BrowserPopupUtils.waitForAllPopupsClose(1000); + + // Advance to when popup closes (300ms) + jest.advanceTimersByTime(300); + + await expect(promise).resolves.toBeUndefined(); + + // Advance further to ensure no more calls are made + jest.advanceTimersByTime(1000); + + expect(BrowserApi.isPopupOpen).toHaveBeenCalledTimes(3); + }); + }); + describe("isSingleActionPopoutOpen", () => { const windowOptions = { id: 1, diff --git a/apps/browser/src/platform/browser/browser-popup-utils.ts b/apps/browser/src/platform/browser/browser-popup-utils.ts index aebb3e92113..8343799d0eb 100644 --- a/apps/browser/src/platform/browser/browser-popup-utils.ts +++ b/apps/browser/src/platform/browser/browser-popup-utils.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { filter, firstValueFrom, interval, of, switchMap, takeWhile, timeout } from "rxjs"; import { ScrollOptions } from "./abstractions/browser-popup-utils.abstractions"; import { BrowserApi } from "./browser-api"; @@ -167,8 +168,29 @@ 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", + }); - return await BrowserApi.createWindow(popoutWindowOptions); + //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; } /** @@ -212,6 +234,27 @@ export default class BrowserPopupUtils { } } + /** + * Waits for all browser action popups to close, polling up to the specified timeout. + * Used before extension reload to prevent zombie popups with invalidated contexts. + * + * @param timeoutMs - Maximum time to wait in milliseconds. Defaults to 1 second. + * @returns Promise that resolves when all popups are closed or timeout is reached. + */ + static async waitForAllPopupsClose(timeoutMs = 1000): Promise { + await firstValueFrom( + interval(100).pipe( + switchMap(() => BrowserApi.isPopupOpen()), + takeWhile((isOpen) => isOpen, true), + filter((isOpen) => !isOpen), + timeout({ + first: timeoutMs, + with: () => of(true), + }), + ), + ); + } + /** * Identifies if a single action window is open based on the passed popoutKey. * Will focus the existing window, and close any other windows that might exist diff --git a/apps/browser/src/platform/ipc/ipc-background.service.ts b/apps/browser/src/platform/ipc/ipc-background.service.ts index 911ca931c70..9fc2ca24b6a 100644 --- a/apps/browser/src/platform/ipc/ipc-background.service.ts +++ b/apps/browser/src/platform/ipc/ipc-background.service.ts @@ -8,6 +8,7 @@ import { OutgoingMessage, ipcRegisterDiscoverHandler, IpcClient, + IpcSessionRepository, } from "@bitwarden/sdk-internal"; import { BrowserApi } from "../browser/browser-api"; @@ -18,6 +19,7 @@ export class IpcBackgroundService extends IpcService { constructor( private platformUtilsService: PlatformUtilsService, private logService: LogService, + private sessionRepository: IpcSessionRepository, ) { super(); } @@ -70,7 +72,9 @@ export class IpcBackgroundService extends IpcService { ); }); - await super.initWithClient(new IpcClient(this.communicationBackend)); + await super.initWithClient( + IpcClient.newWithClientManagedSessions(this.communicationBackend, this.sessionRepository), + ); if (this.platformUtilsService.isDev()) { await ipcRegisterDiscoverHandler(this.client, { diff --git a/apps/browser/src/platform/popup/components/pop-out.component.ts b/apps/browser/src/platform/popup/components/pop-out.component.ts index 320fa6f05ab..fd2acbd8aa7 100644 --- a/apps/browser/src/platform/popup/components/pop-out.component.ts +++ b/apps/browser/src/platform/popup/components/pop-out.component.ts @@ -7,12 +7,16 @@ import { IconButtonModule } from "@bitwarden/components"; import BrowserPopupUtils from "../../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: "app-pop-out", templateUrl: "pop-out.component.html", imports: [CommonModule, JslibModule, IconButtonModule], }) export class PopOutComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() show = true; constructor(private platformUtilsService: PlatformUtilsService) {} diff --git a/apps/browser/src/platform/popup/layout/popup-footer.component.ts b/apps/browser/src/platform/popup/layout/popup-footer.component.ts index 928394b0ad4..c44dfc5079f 100644 --- a/apps/browser/src/platform/popup/layout/popup-footer.component.ts +++ b/apps/browser/src/platform/popup/layout/popup-footer.component.ts @@ -1,5 +1,7 @@ import { Component } from "@angular/core"; +// 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: "popup-footer", templateUrl: "popup-footer.component.html", diff --git a/apps/browser/src/platform/popup/layout/popup-header.component.ts b/apps/browser/src/platform/popup/layout/popup-header.component.ts index b580b84f39b..2e95e7ab587 100644 --- a/apps/browser/src/platform/popup/layout/popup-header.component.ts +++ b/apps/browser/src/platform/popup/layout/popup-header.component.ts @@ -16,6 +16,8 @@ import { PopupRouterCacheService } from "../view-cache/popup-router-cache.servic import { PopupPageComponent } from "./popup-page.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: "popup-header", templateUrl: "popup-header.component.html", @@ -23,13 +25,19 @@ import { PopupPageComponent } from "./popup-page.component"; }) export class PopupHeaderComponent { private popupRouterCacheService = inject(PopupRouterCacheService); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals protected pageContentScrolled: Signal = inject(PopupPageComponent).isScrolled; /** Background color */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() background: "default" | "alt" = "default"; /** Display the back button, which uses Location.back() to go back one page in history */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get showBackButton() { return this._showBackButton; @@ -41,6 +49,8 @@ export class PopupHeaderComponent { private _showBackButton = false; /** Title string that will be inserted as an h1 */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) pageTitle: string; /** @@ -48,6 +58,8 @@ export class PopupHeaderComponent { * * If unset, will call `location.back()` **/ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() backAction: FunctionReturningAwaitable = async () => { return this.popupRouterCacheService.back(); 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 a7103fdfd3c..c6ffe1a6414 100644 --- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts +++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts @@ -31,6 +31,7 @@ import { ScrollLayoutDirective, } from "@bitwarden/components"; +import { VaultLoadingSkeletonComponent } from "../../../vault/popup/components/vault-loading-skeleton/vault-loading-skeleton.component"; import { PopupRouterCacheService } from "../view-cache/popup-router-cache.service"; import { PopupFooterComponent } from "./popup-footer.component"; @@ -38,6 +39,8 @@ import { PopupHeaderComponent } from "./popup-header.component"; import { PopupPageComponent } from "./popup-page.component"; import { PopupTabNavigationComponent } from "./popup-tab-navigation.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: "extension-container", template: ` @@ -48,6 +51,8 @@ import { PopupTabNavigationComponent } from "./popup-tab-navigation.component"; }) class ExtensionContainerComponent {} +// 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: "extension-popped-container", template: ` @@ -59,6 +64,8 @@ class ExtensionContainerComponent {} }) class ExtensionPoppedContainerComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "vault-placeholder", template: /*html*/ ` @@ -92,6 +99,8 @@ class VaultComponent { protected data = Array.from(Array(20).keys()); } +// 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: "mock-add-button", template: ` @@ -104,6 +113,8 @@ class VaultComponent { }) class MockAddButtonComponent {} +// 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: "mock-popout-button", template: ` @@ -113,6 +124,8 @@ class MockAddButtonComponent {} }) class MockPopoutButtonComponent {} +// 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: "mock-current-account", template: ` @@ -124,6 +137,8 @@ class MockPopoutButtonComponent {} }) class MockCurrentAccountComponent {} +// 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: "mock-search", template: ` `, @@ -131,6 +146,8 @@ class MockCurrentAccountComponent {} }) class MockSearchComponent {} +// 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: "mock-banner", template: ` @@ -142,6 +159,8 @@ class MockSearchComponent {} }) class MockBannerComponent {} +// 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: "mock-vault-page", template: ` @@ -169,6 +188,8 @@ class MockBannerComponent {} }) class MockVaultPageComponent {} +// 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: "mock-vault-page-popped", template: ` @@ -192,6 +213,8 @@ class MockVaultPageComponent {} }) class MockVaultPagePoppedComponent {} +// 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: "mock-generator-page", template: ` @@ -216,6 +239,8 @@ class MockVaultPagePoppedComponent {} }) class MockGeneratorPageComponent {} +// 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: "mock-send-page", template: ` @@ -240,6 +265,8 @@ class MockGeneratorPageComponent {} }) class MockSendPageComponent {} +// 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: "mock-settings-page", template: ` @@ -264,6 +291,8 @@ class MockSendPageComponent {} }) class MockSettingsPageComponent {} +// 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: "mock-vault-subpage", template: ` @@ -335,6 +364,7 @@ export default { SectionComponent, IconButtonModule, BadgeModule, + VaultLoadingSkeletonComponent, ], providers: [ { @@ -594,6 +624,22 @@ export const Loading: Story = { }), }; +export const SkeletonLoading: Story = { + render: (args) => ({ + props: { ...args, data: Array(8) }, + template: /* HTML */ ` + + + + + + + + + `, + }), +}; + export const TransparentHeader: Story = { render: (args) => ({ props: args, 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 b53ef6e97eb..828d9947373 100644 --- a/apps/browser/src/platform/popup/layout/popup-page.component.html +++ b/apps/browser/src/platform/popup/layout/popup-page.component.html @@ -1,7 +1,7 @@
-
@@ -37,9 +39,9 @@
- +
diff --git a/apps/browser/src/platform/popup/layout/popup-page.component.ts b/apps/browser/src/platform/popup/layout/popup-page.component.ts index e7675978622..4eed322bdbd 100644 --- a/apps/browser/src/platform/popup/layout/popup-page.component.ts +++ b/apps/browser/src/platform/popup/layout/popup-page.component.ts @@ -1,5 +1,12 @@ import { CommonModule } from "@angular/common"; -import { booleanAttribute, Component, inject, Input, signal } from "@angular/core"; +import { + booleanAttribute, + ChangeDetectionStrategy, + Component, + inject, + input, + signal, +} from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ScrollLayoutHostDirective } from "@bitwarden/components"; @@ -11,20 +18,23 @@ import { ScrollLayoutHostDirective } from "@bitwarden/components"; class: "tw-h-full tw-flex tw-flex-col tw-overflow-y-hidden", }, imports: [CommonModule, ScrollLayoutHostDirective], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class PopupPageComponent { protected i18nService = inject(I18nService); - @Input() loading = false; + readonly loading = input(false); - @Input({ transform: booleanAttribute }) - disablePadding = false; + readonly disablePadding = input(false, { transform: booleanAttribute }); - protected scrolled = signal(false); + /** Hides any overflow within the page content */ + readonly hideOverflow = input(false, { transform: booleanAttribute }); + + protected readonly scrolled = signal(false); isScrolled = this.scrolled.asReadonly(); /** Accessible loading label for the spinner. Defaults to "loading" */ - @Input() loadingText?: string = this.i18nService.t("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-tab-navigation.component.html b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html index 0a52518b250..bce2b5033ae 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 @@ -8,7 +8,7 @@
  • - - - {{ "codeSent" | i18n }} - -
  • - -
    - - -
    - diff --git a/apps/browser/src/popup/components/user-verification.component.ts b/apps/browser/src/popup/components/user-verification.component.ts deleted file mode 100644 index f6cb6cdff12..00000000000 --- a/apps/browser/src/popup/components/user-verification.component.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { animate, style, transition, trigger } from "@angular/animations"; -import { Component } from "@angular/core"; -import { NG_VALUE_ACCESSOR } from "@angular/forms"; - -import { UserVerificationComponent as BaseComponent } from "@bitwarden/angular/auth/components/user-verification.component"; -/** - * @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent or UserVerificationFormInputComponent instead. - * Each client specific component should eventually be converted over to use one of these new components. - */ -@Component({ - selector: "app-user-verification", - templateUrl: "user-verification.component.html", - providers: [ - { - provide: NG_VALUE_ACCESSOR, - multi: true, - useExisting: UserVerificationComponent, - }, - ], - animations: [ - trigger("sent", [ - transition(":enter", [style({ opacity: 0 }), animate("100ms", style({ opacity: 1 }))]), - ]), - ], - standalone: false, -}) -export class UserVerificationComponent extends BaseComponent {} diff --git a/apps/browser/src/popup/images/loading.svg b/apps/browser/src/popup/images/loading.svg index 5f4102a5921..e05a42f6c70 100644 --- a/apps/browser/src/popup/images/loading.svg +++ b/apps/browser/src/popup/images/loading.svg @@ -1,5 +1,5 @@  - Loading... diff --git a/apps/browser/src/popup/scss/base.scss b/apps/browser/src/popup/scss/base.scss index b3d14e65061..01b9d3f05d5 100644 --- a/apps/browser/src/popup/scss/base.scss +++ b/apps/browser/src/popup/scss/base.scss @@ -382,7 +382,7 @@ app-root { } } -main:not(popup-page main) { +main:not(popup-page main):not(auth-anon-layout main) { position: absolute; top: 44px; bottom: 0; diff --git a/apps/browser/src/popup/scss/pages.scss b/apps/browser/src/popup/scss/pages.scss index 4c2daab2159..56c5f80c86c 100644 --- a/apps/browser/src/popup/scss/pages.scss +++ b/apps/browser/src/popup/scss/pages.scss @@ -137,3 +137,8 @@ body.body-full { margin-bottom: 0; } } + +/** Temporary fix for avatar, will not be required once we migrate to tailwind preflight **/ +bit-avatar svg { + display: block; +} diff --git a/apps/browser/src/popup/scss/variables.scss b/apps/browser/src/popup/scss/variables.scss index aea69e26436..e57e98fd0cc 100644 --- a/apps/browser/src/popup/scss/variables.scss +++ b/apps/browser/src/popup/scss/variables.scss @@ -1,6 +1,6 @@ $dark-icon-themes: "theme_dark"; -$font-family-sans-serif: Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif; +$font-family-sans-serif: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; $font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace; $font-size-base: 16px; $font-size-large: 18px; diff --git a/apps/browser/src/popup/services/init.service.ts b/apps/browser/src/popup/services/init.service.ts index 1930dbd1d4b..91b6f0ff105 100644 --- a/apps/browser/src/popup/services/init.service.ts +++ b/apps/browser/src/popup/services/init.service.ts @@ -2,7 +2,9 @@ import { DOCUMENT } from "@angular/common"; import { inject, Inject, Injectable } from "@angular/core"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +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"; @@ -29,6 +31,8 @@ export class InitService { private sdkLoadService: SdkLoadService, private viewCacheService: PopupViewCacheService, private readonly migrationRunner: MigrationRunner, + private configService: ConfigService, + private encryptService: EncryptService, @Inject(DOCUMENT) private document: Document, ) {} @@ -40,6 +44,7 @@ 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 d87c9417c85..c462319dc2e 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -29,13 +29,16 @@ import { TwoFactorAuthDuoComponentService, TwoFactorAuthWebAuthnComponentService, SsoComponentService, + NewDeviceVerificationComponentService, } from "@bitwarden/auth/angular"; import { LockService, LoginEmailService, SsoUrlService, LogoutService, + UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; +import { ExtensionNewDeviceVerificationComponentService } from "@bitwarden/browser/auth/services/new-device-verification/extension-new-device-verification-component.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -73,7 +76,6 @@ 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 { VaultTimeoutService, VaultTimeoutStringType, @@ -119,10 +121,12 @@ import { SystemNotificationsService } from "@bitwarden/common/platform/system-no import { UnsupportedSystemNotificationsService } from "@bitwarden/common/platform/system-notifications/unsupported-system-notifications.service"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; +import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { @@ -138,15 +142,16 @@ import { KdfConfigService, KeyService, } from "@bitwarden/key-management"; -import { LockComponentService } from "@bitwarden/key-management-ui"; +import { + LockComponentService, + SessionTimeoutSettingsComponentService, +} from "@bitwarden/key-management-ui"; import { DerivedStateProvider, GlobalStateProvider, StateProvider } from "@bitwarden/state"; import { InlineDerivedStateProvider } from "@bitwarden/state-internal"; import { DefaultSshImportPromptService, PasswordRepromptService, SshImportPromptService, - CipherArchiveService, - DefaultCipherArchiveService, } from "@bitwarden/vault"; import { AccountSwitcherService } from "../../auth/popup/account-switching/services/account-switcher.service"; @@ -164,6 +169,7 @@ import AutofillService from "../../autofill/services/autofill.service"; import { InlineMenuFieldQualificationService } from "../../autofill/services/inline-menu-field-qualification.service"; import { ForegroundBrowserBiometricsService } from "../../key-management/biometrics/foreground-browser-biometrics"; import { ExtensionLockComponentService } from "../../key-management/lock/services/extension-lock-component.service"; +import { BrowserSessionTimeoutSettingsComponentService } from "../../key-management/session-timeout/services/browser-session-timeout-settings-component.service"; import { ForegroundVaultTimeoutService } from "../../key-management/vault-timeout/foreground-vault-timeout.service"; import { BrowserActionsService } from "../../platform/actions/browser-actions.service"; import { BrowserApi } from "../../platform/browser/browser-api"; @@ -269,7 +275,6 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: KeyService, useFactory: ( - pinService: PinServiceAbstraction, masterPasswordService: InternalMasterPasswordServiceAbstraction, keyGenerationService: KeyGenerationService, cryptoFunctionService: CryptoFunctionService, @@ -282,7 +287,6 @@ const safeProviders: SafeProvider[] = [ kdfConfigService: KdfConfigService, ) => { const keyService = new DefaultKeyService( - pinService, masterPasswordService, keyGenerationService, cryptoFunctionService, @@ -298,7 +302,6 @@ const safeProviders: SafeProvider[] = [ return keyService; }, deps: [ - PinServiceAbstraction, InternalMasterPasswordServiceAbstraction, KeyGenerationService, CryptoFunctionService, @@ -366,7 +369,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: DomainSettingsService, useClass: DefaultDomainSettingsService, - deps: [StateProvider], + deps: [StateProvider, PolicyService, AccountService], }), safeProvider({ provide: AbstractStorageService, @@ -605,7 +608,12 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: Fido2UserVerificationService, useClass: Fido2UserVerificationService, - deps: [PasswordRepromptService, UserVerificationService, DialogService], + deps: [ + PasswordRepromptService, + UserDecryptionOptionsServiceAbstraction, + DialogService, + AccountServiceAbstraction, + ], }), safeProvider({ provide: AnimationControlService, @@ -708,14 +716,17 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: CipherArchiveService, useClass: DefaultCipherArchiveService, - deps: [ - CipherService, - ApiService, - DialogService, - PasswordRepromptService, - BillingAccountProfileStateService, - ConfigService, - ], + deps: [CipherService, ApiService, BillingAccountProfileStateService, ConfigService], + }), + safeProvider({ + provide: NewDeviceVerificationComponentService, + useClass: ExtensionNewDeviceVerificationComponentService, + deps: [], + }), + safeProvider({ + provide: SessionTimeoutSettingsComponentService, + useClass: BrowserSessionTimeoutSettingsComponentService, + deps: [I18nServiceAbstraction, PlatformUtilsService, MessagingServiceAbstraction], }), ]; diff --git a/apps/browser/src/popup/tabs-v2.component.ts b/apps/browser/src/popup/tabs-v2.component.ts index f1e42799b35..1c409fee639 100644 --- a/apps/browser/src/popup/tabs-v2.component.ts +++ b/apps/browser/src/popup/tabs-v2.component.ts @@ -17,6 +17,8 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { NavButton } from "../platform/popup/layout/popup-tab-navigation.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-tabs-v2", templateUrl: "./tabs-v2.component.html", diff --git a/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift b/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift index 54e91611325..dad1e6855fc 100644 --- a/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift +++ b/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift @@ -69,8 +69,8 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { if let url = panel.url { do { let fileManager = FileManager.default - if !fileManager.fileExists(atPath: url.absoluteString) { - fileManager.createFile(atPath: url.absoluteString, contents: Data(), + if !fileManager.fileExists(atPath: url.path) { + fileManager.createFile(atPath: url.path, contents: Data(), attributes: nil) } try data.write(to: url) 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 index 25b80c82c57..33044b79351 100644 --- a/apps/browser/src/tools/popup/components/file-popout-callout.component.ts +++ b/apps/browser/src/tools/popup/components/file-popout-callout.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, OnInit } from "@angular/core"; @@ -9,16 +7,18 @@ 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; - protected showFirefoxFileWarning: boolean; - protected showSafariFileWarning: boolean; - protected showChromiumFileWarning: boolean; + protected showFilePopoutMessage: boolean = false; + protected showFirefoxFileWarning: boolean = false; + protected showSafariFileWarning: boolean = false; + protected showChromiumFileWarning: boolean = false; constructor(private filePopoutUtilsService: FilePopoutUtilsService) {} diff --git a/apps/browser/src/tools/popup/generator/credential-generator-history.component.ts b/apps/browser/src/tools/popup/generator/credential-generator-history.component.ts index 441e5d6e4c6..90a23c82330 100644 --- a/apps/browser/src/tools/popup/generator/credential-generator-history.component.ts +++ b/apps/browser/src/tools/popup/generator/credential-generator-history.component.ts @@ -25,6 +25,8 @@ import { PopupFooterComponent } from "../../../platform/popup/layout/popup-foote import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.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-credential-generator-history", templateUrl: "credential-generator-history.component.html", @@ -52,6 +54,8 @@ export class CredentialGeneratorHistoryComponent implements OnChanges, OnInit, O private logService: LogService, ) {} + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() account: Account | null; @@ -60,6 +64,8 @@ export class CredentialGeneratorHistoryComponent implements OnChanges, OnInit, O * * @warning this may reveal sensitive information in plaintext. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() debug: boolean = false; diff --git a/apps/browser/src/tools/popup/generator/credential-generator.component.ts b/apps/browser/src/tools/popup/generator/credential-generator.component.ts index b34c829b006..a69a7f52a6f 100644 --- a/apps/browser/src/tools/popup/generator/credential-generator.component.ts +++ b/apps/browser/src/tools/popup/generator/credential-generator.component.ts @@ -10,6 +10,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; +// 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: "credential-generator", templateUrl: "credential-generator.component.html", 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 c6ea52aff62..a72847a5bf2 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 @@ -16,7 +16,7 @@ -
    -

    +

    {{ "createdSendSuccessfully" | i18n }}

    diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts index 30359e98fa0..e9109ec6c21 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts @@ -20,6 +20,8 @@ import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-fo import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.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-send-created", templateUrl: "./send-created.component.html", diff --git a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts index 251f19cf252..1f0d9f2a0c9 100644 --- a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts @@ -1,24 +1,26 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, Input, OnInit } from "@angular/core"; +import { Component, input, OnInit } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; -import { DialogService } from "@bitwarden/components"; +import { CenterPositionStrategy, DialogService } from "@bitwarden/components"; import { SendFormConfig } from "@bitwarden/send-ui"; import { FilePopoutUtilsService } from "../../services/file-popout-utils.service"; import { SendFilePopoutDialogComponent } from "./send-file-popout-dialog.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: "send-file-popout-dialog-container", templateUrl: "./send-file-popout-dialog-container.component.html", imports: [JslibModule, CommonModule], }) export class SendFilePopoutDialogContainerComponent implements OnInit { - @Input() config: SendFormConfig; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + config = input.required(); constructor( private dialogService: DialogService, @@ -27,11 +29,13 @@ export class SendFilePopoutDialogContainerComponent implements OnInit { ngOnInit() { if ( - this.config?.sendType === SendType.File && - this.config?.mode === "add" && + this.config().sendType === SendType.File && + this.config().mode === "add" && this.filePopoutUtilsService.showFilePopoutMessage(window) ) { - this.dialogService.open(SendFilePopoutDialogComponent); + this.dialogService.open(SendFilePopoutDialogComponent, { + positionStrategy: new CenterPositionStrategy(), + }); } } } 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 index 64c95a2e2f7..23fa744995a 100644 --- 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 @@ -6,6 +6,8 @@ import { ButtonModule, DialogModule, DialogService, TypographyModule } from "@bi 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", 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 997b65e9934..47ecd7564dc 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 @@ - + @@ -6,7 +6,7 @@ - + {{ "sendDisabledWarning" | i18n }} @@ -34,7 +34,7 @@

    - +
    + @if (showSkeletonsLoaders$ | async) { + + + + } 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 63ede7ba357..6d79f430a37 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 @@ -37,7 +37,7 @@ import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-heade import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popup-router-cache.service"; -import { SendV2Component, SendState } from "./send-v2.component"; +import { SendState, SendV2Component } from "./send-v2.component"; describe("SendV2Component", () => { let component: SendV2Component; 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 a37c038d822..89769bdd1ce 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 @@ -1,17 +1,22 @@ import { CommonModule } from "@angular/common"; import { Component, OnDestroy } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { combineLatest, switchMap } from "rxjs"; +import { combineLatest, distinctUntilChanged, map, shareReplay, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { NoResults, NoSendsIcon } from "@bitwarden/assets/svg"; +import { VaultLoadingSkeletonComponent } from "@bitwarden/browser/vault/popup/components/vault-loading-skeleton/vault-loading-skeleton.component"; import { BrowserPremiumUpgradePromptService } from "@bitwarden/browser/vault/popup/services/browser-premium-upgrade-prompt.service"; 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; +import { skeletonLoadingDelay } from "@bitwarden/common/vault/utils/skeleton-loading.operator"; import { ButtonModule, CalloutModule, @@ -31,14 +36,21 @@ import { CurrentAccountComponent } from "../../../auth/popup/account-switching/c 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 { VaultFadeInOutSkeletonComponent } from "../../../vault/popup/components/vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component"; -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum SendState { - Empty, - NoResults, -} +/** A state of the Send list UI. */ +export const SendState = Object.freeze({ + /** No sends exist for the current filter (file or text). */ + Empty: "Empty", + /** Sends exist, but none match the current filter/search. */ + NoResults: "NoResults", +} as const); +/** A state of the Send list UI. */ +export type SendState = (typeof SendState)[keyof typeof SendState]; + +// 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: "send-v2.component.html", providers: [ @@ -62,6 +74,8 @@ export enum SendState { SendListFiltersComponent, SendSearchComponent, TypographyModule, + VaultFadeInOutSkeletonComponent, + VaultLoadingSkeletonComponent, ], }) export class SendV2Component implements OnDestroy { @@ -70,18 +84,52 @@ export class SendV2Component implements OnDestroy { protected listState: SendState | null = null; protected sends$ = this.sendItemsService.filteredAndSortedSends$; - protected sendsLoading$ = this.sendItemsService.loading$; + 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, + ), + distinctUntilChanged(), + skeletonLoadingDelay(), + ); + protected title: string = "allSends"; protected noItemIcon = NoSendsIcon; protected noResultsIcon = NoResults; protected sendsDisabled = false; + private readonly sendTypeTitles: Record = { + [SendType.File]: "fileSends", + [SendType.Text]: "textSends", + }; + constructor( protected sendItemsService: SendItemsService, protected sendListFiltersService: SendListFiltersService, private policyService: PolicyService, private accountService: AccountService, + private configService: ConfigService, + private searchService: SearchService, ) { combineLatest([ this.sendItemsService.emptyList$, @@ -91,7 +139,7 @@ export class SendV2Component implements OnDestroy { .pipe(takeUntilDestroyed()) .subscribe(([emptyList, noFilteredResults, currentFilter]) => { if (currentFilter?.sendType !== null) { - this.title = `${this.sendType[currentFilter.sendType].toLowerCase()}Sends`; + this.title = this.sendTypeTitles[currentFilter.sendType] ?? "allSends"; } else { this.title = "allSends"; } diff --git a/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.ts b/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.ts index 39bff089668..2fa401f0010 100644 --- a/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.ts +++ b/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.ts @@ -16,11 +16,15 @@ import { TypographyModule, } from "@bitwarden/components"; +// 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: "about-dialog.component.html", imports: [CommonModule, JslibModule, DialogModule, ButtonModule, TypographyModule], }) export class AboutDialogComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("version") protected version!: ElementRef; protected year = new Date().getFullYear(); diff --git a/apps/browser/src/tools/popup/settings/about-page/about-page-v2.component.ts b/apps/browser/src/tools/popup/settings/about-page/about-page-v2.component.ts index 67212dc5c4a..88f6ad96807 100644 --- a/apps/browser/src/tools/popup/settings/about-page/about-page-v2.component.ts +++ b/apps/browser/src/tools/popup/settings/about-page/about-page-v2.component.ts @@ -7,7 +7,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { DeviceType } from "@bitwarden/common/enums"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { DialogService, ItemModule } from "@bitwarden/components"; +import { CenterPositionStrategy, DialogService, ItemModule } from "@bitwarden/components"; import { BrowserApi } from "../../../../platform/browser/browser-api"; import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; @@ -29,6 +29,8 @@ const RateUrls = { [DeviceType.SafariExtension]: "https://apps.apple.com/app/bitwarden/id1352778147", }; +// 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: "about-page-v2.component.html", imports: [ @@ -49,7 +51,9 @@ export class AboutPageV2Component { ) {} about() { - this.dialogService.open(AboutDialogComponent); + this.dialogService.open(AboutDialogComponent, { + positionStrategy: new CenterPositionStrategy(), + }); } async launchHelp() { diff --git a/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html b/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html index 8493fa5fee7..d6bf3a3a253 100644 --- a/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html +++ b/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html @@ -23,7 +23,7 @@ > {{ "exportVault" | i18n }} - diff --git a/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.ts b/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.ts index 5aebee3b781..584c7cd3f7c 100644 --- a/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.ts +++ b/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.ts @@ -12,6 +12,8 @@ import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-fo import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.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({ templateUrl: "export-browser-v2.component.html", imports: [ diff --git a/apps/browser/src/tools/popup/settings/import/import-browser-v2.component.ts b/apps/browser/src/tools/popup/settings/import/import-browser-v2.component.ts index 506dae2fb18..a88cc3f81dc 100644 --- a/apps/browser/src/tools/popup/settings/import/import-browser-v2.component.ts +++ b/apps/browser/src/tools/popup/settings/import/import-browser-v2.component.ts @@ -4,13 +4,24 @@ import { Router } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AsyncActionsModule, ButtonModule, DialogModule } from "@bitwarden/components"; -import { ImportComponent } from "@bitwarden/importer-ui"; +import { + DefaultImportMetadataService, + ImportMetadataServiceAbstraction, +} from "@bitwarden/importer-core"; +import { + ImportComponent, + ImporterProviders, + SYSTEM_SERVICE_PROVIDER, +} from "@bitwarden/importer-ui"; +import { safeProvider } from "@bitwarden/ui-common"; import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; +// 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: "import-browser-v2.component.html", imports: [ @@ -25,6 +36,14 @@ import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page PopupHeaderComponent, PopOutComponent, ], + providers: [ + ...ImporterProviders, + safeProvider({ + provide: ImportMetadataServiceAbstraction, + useClass: DefaultImportMetadataService, + deps: [SYSTEM_SERVICE_PROVIDER], + }), + ], }) export class ImportBrowserV2Component { protected disabled = false; 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 a12c5fe005f..683b7d70ed6 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.html +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.html @@ -1,4 +1,19 @@ + + {{ "unlockFeaturesWithPremium" | i18n }} + + + @@ -20,7 +35,7 @@

    {{ "autofill" | i18n }}

    { + let account$: BehaviorSubject; + let mockAccountService: Partial; + let mockBillingState: { hasPremiumFromAnySource$: jest.Mock }; + let mockNudges: { + showNudgeBadge$: jest.Mock; + dismissNudge: jest.Mock; + }; + let mockAutofillSettings: { + defaultBrowserAutofillDisabled$: Subject; + isBrowserAutofillSettingOverridden: jest.Mock>; + }; + let dialogService: MockProxy; + let openSpy: jest.SpyInstance; + + beforeEach(waitForAsync(async () => { + dialogService = mock(); + account$ = new BehaviorSubject(null); + mockAccountService = { + activeAccount$: account$ as unknown as AccountService["activeAccount$"], + }; + + mockBillingState = { + hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)), + }; + + mockNudges = { + showNudgeBadge$: jest.fn().mockImplementation(() => of(false)), + dismissNudge: jest.fn().mockResolvedValue(undefined), + }; + + mockAutofillSettings = { + defaultBrowserAutofillDisabled$: new BehaviorSubject(false), + isBrowserAutofillSettingOverridden: jest.fn().mockResolvedValue(false), + }; + + jest.spyOn(BrowserApi, "getBrowserClientVendor").mockReturnValue("Chrome"); + + const cfg = TestBed.configureTestingModule({ + imports: [SettingsV2Component, RouterTestingModule], + providers: [ + { provide: AccountService, useValue: mockAccountService }, + { provide: BillingAccountProfileStateService, useValue: mockBillingState }, + { provide: NudgesService, useValue: mockNudges }, + { provide: AutofillBrowserSettingsService, useValue: mockAutofillSettings }, + { provide: DialogService, useValue: dialogService }, + { provide: I18nService, useValue: { t: jest.fn((key: string) => key) } }, + { provide: GlobalStateProvider, useValue: new FakeGlobalStateProvider() }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: AvatarService, useValue: mock() }, + { provide: AuthService, useValue: mock() }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }); + + TestBed.overrideComponent(SettingsV2Component, { + add: { + imports: [CurrentAccountStubComponent], + providers: [{ provide: DialogService, useValue: dialogService }], + }, + remove: { + imports: [CurrentAccountComponent], + }, + }); + + await cfg.compileComponents(); + })); + + afterEach(() => { + jest.resetAllMocks(); + }); + + function pushActiveAccount(id = "user-123"): Account { + const acct = { id } as Account; + account$.next(acct); + return acct; + } + + it("shows the premium spotlight when user does NOT have premium", async () => { + mockBillingState.hasPremiumFromAnySource$.mockReturnValue(of(false)); + pushActiveAccount(); + + const fixture = TestBed.createComponent(SettingsV2Component); + fixture.detectChanges(); + await fixture.whenStable(); + + const el: HTMLElement = fixture.nativeElement; + + expect(el.querySelector("bit-spotlight")).toBeTruthy(); + }); + + it("hides the premium spotlight when user HAS premium", async () => { + mockBillingState.hasPremiumFromAnySource$.mockReturnValue(of(true)); + pushActiveAccount(); + + const fixture = TestBed.createComponent(SettingsV2Component); + fixture.detectChanges(); + await fixture.whenStable(); + + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector("bit-spotlight")).toBeFalsy(); + }); + + it("openUpgradeDialog calls PremiumUpgradeDialogComponent.open with the DialogService", async () => { + openSpy = jest.spyOn(PremiumUpgradeDialogComponent, "open").mockImplementation(); + mockBillingState.hasPremiumFromAnySource$.mockReturnValue(of(false)); + pushActiveAccount(); + + const fixture = TestBed.createComponent(SettingsV2Component); + const component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + component["openUpgradeDialog"](); + expect(openSpy).toHaveBeenCalledTimes(1); + expect(openSpy).toHaveBeenCalledWith(dialogService); + }); + + it("isBrowserAutofillSettingOverridden$ emits the value from the AutofillBrowserSettingsService", async () => { + pushActiveAccount(); + + mockAutofillSettings.isBrowserAutofillSettingOverridden.mockResolvedValue(true); + + const fixture = TestBed.createComponent(SettingsV2Component); + const component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + const value = await firstValueFrom(component["isBrowserAutofillSettingOverridden$"]); + expect(value).toBe(true); + + mockAutofillSettings.isBrowserAutofillSettingOverridden.mockResolvedValue(false); + + const fixture2 = TestBed.createComponent(SettingsV2Component); + const component2 = fixture2.componentInstance; + fixture2.detectChanges(); + await fixture2.whenStable(); + + const value2 = await firstValueFrom(component2["isBrowserAutofillSettingOverridden$"]); + expect(value2).toBe(false); + }); + + it("showAutofillBadge$ emits true when default autofill is NOT disabled and nudge is true", async () => { + pushActiveAccount(); + + mockNudges.showNudgeBadge$.mockImplementation((type: NudgeType) => + of(type === NudgeType.AutofillNudge), + ); + + const fixture = TestBed.createComponent(SettingsV2Component); + const component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + mockAutofillSettings.defaultBrowserAutofillDisabled$.next(false); + + const value = await firstValueFrom(component.showAutofillBadge$); + expect(value).toBe(true); + }); + + it("showAutofillBadge$ emits false when default autofill IS disabled even if nudge is true", async () => { + pushActiveAccount(); + + mockNudges.showNudgeBadge$.mockImplementation((type: NudgeType) => + of(type === NudgeType.AutofillNudge), + ); + + const fixture = TestBed.createComponent(SettingsV2Component); + const component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + mockAutofillSettings.defaultBrowserAutofillDisabled$.next(true); + + const value = await firstValueFrom(component.showAutofillBadge$); + expect(value).toBe(false); + }); + + it("dismissBadge dismisses when showVaultBadge$ emits true", async () => { + const acct = pushActiveAccount(); + + mockNudges.showNudgeBadge$.mockImplementation((type: NudgeType) => { + return of(type === NudgeType.EmptyVaultNudge); + }); + + const fixture = TestBed.createComponent(SettingsV2Component); + const component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + await component.dismissBadge(NudgeType.EmptyVaultNudge); + + expect(mockNudges.dismissNudge).toHaveBeenCalledTimes(1); + expect(mockNudges.dismissNudge).toHaveBeenCalledWith(NudgeType.EmptyVaultNudge, acct.id, true); + }); + + it("dismissBadge does nothing when showVaultBadge$ emits false", async () => { + pushActiveAccount(); + + mockNudges.showNudgeBadge$.mockReturnValue(of(false)); + + const fixture = TestBed.createComponent(SettingsV2Component); + const component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + await component.dismissBadge(NudgeType.EmptyVaultNudge); + + expect(mockNudges.dismissNudge).not.toHaveBeenCalled(); + }); + + it("showDownloadBitwardenNudge$ proxies to nudges service for the active account", async () => { + const acct = pushActiveAccount("user-xyz"); + + mockNudges.showNudgeBadge$.mockImplementation((type: NudgeType) => + of(type === NudgeType.DownloadBitwarden), + ); + + const fixture = TestBed.createComponent(SettingsV2Component); + const component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + const val = await firstValueFrom(component.showDownloadBitwardenNudge$); + expect(val).toBe(true); + expect(mockNudges.showNudgeBadge$).toHaveBeenCalledWith(NudgeType.DownloadBitwarden, acct.id); + }); +}); diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.ts b/apps/browser/src/tools/popup/settings/settings-v2.component.ts index 205d4063b59..95aeeb2f480 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.ts +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.ts @@ -1,21 +1,31 @@ import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component } from "@angular/core"; import { RouterModule } from "@angular/router"; import { combineLatest, filter, firstValueFrom, + from, map, Observable, shareReplay, switchMap, } 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 { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { UserId } from "@bitwarden/common/types/guid"; -import { BadgeComponent, ItemModule } from "@bitwarden/components"; +import { + BadgeComponent, + DialogService, + ItemModule, + LinkModule, + TypographyModule, +} from "@bitwarden/components"; import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component"; import { AutofillBrowserSettingsService } from "../../../autofill/services/autofill-browser-settings.service"; @@ -36,18 +46,30 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co ItemModule, CurrentAccountComponent, BadgeComponent, + SpotlightComponent, + TypographyModule, + LinkModule, ], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SettingsV2Component implements OnInit { +export class SettingsV2Component { NudgeType = NudgeType; - activeUserId: UserId | null = null; - protected isBrowserAutofillSettingOverridden = false; + + protected isBrowserAutofillSettingOverridden$ = from( + this.autofillBrowserSettingsService.isBrowserAutofillSettingOverridden( + BrowserApi.getBrowserClientVendor(window), + ), + ); private authenticatedAccount$: Observable = this.accountService.activeAccount$.pipe( filter((account): account is Account => account !== null), shareReplay({ bufferSize: 1, refCount: true }), ); + protected hasPremium$ = this.authenticatedAccount$.pipe( + switchMap((account) => this.accountProfileStateService.hasPremiumFromAnySource$(account.id)), + ); + showDownloadBitwardenNudge$: Observable = this.authenticatedAccount$.pipe( switchMap((account) => this.nudgesService.showNudgeBadge$(NudgeType.DownloadBitwarden, account.id), @@ -77,13 +99,12 @@ export class SettingsV2Component implements OnInit { private readonly nudgesService: NudgesService, private readonly accountService: AccountService, private readonly autofillBrowserSettingsService: AutofillBrowserSettingsService, + private readonly accountProfileStateService: BillingAccountProfileStateService, + private readonly dialogService: DialogService, ) {} - async ngOnInit() { - this.isBrowserAutofillSettingOverridden = - await this.autofillBrowserSettingsService.isBrowserAutofillSettingOverridden( - BrowserApi.getBrowserClientVendor(window), - ); + protected openUpgradeDialog() { + PremiumUpgradeDialogComponent.open(this.dialogService); } async dismissBadge(type: NudgeType) { diff --git a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.html b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.html index 16d9b6a322a..0efe2bd14e2 100644 --- a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.html +++ b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.html @@ -1,9 +1,28 @@ - - - - {{ - (taskCount === 1 ? "reviewAndChangeAtRiskPassword" : "reviewAndChangeAtRiskPasswordsPlural") - | i18n: taskCount.toString() - }} - - +@if ((currentPendingTasks$ | async)?.length > 0) { + + + {{ + ((currentPendingTasks$ | async)?.length === 1 + ? "reviewAndChangeAtRiskPassword" + : "reviewAndChangeAtRiskPasswordsPlural" + ) | i18n: (currentPendingTasks$ | async)?.length.toString() + }} + + +} + +@if (showCompletedTasksBanner$ | async) { + + {{ "atRiskLoginsSecured" | i18n }} + +} diff --git a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts index 3c3270e557c..c37131b3ff1 100644 --- a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts @@ -1,42 +1,49 @@ import { CommonModule } from "@angular/common"; import { Component, inject } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { combineLatest, map, switchMap } from "rxjs"; +import { firstValueFrom, switchMap } from "rxjs"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; -import { AnchorLinkDirective, CalloutModule } from "@bitwarden/components"; +import { AnchorLinkDirective, CalloutModule, BannerModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +import { AtRiskPasswordCalloutData, AtRiskPasswordCalloutService } from "@bitwarden/vault"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "vault-at-risk-password-callout", - imports: [CommonModule, AnchorLinkDirective, RouterModule, CalloutModule, I18nPipe], + imports: [ + AnchorLinkDirective, + CommonModule, + RouterModule, + CalloutModule, + I18nPipe, + BannerModule, + JslibModule, + ], + providers: [AtRiskPasswordCalloutService], templateUrl: "./at-risk-password-callout.component.html", }) export class AtRiskPasswordCalloutComponent { - private taskService = inject(TaskService); - private cipherService = inject(CipherService); private activeAccount$ = inject(AccountService).activeAccount$.pipe(getUserId); + private atRiskPasswordCalloutService = inject(AtRiskPasswordCalloutService); - protected pendingTasks$ = this.activeAccount$.pipe( - switchMap((userId) => - combineLatest([ - this.taskService.pendingTasks$(userId), - this.cipherService.cipherViews$(userId), - ]), - ), - map(([tasks, ciphers]) => - tasks.filter((t) => { - const associatedCipher = ciphers.find((c) => c.id === t.cipherId); - - return ( - t.type === SecurityTaskType.UpdateAtRiskCredential && - associatedCipher && - !associatedCipher.isDeleted - ); - }), - ), + showCompletedTasksBanner$ = this.activeAccount$.pipe( + switchMap((userId) => this.atRiskPasswordCalloutService.showCompletedTasksBanner$(userId)), ); + + currentPendingTasks$ = this.activeAccount$.pipe( + switchMap((userId) => this.atRiskPasswordCalloutService.pendingTasks$(userId)), + ); + + async successBannerDismissed() { + const updateObject: AtRiskPasswordCalloutData = { + hasInteractedWithTasks: true, + tasksBannerDismissed: true, + }; + const userId = await firstValueFrom(this.activeAccount$); + this.atRiskPasswordCalloutService.updateAtRiskPasswordState(userId, updateObject); + } } diff --git a/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.ts b/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.ts index 08c466d21a9..1b83c316f41 100644 --- a/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.ts @@ -7,6 +7,7 @@ import { DialogModule, DialogService, TypographyModule, + CenterPositionStrategy, } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { DarkImageSourceDirective, VaultCarouselModule } from "@bitwarden/vault"; @@ -17,6 +18,8 @@ export const AtRiskCarouselDialogResult = { type AtRiskCarouselDialogResult = UnionOfValues; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "vault-at-risk-carousel-dialog", templateUrl: "./at-risk-carousel-dialog.component.html", @@ -32,6 +35,8 @@ type AtRiskCarouselDialogResult = UnionOfValues(AtRiskCarouselDialogComponent, { disableClose: true, + positionStrategy: new CenterPositionStrategy(), }); } } diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.html b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.html index 1ffb404fddb..3953d8f1eab 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.html +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.html @@ -53,12 +53,14 @@
    {{ cipher.name }} - {{ cipher.subTitle }} +
    + +
    +
    +
    + @for (url of savedUrls(); track url) { +
    + +
    + {{ url }} +
    +
    +
    + } +
    + } +

    + {{ "currentWebsite" | i18n }} +

    + +
    + {{ currentUrl() }} +
    +
    +
    + @if (!viewOnly()) { + + } + + +
    +
    + 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 new file mode 100644 index 00000000000..a28b8730109 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts @@ -0,0 +1,266 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { provideNoopAnimations } from "@angular/platform-browser/animations"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { DIALOG_DATA, DialogRef, DialogService } from "@bitwarden/components"; + +import { + AutofillConfirmationDialogComponent, + AutofillConfirmationDialogResult, + AutofillConfirmationDialogParams, +} from "./autofill-confirmation-dialog.component"; + +describe("AutofillConfirmationDialogComponent", () => { + let fixture: ComponentFixture; + let component: AutofillConfirmationDialogComponent; + + const dialogRef = { + close: jest.fn(), + } as unknown as DialogRef; + + const params: AutofillConfirmationDialogParams = { + currentUrl: "https://example.com/path?q=1", + savedUrls: ["https://one.example.com/a", "https://two.example.com/b", "not-a-url.example"], + }; + + async function createFreshFixture(options?: { + params?: AutofillConfirmationDialogParams; + viewOnly?: boolean; + }) { + const base = options?.params ?? params; + const p: AutofillConfirmationDialogParams = { + ...base, + viewOnly: options?.viewOnly, + }; + + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [AutofillConfirmationDialogComponent], + providers: [ + provideNoopAnimations(), + { provide: DIALOG_DATA, useValue: p }, + { provide: DialogRef, useValue: dialogRef }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: DialogService, useValue: {} }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + const freshFixture = TestBed.createComponent(AutofillConfirmationDialogComponent); + const freshInstance = freshFixture.componentInstance; + freshFixture.detectChanges(); + return { fixture: freshFixture, component: freshInstance }; + } + + beforeEach(async () => { + jest.spyOn(Utils, "getHostname").mockImplementation((value: string | null | undefined) => { + if (typeof value !== "string" || !value) { + return ""; + } + try { + // handle non-URL host strings gracefully + if (!value.includes("://")) { + return value; + } + return new URL(value).hostname; + } catch { + return ""; + } + }); + + await TestBed.configureTestingModule({ + imports: [AutofillConfirmationDialogComponent], + providers: [ + provideNoopAnimations(), + { provide: DIALOG_DATA, useValue: params }, + { provide: DialogRef, useValue: dialogRef }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: DialogService, useValue: {} }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(AutofillConfirmationDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const findShowAll = (inFx?: ComponentFixture) => + (inFx || fixture).nativeElement.querySelector( + "button.tw-text-sm.tw-font-medium.tw-cursor-pointer", + ) as HTMLButtonElement | null; + + it("normalizes currentUrl and savedUrls via Utils.getHostname", () => { + expect(Utils.getHostname).toHaveBeenCalledTimes(1 + (params.savedUrls?.length ?? 0)); + expect(component.currentUrl()).toBe("example.com"); + expect(component.savedUrls()).toEqual([ + "one.example.com", + "two.example.com", + "not-a-url.example", + ]); + }); + + it("renders normalized values into the template (shallow check)", () => { + const text = fixture.nativeElement.textContent as string; + expect(text).toContain("example.com"); + expect(text).toContain("one.example.com"); + expect(text).toContain("two.example.com"); + expect(text).toContain("not-a-url.example"); + }); + + it("emits Canceled on close()", () => { + const spy = jest.spyOn(dialogRef, "close"); + (component as any)["close"](); + expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.Canceled); + }); + + it("emits AutofillAndUrlAdded on autofillAndAddUrl()", () => { + const spy = jest.spyOn(dialogRef, "close"); + (component as any)["autofillAndAddUrl"](); + expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.AutofillAndUrlAdded); + }); + + it("emits AutofilledOnly on autofillOnly()", () => { + const spy = jest.spyOn(dialogRef, "close"); + (component as any)["autofillOnly"](); + expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.AutofilledOnly); + }); + + it("applies collapsed list gradient class by default, then clears it after toggling", () => { + const initial = component.savedUrlsListClass(); + expect(initial).toContain("gradient"); + + component.toggleSavedUrlExpandedState(); + fixture.detectChanges(); + + const expanded = component.savedUrlsListClass(); + expect(expanded).toBe(""); + }); + + it("handles empty savedUrls gracefully", async () => { + const newParams: AutofillConfirmationDialogParams = { + currentUrl: "https://bitwarden.com/help", + savedUrls: [], + }; + + const { component: fresh } = await createFreshFixture({ params: newParams }); + expect(fresh.savedUrls()).toEqual([]); + expect(fresh.currentUrl()).toBe("bitwarden.com"); + }); + + it("handles undefined savedUrls by defaulting to [] and empty strings from Utils.getHostname", async () => { + const localParams: AutofillConfirmationDialogParams = { + currentUrl: "https://sub.domain.tld/x", + }; + + const { component: local } = await createFreshFixture({ params: localParams }); + expect(local.savedUrls()).toEqual([]); + expect(local.currentUrl()).toBe("sub.domain.tld"); + }); + + it("filters out falsy/invalid values from Utils.getHostname in savedUrls", async () => { + const hostSpy = jest.spyOn(Utils, "getHostname"); + hostSpy.mockImplementationOnce(() => "example.com"); + hostSpy.mockImplementationOnce(() => "ok.example"); + hostSpy.mockImplementationOnce(() => ""); + hostSpy.mockImplementationOnce(() => undefined as unknown as string); + + const edgeParams: AutofillConfirmationDialogParams = { + currentUrl: "https://example.com", + savedUrls: ["https://ok.example", "://bad", "%%%"], + }; + + const { component: edge } = await createFreshFixture({ params: edgeParams }); + + expect(edge.currentUrl()).toBe("example.com"); + expect(edge.savedUrls()).toEqual(["ok.example"]); + }); + + it("renders one current-url callout and N saved-url callouts", () => { + const callouts = Array.from( + fixture.nativeElement.querySelectorAll("bit-callout"), + ) as HTMLElement[]; + expect(callouts.length).toBe(1 + params.savedUrls!.length); + }); + + it("renders normalized hostnames into the DOM text", () => { + const text = (fixture.nativeElement.textContent as string).replace(/\s+/g, " "); + expect(text).toContain("example.com"); + expect(text).toContain("one.example.com"); + expect(text).toContain("two.example.com"); + }); + + it("shows the 'show all' button when savedUrls > 1", () => { + const btn = findShowAll(); + expect(btn).toBeTruthy(); + expect(btn!.textContent).toContain("showAll"); + }); + + it('hides the "show all" button when savedUrls is empty', async () => { + const newParams: AutofillConfirmationDialogParams = { + currentUrl: "https://bitwarden.com/help", + savedUrls: [], + }; + + const { fixture: vf } = await createFreshFixture({ params: newParams }); + vf.detectChanges(); + const btn = findShowAll(vf); + expect(btn).toBeNull(); + }); + + it("handles toggling of the 'show all' button correctly", async () => { + const { fixture: vf, component: vc } = await createFreshFixture(); + + let btn = findShowAll(vf); + expect(btn).toBeTruthy(); + expect(vc.savedUrlsExpanded()).toBe(false); + expect(btn!.textContent).toContain("showAll"); + + // click to expand + btn!.click(); + vf.detectChanges(); + + btn = findShowAll(vf); + expect(btn!.textContent).toContain("showLess"); + expect(vc.savedUrlsExpanded()).toBe(true); + + // click to collapse + btn!.click(); + vf.detectChanges(); + + btn = findShowAll(vf); + expect(btn!.textContent).toContain("showAll"); + expect(vc.savedUrlsExpanded()).toBe(false); + }); + + it("shows autofillWithoutAdding text on autofill button when viewOnly is false", () => { + fixture.detectChanges(); + const text = fixture.nativeElement.textContent as string; + expect(text.includes("autofillWithoutAdding")).toBe(true); + }); + + it("does not show autofillWithoutAdding text on autofill button when viewOnly is true", async () => { + const { fixture: vf } = await createFreshFixture({ viewOnly: true }); + const text = vf.nativeElement.textContent as string; + expect(text.includes("autofillWithoutAdding")).toBe(false); + }); + + it("shows autofill and save button when viewOnly is false", () => { + // default viewOnly is false + fixture.detectChanges(); + const text = fixture.nativeElement.textContent as string; + expect(text.includes("autofillAndAddWebsite")).toBe(true); + }); + + it("does not show autofill and save button when viewOnly is true", async () => { + const { fixture: vf } = await createFreshFixture({ viewOnly: true }); + const text = vf.nativeElement.textContent as string; + expect(text.includes("autofillAndAddWebsite")).toBe(false); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts new file mode 100644 index 00000000000..3a9f70b7c4b --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts @@ -0,0 +1,92 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { + DIALOG_DATA, + DialogConfig, + DialogRef, + DialogService, + ButtonModule, + DialogModule, + TypographyModule, + CalloutComponent, + LinkModule, +} from "@bitwarden/components"; + +export interface AutofillConfirmationDialogParams { + savedUrls?: string[]; + currentUrl: string; + viewOnly?: boolean; +} + +export const AutofillConfirmationDialogResult = Object.freeze({ + AutofillAndUrlAdded: "added", + AutofilledOnly: "autofilled", + Canceled: "canceled", +} as const); + +export type AutofillConfirmationDialogResultType = UnionOfValues< + typeof AutofillConfirmationDialogResult +>; + +@Component({ + templateUrl: "./autofill-confirmation-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ButtonModule, + CalloutComponent, + CommonModule, + DialogModule, + LinkModule, + TypographyModule, + JslibModule, + ], +}) +export class AutofillConfirmationDialogComponent { + private readonly params = inject(DIALOG_DATA); + private readonly dialogRef = inject(DialogRef); + + readonly currentUrl = signal(Utils.getHostname(this.params.currentUrl)); + readonly savedUrls = signal( + (this.params.savedUrls ?? []).map((u) => Utils.getHostname(u) ?? "").filter(Boolean), + ); + readonly viewOnly = signal(this.params.viewOnly ?? false); + readonly savedUrlsExpanded = signal(false); + + readonly savedUrlsListClass = computed(() => + this.savedUrlsExpanded() + ? "" + : `tw-relative tw-max-h-24 tw-overflow-hidden after:tw-pointer-events-none + after:tw-content-[''] after:tw-absolute after:tw-inset-x-0 after:tw-bottom-0 + after:tw-h-8 after:tw-bg-gradient-to-t after:tw-from-background after:tw-to-transparent`, + ); + + toggleSavedUrlExpandedState() { + this.savedUrlsExpanded.update((v) => !v); + } + + close() { + this.dialogRef.close(AutofillConfirmationDialogResult.Canceled); + } + + autofillAndAddUrl() { + this.dialogRef.close(AutofillConfirmationDialogResult.AutofillAndUrlAdded); + } + + autofillOnly() { + this.dialogRef.close(AutofillConfirmationDialogResult.AutofilledOnly); + } + + static open( + dialogService: DialogService, + config: DialogConfig, + ) { + return dialogService.open( + AutofillConfirmationDialogComponent, + { ...config }, + ); + } +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts index 1eef907821d..64f662ab840 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts @@ -15,6 +15,8 @@ import { VaultPopupItemsService } from "../../../services/vault-popup-items.serv import { PopupCipherViewLike } from "../../../views/popup-cipher.view"; import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.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({ imports: [ CommonModule, @@ -46,7 +48,7 @@ export class AutofillVaultListItemsComponent { startWith(true), // Start with true to avoid flashing the fill button on first load ); - protected groupByType = toSignal( + protected readonly groupByType = toSignal( this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => !hasFilter)), ); diff --git a/apps/browser/src/vault/popup/components/vault-v2/blocked-injection-banner/blocked-injection-banner.component.ts b/apps/browser/src/vault/popup/components/vault-v2/blocked-injection-banner/blocked-injection-banner.component.ts index 5824e8d97ea..44a033137de 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/blocked-injection-banner/blocked-injection-banner.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/blocked-injection-banner/blocked-injection-banner.component.ts @@ -15,6 +15,8 @@ import { VaultPopupAutofillService } from "../../../services/vault-popup-autofil const blockedURISettingsRoute = "/blocked-domains"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ imports: [ BannerModule, @@ -28,6 +30,8 @@ const blockedURISettingsRoute = "/blocked-domains"; selector: "blocked-injection-banner", templateUrl: "blocked-injection-banner.component.html", }) +// FIXME(https://bitwarden.atlassian.net/browse/PM-28231): Use Component suffix +// eslint-disable-next-line @angular-eslint/component-class-suffix export class BlockedInjectionBanner implements OnInit { /** * Flag indicating that the banner should be shown 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 94996a054e6..48c8f5682bc 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 @@ -9,6 +9,8 @@ import { VaultCarouselModule } from "@bitwarden/vault"; import { IntroCarouselService } from "../../../services/intro-carousel.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: "app-intro-carousel", templateUrl: "./intro-carousel.component.html", diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts index 6c7e8bcfbc3..e24db60a55a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts @@ -10,7 +10,7 @@ import { } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { IconButtonModule, ItemModule, MenuModule } from "@bitwarden/components"; import { CopyableCipherFields } from "@bitwarden/sdk-internal"; -import { CopyAction, CopyCipherFieldDirective } from "@bitwarden/vault"; +import { CopyFieldAction, CopyCipherFieldDirective } from "@bitwarden/vault"; import { VaultPopupCopyButtonsService } from "../../../services/vault-popup-copy-buttons.service"; @@ -18,9 +18,11 @@ type CipherItem = { /** Translation key for the respective value */ key: string; /** Property key on `CipherView` to retrieve the copy value */ - field: CopyAction; + field: CopyFieldAction; }; +// 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-item-copy-actions", templateUrl: "item-copy-actions.component.html", @@ -35,6 +37,8 @@ type CipherItem = { }) export class ItemCopyActionsComponent { protected showQuickCopyActions$ = inject(VaultPopupCopyButtonsService).showQuickCopyActions$; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) cipher!: CipherViewLike; protected CipherViewLikeUtils = CipherViewLikeUtils; @@ -44,7 +48,7 @@ export class ItemCopyActionsComponent { * singleCopyableLogin uses appCopyField instead of appCopyClick. This allows for the TOTP * code to be copied correctly. See #14167 */ - get singleCopyableLogin() { + get singleCopyableLogin(): CipherItem | null { const loginItems: CipherItem[] = [ { key: "copyUsername", field: "username" }, { key: "copyPassword", field: "password" }, @@ -58,7 +62,7 @@ export class ItemCopyActionsComponent { ) { return { key: this.i18nService.t("copyUsername"), - field: "username", + field: "username" as const, }; } return this.findSingleCopyableItem(loginItems); 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 16cc04ce612..5c5171ac81d 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 @@ -13,9 +13,17 @@ - + + @if (!(autofillConfirmationFlagEnabled$ | async)) { + + } @@ -26,6 +34,11 @@ + @if (canEdit) { + + } {{ "clone" | i18n }} @@ -38,9 +51,32 @@ {{ "assignToCollections" | i18n }} - @if (canArchive$ | async) { - + } @else { + + } + } + @if (canDelete$ | async) { + } diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts new file mode 100644 index 00000000000..577b7d96771 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts @@ -0,0 +1,412 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +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 { + UriMatchStrategy, + UriMatchStrategySetting, +} from "@bitwarden/common/models/domain/domain-service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; +import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; +import { + AutofillConfirmationDialogComponent, + AutofillConfirmationDialogResult, +} from "../autofill-confirmation-dialog/autofill-confirmation-dialog.component"; + +import { ItemMoreOptionsComponent } from "./item-more-options.component"; + +describe("ItemMoreOptionsComponent", () => { + let fixture: ComponentFixture; + let component: ItemMoreOptionsComponent; + + const dialogService = { + openSimpleDialog: jest.fn().mockResolvedValue(true), + open: jest.fn(), + }; + const featureFlag$ = new BehaviorSubject(false); + const configService = { + getFeatureFlag$: jest.fn().mockImplementation(() => featureFlag$.asObservable()), + }; + const cipherService = { + getFullCipherView: jest.fn(), + encrypt: jest.fn(), + updateWithServer: jest.fn(), + softDeleteWithServer: jest.fn(), + }; + const autofillSvc = { + doAutofill: jest.fn(), + doAutofillAndSave: jest.fn(), + currentAutofillTab$: new BehaviorSubject<{ url?: string | null } | null>(null), + autofillAllowed$: new BehaviorSubject(true), + }; + + const passwordRepromptService = { + passwordRepromptCheck: jest.fn().mockResolvedValue(true), + }; + + const uriMatchStrategy$ = new BehaviorSubject(UriMatchStrategy.Domain); + + const domainSettingsService = { + resolvedDefaultUriMatchStrategy$: uriMatchStrategy$.asObservable(), + }; + + const baseCipher = { + id: "cipher-1", + login: { + uris: [ + { uri: "https://one.example.com" }, + { uri: "" }, + { uri: undefined as unknown as string }, + { uri: "https://two.example.com/a" }, + ], + username: "user", + }, + favorite: false, + reprompt: 0, + type: CipherType.Login, + viewPassword: true, + edit: true, + } as any; + + beforeEach(waitForAsync(async () => { + jest.clearAllMocks(); + + cipherService.getFullCipherView.mockImplementation(async (c) => ({ ...baseCipher, ...c })); + + TestBed.configureTestingModule({ + imports: [ItemMoreOptionsComponent, NoopAnimationsModule], + providers: [ + { provide: ConfigService, useValue: configService }, + { provide: CipherService, useValue: cipherService }, + { provide: VaultPopupAutofillService, useValue: autofillSvc }, + + { provide: I18nService, useValue: { t: (k: string) => k } }, + { provide: AccountService, useValue: { activeAccount$: of({ id: "UserId" }) } }, + { provide: OrganizationService, useValue: { hasOrganizations: () => of(false) } }, + { + provide: CipherAuthorizationService, + useValue: { canDeleteCipher$: () => of(true), canCloneCipher$: () => of(true) }, + }, + { provide: CollectionService, useValue: { decryptedCollections$: () => of([]) } }, + { provide: RestrictedItemTypesService, useValue: { restricted$: of([]) } }, + { + provide: CipherArchiveService, + useValue: { userCanArchive$: () => of(true), hasArchiveFlagEnabled$: () => of(true) }, + }, + { provide: ToastService, useValue: { showToast: () => {} } }, + { provide: Router, useValue: { navigate: () => Promise.resolve(true) } }, + { provide: PasswordRepromptService, useValue: passwordRepromptService }, + { + provide: DomainSettingsService, + useValue: domainSettingsService, + }, + { + provide: VaultPopupItemsService, + useValue: mock({}), + }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }); + TestBed.overrideProvider(DialogService, { useValue: dialogService }); + await TestBed.compileComponents(); + fixture = TestBed.createComponent(ItemMoreOptionsComponent); + component = fixture.componentInstance; + component.cipher = baseCipher; + })); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function mockConfirmDialogResult(result: string) { + const openSpy = jest + .spyOn(AutofillConfirmationDialogComponent, "open") + .mockReturnValue({ closed: of(result) } as any); + return openSpy; + } + + describe("doAutofill", () => { + it("calls the passwordService to passwordRepromptCheck", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + + await component.doAutofill(); + + expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); + }); + + it("calls the autofill service to autofill without showing the confirmation dialog when the feature flag is disabled", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + + await component.doAutofill(); + + expect(cipherService.getFullCipherView).toHaveBeenCalled(); + expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1); + expect(autofillSvc.doAutofill).toHaveBeenCalledWith( + expect.objectContaining({ id: "cipher-1" }), + true, + true, + ); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("does nothing if the user fails master password reprompt", async () => { + baseCipher.reprompt = 2; // Master Password reprompt enabled + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + passwordRepromptService.passwordRepromptCheck.mockResolvedValue(false); // Reprompt fails + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + + await component.doAutofill(); + + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("does not show the exact match dialog when the default match strategy is Exact and autofill confirmation is not to be shown", async () => { + // autofill confirmation dialog is not shown when either the feature flag is disabled + uriMatchStrategy$.next(UriMatchStrategy.Exact); + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + describe("autofill confirmation dialog", () => { + beforeEach(() => { + // autofill confirmation dialog is shown when feature flag is enabled + featureFlag$.next(true); + uriMatchStrategy$.next(UriMatchStrategy.Domain); + passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true); + }); + + it("calls the passwordService to passwordRepromptCheck", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + + await component.doAutofill(); + + expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); + }); + + it("opens the autofill confirmation dialog with filtered saved URLs when the feature flag is enabled", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); + const openSpy = mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); + + await component.doAutofill(); + + expect(openSpy).toHaveBeenCalledTimes(1); + const args = openSpy.mock.calls[0][1]; + expect(args.data?.currentUrl).toBe("https://page.example.com/path"); + expect(args.data?.savedUrls).toEqual([ + "https://one.example.com", + "https://two.example.com/a", + ]); + }); + + it("does nothing when the user cancels the autofill confirmation dialog", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); + + await component.doAutofill(); + + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("calls the autofill service to autofill when the user selects 'AutofilledOnly'", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + + await component.doAutofill(); + + expect(autofillSvc.doAutofill).toHaveBeenCalledWith( + expect.objectContaining({ id: "cipher-1" }), + true, + true, + ); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("calls the autofill service to doAutofillAndSave when the user selects 'AutofillAndUrlAdded'", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofillAndUrlAdded); + + await component.doAutofill(); + + expect(autofillSvc.doAutofillAndSave).toHaveBeenCalledWith( + expect.objectContaining({ id: "cipher-1" }), + false, + true, + ); + expect(autofillSvc.doAutofillAndSave.mock.calls[0][1]).toBe(false); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + }); + + describe("URI match strategy handling", () => { + describe("when the default URI match strategy is Exact", () => { + beforeEach(() => { + uriMatchStrategy$.next(UriMatchStrategy.Exact); + }); + + it("calls the passwordService to passwordRepromptCheck", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + + await component.doAutofill(); + + expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); + }); + + it("shows the exact match dialog", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(1); + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.objectContaining({ key: "cannotAutofill" }), + content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), + type: "info", + }), + ); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + }); + + describe("when the default URI match strategy is not Exact", () => { + beforeEach(() => { + mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); + uriMatchStrategy$.next(UriMatchStrategy.Domain); + }); + it("does not show the exact match dialog", async () => { + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://page.example.com", match: UriMatchStrategy.Domain }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("shows the exact match dialog when the cipher has a single uri with a match strategy of Exact", async () => { + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [{ uri: "https://one.example.com", match: UriMatchStrategy.Exact }], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.objectContaining({ key: "cannotAutofill" }), + content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), + type: "info", + }), + ); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + }); + + it("does not show the exact match dialog when the cipher has no uris", async () => { + mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("does not show the exact match dialog when the cipher has a uri with a match strategy of Exact and a uri with a match strategy of Domain", async () => { + mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://page.example.com", match: UriMatchStrategy.Domain }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + }); + + it("hides the 'Fill and Save' button when showAutofillConfirmation$ is true", async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const fillAndSaveButton = fixture.nativeElement.querySelector( + "button[bitMenuItem]:not([disabled])", + ); + + const buttonText = fillAndSaveButton?.textContent?.trim().toLowerCase() ?? ""; + expect(buttonText.includes("fillAndSave".toLowerCase())).toBe(false); + }); + + it("does nothing if the user fails master password reprompt", async () => { + baseCipher.reprompt = 2; // Master Password reprompt enabled + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + passwordRepromptService.passwordRepromptCheck.mockResolvedValue(false); // Reprompt fails + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + + await component.doAutofill(); + + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index 6979f519f2d..4dfaf7bc66f 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 @@ -1,19 +1,24 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; import { booleanAttribute, Component, Input } from "@angular/core"; import { Router, RouterModule } from "@angular/router"; -import { BehaviorSubject, combineLatest, firstValueFrom, map, switchMap } from "rxjs"; +import { BehaviorSubject, combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs"; import { filter } from "rxjs/operators"; import { CollectionService } from "@bitwarden/admin-console/common"; +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { JslibModule } from "@bitwarden/angular/jslib.module"; 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 { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherId, UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; @@ -28,19 +33,40 @@ import { MenuModule, ToastService, } from "@bitwarden/components"; -import { CipherArchiveService, PasswordRepromptService } from "@bitwarden/vault"; +import { PasswordRepromptService } from "@bitwarden/vault"; +import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; +import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; +import { + AutofillConfirmationDialogComponent, + AutofillConfirmationDialogResult, +} from "../autofill-confirmation-dialog/autofill-confirmation-dialog.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-item-more-options", templateUrl: "./item-more-options.component.html", - imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule], + imports: [ + ItemModule, + IconButtonModule, + MenuModule, + CommonModule, + JslibModule, + RouterModule, + PremiumBadgeComponent, + ], + providers: [ + { provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService }, + ], }) export class ItemMoreOptionsComponent { - private _cipher$ = new BehaviorSubject(undefined); + private _cipher$ = new BehaviorSubject({} as CipherViewLike); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true, }) @@ -56,18 +82,28 @@ export class ItemMoreOptionsComponent { * Flag to show view item menu option. Used when something else is * assigned as the primary action for the item, such as autofill. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ transform: booleanAttribute }) - showViewOption: boolean; + showViewOption = false; /** * Flag to hide the autofill menu options. Used for items that are * already in the autofill list suggestion. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ transform: booleanAttribute }) - hideAutofillOptions: boolean; + hideAutofillOptions = false; protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$; + protected autofillConfirmationFlagEnabled$ = this.configService + .getFeatureFlag$(FeatureFlag.AutofillConfirmation) + .pipe(map((isFeatureFlagEnabled) => isFeatureFlagEnabled)); + + protected uriMatchStrategy$ = this.domainSettingsService.resolvedDefaultUriMatchStrategy$; + /** * Observable that emits a boolean value indicating if the user is authorized to clone the cipher. * @protected @@ -105,18 +141,15 @@ export class ItemMoreOptionsComponent { }), ); - /** Observable Boolean checking if item can show Archive menu option */ - protected canArchive$ = combineLatest([ - this._cipher$, - this.accountService.activeAccount$.pipe( - getUserId, - switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)), - ), - ]).pipe( - filter(([cipher, userId]) => cipher != null && userId != null), - map(([cipher, canArchive]) => { - return canArchive && !CipherViewLikeUtils.isArchived(cipher) && cipher.organizationId == null; - }), + protected showArchive$: Observable = this.cipherArchiveService.hasArchiveFlagEnabled$(); + + protected canArchive$: Observable = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)), + ); + + protected canDelete$ = this._cipher$.pipe( + switchMap((cipher) => this.cipherAuthorizationService.canDeleteCipher$(cipher)), ); constructor( @@ -133,6 +166,9 @@ export class ItemMoreOptionsComponent { private collectionService: CollectionService, private restrictedItemTypesService: RestrictedItemTypesService, private cipherArchiveService: CipherArchiveService, + private configService: ConfigService, + private vaultPopupItemsService: VaultPopupItemsService, + private domainSettingsService: DomainSettingsService, ) {} get canEdit() { @@ -164,14 +200,75 @@ export class ItemMoreOptionsComponent { return this.cipher.favorite ? "unfavorite" : "favorite"; } - async doAutofill() { - const cipher = await this.cipherService.getFullCipherView(this.cipher); - await this.vaultPopupAutofillService.doAutofill(cipher); - } - async doAutofillAndSave() { const cipher = await this.cipherService.getFullCipherView(this.cipher); - await this.vaultPopupAutofillService.doAutofillAndSave(cipher, false); + await this.vaultPopupAutofillService.doAutofillAndSave(cipher); + } + + async doAutofill() { + const cipher = await this.cipherService.getFullCipherView(this.cipher); + + if (!(await this.passwordRepromptService.passwordRepromptCheck(this.cipher))) { + return; + } + + const uris = cipher.login?.uris ?? []; + const cipherHasAllExactMatchLoginUris = + uris.length > 0 && uris.every((u) => u.uri && u.match === UriMatchStrategy.Exact); + + const showAutofillConfirmation = await firstValueFrom(this.autofillConfirmationFlagEnabled$); + const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$); + + if ( + showAutofillConfirmation && + (cipherHasAllExactMatchLoginUris || uriMatchStrategy === UriMatchStrategy.Exact) + ) { + await this.dialogService.openSimpleDialog({ + title: { key: "cannotAutofill" }, + content: { key: "cannotAutofillExactMatch" }, + type: "info", + acceptButtonText: { key: "okay" }, + cancelButtonText: null, + }); + return; + } + + if (!showAutofillConfirmation) { + await this.vaultPopupAutofillService.doAutofill(cipher, true, true); + return; + } + + const currentTab = await firstValueFrom(this.vaultPopupAutofillService.currentAutofillTab$); + + if (!currentTab?.url) { + await this.dialogService.openSimpleDialog({ + title: { key: "error" }, + content: { key: "errorGettingAutoFillData" }, + type: "danger", + }); + return; + } + + const ref = AutofillConfirmationDialogComponent.open(this.dialogService, { + data: { + currentUrl: currentTab?.url || "", + savedUrls: cipher.login?.uris?.filter((u) => u.uri).map((u) => u.uri!) ?? [], + viewOnly: !this.cipher.edit, + }, + }); + + const result = await firstValueFrom(ref.closed); + + switch (result) { + case AutofillConfirmationDialogResult.Canceled: + return; + case AutofillConfirmationDialogResult.AutofilledOnly: + await this.vaultPopupAutofillService.doAutofill(cipher, true, true); + return; + case AutofillConfirmationDialogResult.AutofillAndUrlAdded: + await this.vaultPopupAutofillService.doAutofillAndSave(cipher, false, true); + return; + } } async onView() { @@ -191,17 +288,16 @@ export class ItemMoreOptionsComponent { const cipher = await this.cipherService.getFullCipherView(this.cipher); cipher.favorite = !cipher.favorite; - const activeUserId = await firstValueFrom( + const activeUserId = (await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); + )) as UserId; const encryptedCipher = await this.cipherService.encrypt(cipher, activeUserId); await this.cipherService.updateWithServer(encryptedCipher); this.toastService.showToast({ variant: "success", - title: null, message: this.i18nService.t( - this.cipher.favorite ? "itemAddedToFavorites" : "itemRemovedFromFavorites", + cipher.favorite ? "itemAddedToFavorites" : "itemRemovedFromFavorites", ), }); } @@ -251,7 +347,48 @@ export class ItemMoreOptionsComponent { }); } + protected async edit() { + if (this.cipher.reprompt && !(await this.passwordRepromptService.showPasswordPrompt())) { + return; + } + + await this.router.navigate(["/edit-cipher"], { + queryParams: { cipherId: this.cipher.id, type: CipherViewLikeUtils.getType(this.cipher) }, + }); + } + + protected async delete() { + const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(this.cipher); + if (!repromptPassed) { + return; + } + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "deleteItem" }, + content: { key: "deleteItemConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return; + } + + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + await this.cipherService.softDeleteWithServer(this.cipher.id as CipherId, activeUserId); + + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("deletedItem"), + }); + } + async archive() { + const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(this.cipher); + if (!repromptPassed) { + return; + } + const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "archiveItem" }, content: { key: "archiveItemConfirmDesc" }, @@ -266,7 +403,7 @@ export class ItemMoreOptionsComponent { await this.cipherArchiveService.archiveWithServer(this.cipher.id as CipherId, activeUserId); this.toastService.showToast({ variant: "success", - message: this.i18nService.t("itemSentToArchive"), + message: this.i18nService.t("itemWasSentToArchive"), }); } } diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts index d1586bd6ad5..004980db181 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts @@ -23,6 +23,8 @@ export interface NewItemInitialValues { collectionId?: CollectionId; } +// 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-new-item-dropdown", templateUrl: "new-item-dropdown-v2.component.html", @@ -34,6 +36,8 @@ export class NewItemDropdownV2Component implements OnInit { /** * Optional initial values to pass to the add cipher form */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() initialValues: NewItemInitialValues; diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts index b65138dac3a..8fa48dc5d79 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts @@ -18,14 +18,26 @@ import { VaultGeneratorDialogComponent, } from "./vault-generator-dialog.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: "vault-cipher-form-generator", template: "", }) +// FIXME(https://bitwarden.atlassian.net/browse/PM-28231): Use Component suffix +// eslint-disable-next-line @angular-eslint/component-class-suffix class MockCipherFormGenerator { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() type: "password" | "username" = "password"; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() algorithmSelected: EventEmitter = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() uri: string = ""; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() valueGenerated = new EventEmitter(); } diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts index b0103aaacfb..caeebdabc09 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts @@ -38,6 +38,8 @@ export const GeneratorDialogAction = { type GeneratorDialogAction = UnionOfValues; +// 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-vault-generator-dialog", templateUrl: "./vault-generator-dialog.component.html", diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts index 9564aeadc09..2e822d82855 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts @@ -10,6 +10,7 @@ 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -28,6 +29,7 @@ import { PopupListFilter, VaultPopupListFiltersService, } from "../../../../../vault/popup/services/vault-popup-list-filters.service"; +import { VaultPopupLoadingService } from "../../../services/vault-popup-loading.service"; import { VaultHeaderV2Component } from "./vault-header-v2.component"; @@ -75,6 +77,10 @@ describe("VaultHeaderV2Component", () => { { provide: MessageSender, useValue: mock() }, { provide: AccountService, useValue: mock() }, { provide: LogService, useValue: mock() }, + { + provide: ConfigService, + useValue: { getFeatureFlag$: jest.fn(() => new BehaviorSubject(true)) }, + }, { provide: VaultPopupItemsService, useValue: mock({ searchText$: new BehaviorSubject("") }), @@ -99,6 +105,10 @@ describe("VaultHeaderV2Component", () => { provide: StateProvider, useValue: { getGlobal: () => ({ state$, update }) }, }, + { + provide: VaultPopupLoadingService, + useValue: { loading$: new BehaviorSubject(false) }, + }, ], }).compileComponents(); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.ts index f64b5e6b83d..6381b8be147 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.ts @@ -17,6 +17,8 @@ import { VaultPopupListFiltersService } from "../../../../../vault/popup/service import { VaultListFiltersComponent } from "../vault-list-filters/vault-list-filters.component"; import { VaultV2SearchComponent } from "../vault-search/vault-v2-search.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-vault-header-v2", templateUrl: "vault-header-v2.component.html", @@ -31,6 +33,8 @@ import { VaultV2SearchComponent } from "../vault-search/vault-v2-search.componen ], }) export class VaultHeaderV2Component { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(DisclosureComponent) disclosure: DisclosureComponent; /** Emits the visibility status of the disclosure component. */ diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.ts index 81fad896ad2..50da66fe5b8 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.ts @@ -8,6 +8,8 @@ import { ChipSelectComponent } from "@bitwarden/components"; import { VaultPopupListFiltersService } from "../../../services/vault-popup-list-filters.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: "app-vault-list-filters", templateUrl: "./vault-list-filters.component.html", diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index fad5615764c..3dac158b8e1 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -84,7 +84,7 @@ -

    +

    {{ group.subHeaderKey | i18n }}

    diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts index 61d7815d93e..469247f9692 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts @@ -90,12 +90,18 @@ export class VaultListItemsContainerComponent implements AfterViewInit { private vaultPopupSectionService = inject(VaultPopupSectionService); protected CipherViewLikeUtils = CipherViewLikeUtils; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(CdkVirtualScrollViewport, { static: false }) viewPort!: CdkVirtualScrollViewport; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(DisclosureComponent) disclosure!: DisclosureComponent; /** * Indicates whether the section should be open or closed if collapsibleKey is provided */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals protected sectionOpenState: Signal = computed(() => { if (!this.collapsibleKey()) { return true; @@ -130,17 +136,23 @@ export class VaultListItemsContainerComponent implements AfterViewInit { */ private viewCipherTimeout?: number; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals ciphers = input([]); /** * If true, we will group ciphers by type (Login, Card, Identity) * within subheadings in a single container, converted to a WritableSignal. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals groupByType = input(false); /** * Computed signal for a grouped list of ciphers with an optional header */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals cipherGroups = computed< { subHeaderKey?: string; @@ -183,6 +195,8 @@ export class VaultListItemsContainerComponent implements AfterViewInit { /** * Title for the vault list item section. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals title = input(undefined); /** @@ -191,33 +205,45 @@ export class VaultListItemsContainerComponent implements AfterViewInit { * The key must be added to the state definition in `vault-popup-section.service.ts` since the * collapsed state is stored locally. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals collapsibleKey = input(undefined); /** * Optional description for the vault list item section. Will be shown below the title even when * no ciphers are available. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals description = input(undefined); /** * Option to show a refresh button in the section header. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals showRefresh = input(false, { transform: booleanAttribute }); /** * Event emitted when the refresh button is clicked. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onRefresh = new EventEmitter(); /** * Flag indicating that the current tab location is blocked */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals currentURIIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$); /** * Resolved i18n key to use for suggested cipher items */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals cipherItemTitleKey = computed(() => { return (cipher: CipherViewLike) => { const login = CipherViewLikeUtils.getLogin(cipher); @@ -233,11 +259,15 @@ export class VaultListItemsContainerComponent implements AfterViewInit { /** * Option to show the autofill button for each item. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals showAutofillButton = input(false, { transform: booleanAttribute }); /** * Flag indicating whether the suggested cipher item autofill button should be shown or not */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals hideAutofillButton = computed( () => !this.showAutofillButton() || this.currentURIIsBlocked() || this.primaryActionAutofill(), ); @@ -245,22 +275,30 @@ export class VaultListItemsContainerComponent implements AfterViewInit { /** * Flag indicating whether the cipher item autofill menu options should be shown or not */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals hideAutofillMenuOptions = computed(() => this.currentURIIsBlocked() || this.showAutofillButton()); /** * Option to perform autofill operation as the primary action for autofill suggestions. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals primaryActionAutofill = input(false, { transform: booleanAttribute }); /** * Remove the bottom margin from the bit-section in this component * (used for containers at the end of the page where bottom margin is not needed) */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals disableSectionMargin = input(false, { transform: booleanAttribute }); /** * Remove the description margin */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals disableDescriptionMargin = input(false, { transform: booleanAttribute }); /** @@ -275,6 +313,8 @@ export class VaultListItemsContainerComponent implements AfterViewInit { return collections[0]?.name; } + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals protected autofillShortcutTooltip = signal(undefined); constructor( @@ -388,7 +428,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit { await this.vaultPopupSectionService.updateSectionOpenStoredState( this.collapsibleKey()!, - this.disclosure.open, + this.disclosure.open(), ); } diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts index f2764df7ba7..7b9f358c01c 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts @@ -18,6 +18,8 @@ import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component"; import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.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: "vault-password-history-v2", templateUrl: "vault-password-history-v2.component.html", 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 new file mode 100644 index 00000000000..37c4804e600 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts @@ -0,0 +1,160 @@ +import { CommonModule } from "@angular/common"; +import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; +import { FormsModule } from "@angular/forms"; +import { BehaviorSubject } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service"; +import { SearchModule } from "@bitwarden/components"; + +import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; +import { VaultPopupLoadingService } from "../../../services/vault-popup-loading.service"; + +import { VaultV2SearchComponent } from "./vault-v2-search.component"; + +describe("VaultV2SearchComponent", () => { + let component: VaultV2SearchComponent; + let fixture: ComponentFixture; + + const searchText$ = new BehaviorSubject(""); + const loading$ = new BehaviorSubject(false); + const featureFlag$ = new BehaviorSubject(true); + const applyFilter = jest.fn(); + + const createComponent = () => { + fixture = TestBed.createComponent(VaultV2SearchComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }; + + beforeEach(async () => { + applyFilter.mockClear(); + featureFlag$.next(true); + + await TestBed.configureTestingModule({ + imports: [VaultV2SearchComponent, CommonModule, SearchModule, JslibModule, FormsModule], + providers: [ + { + provide: VaultPopupItemsService, + useValue: { + searchText$, + applyFilter, + }, + }, + { + provide: VaultPopupLoadingService, + useValue: { + loading$, + }, + }, + { + provide: ConfigService, + useValue: { + getFeatureFlag$: jest.fn(() => featureFlag$), + }, + }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + ], + }).compileComponents(); + }); + + it("subscribes to search text from service", () => { + createComponent(); + + searchText$.next("test search"); + fixture.detectChanges(); + + expect(component.searchText).toBe("test search"); + }); + + describe("debouncing behavior", () => { + describe("when feature flag is enabled", () => { + beforeEach(() => { + featureFlag$.next(true); + createComponent(); + }); + + it("debounces search text changes when not loading", fakeAsync(() => { + loading$.next(false); + + component.searchText = "test"; + component.onSearchTextChanged(); + + expect(applyFilter).not.toHaveBeenCalled(); + + tick(SearchTextDebounceInterval); + + expect(applyFilter).toHaveBeenCalledWith("test"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); + + it("should not debounce search text changes when loading", fakeAsync(() => { + loading$.next(true); + + component.searchText = "test"; + component.onSearchTextChanged(); + + tick(0); + + expect(applyFilter).toHaveBeenCalledWith("test"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); + + it("cancels previous debounce when new text is entered", fakeAsync(() => { + loading$.next(false); + + component.searchText = "test"; + component.onSearchTextChanged(); + + tick(SearchTextDebounceInterval / 2); + + component.searchText = "test2"; + component.onSearchTextChanged(); + + tick(SearchTextDebounceInterval / 2); + + expect(applyFilter).not.toHaveBeenCalled(); + + tick(SearchTextDebounceInterval / 2); + + expect(applyFilter).toHaveBeenCalledWith("test2"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); + }); + + describe("when feature flag is disabled", () => { + beforeEach(() => { + featureFlag$.next(false); + createComponent(); + }); + + it("debounces search text changes", fakeAsync(() => { + component.searchText = "test"; + component.onSearchTextChanged(); + + expect(applyFilter).not.toHaveBeenCalled(); + + tick(SearchTextDebounceInterval); + + expect(applyFilter).toHaveBeenCalledWith("test"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); + + it("ignores loading state and always debounces", fakeAsync(() => { + loading$.next(true); + + component.searchText = "test"; + component.onSearchTextChanged(); + + expect(applyFilter).not.toHaveBeenCalled(); + + tick(SearchTextDebounceInterval); + + expect(applyFilter).toHaveBeenCalledWith("test"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts index 72df3cba41a..154cd49c5a3 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 @@ -2,14 +2,30 @@ import { CommonModule } from "@angular/common"; import { Component, NgZone } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; -import { Subject, Subscription, debounceTime, distinctUntilChanged, filter } from "rxjs"; +import { + Subject, + 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"; import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; +import { VaultPopupLoadingService } from "../../../services/vault-popup-loading.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({ imports: [CommonModule, SearchModule, JslibModule, FormsModule], selector: "app-vault-v2-search", @@ -20,8 +36,11 @@ export class VaultV2SearchComponent { private searchText$ = new Subject(); + protected loading$ = this.vaultPopupLoadingService.loading$; constructor( private vaultPopupItemsService: VaultPopupItemsService, + private vaultPopupLoadingService: VaultPopupLoadingService, + private configService: ConfigService, private ngZone: NgZone, ) { this.subscribeToLatestSearchText(); @@ -43,13 +62,38 @@ export class VaultV2SearchComponent { }); } - subscribeToApplyFilter(): Subscription { - return this.searchText$ - .pipe(debounceTime(SearchTextDebounceInterval), distinctUntilChanged(), takeUntilDestroyed()) - .subscribe((data) => { + subscribeToApplyFilter(): void { + this.configService + .getFeatureFlag$(FeatureFlag.VaultLoadingSkeletons) + .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), + ); + }), + takeUntilDestroyed(), + ) + .subscribe((text) => { this.ngZone.runOutsideAngular(() => { this.ngZone.run(() => { - this.vaultPopupItemsService.applyFilter(data); + this.vaultPopupItemsService.applyFilter(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 a56eef4dfc1..347c5fe6286 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,20 +8,32 @@ -
    - - {{ "yourVaultIsEmpty" | i18n }} - -

    {{ "emptyVaultDescription" | i18n }}

    -
    - - {{ "newLogin" | i18n }} - -
    -
    + +
    + + {{ "yourVaultIsEmpty" | i18n }} + +

    + {{ "emptyVaultDescription" | i18n }} +

    +
    + + {{ "newLogin" | i18n }} + +
    +
    +
    + + @if (skeletonFeatureFlag$ | async) { + + + + } @else { + + } + + + + + +
    -
    @@ -84,21 +107,37 @@
    - - - - - + + + + + + + + + @if (skeletonFeatureFlag$ | async) { + + + + } @else { + + } + + @if (showSkeletonsLoaders$ | async) { + + + + } 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 new file mode 100644 index 00000000000..5563cd3033b --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts @@ -0,0 +1,569 @@ +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 { ActivatedRoute, Router } from "@angular/router"; +import { RouterTestingModule } from "@angular/router/testing"; +import { mock } from "jest-mock-extended"; +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 { CurrentAccountComponent } from "@bitwarden/browser/auth/popup/account-switching/current-account.component"; +import AutofillService from "@bitwarden/browser/autofill/services/autofill.service"; +import { PopOutComponent } from "@bitwarden/browser/platform/popup/components/pop-out.component"; +import { PopupHeaderComponent } from "@bitwarden/browser/platform/popup/layout/popup-header.component"; +import { PopupRouterCacheService } from "@bitwarden/browser/platform/popup/view-cache/popup-router-cache.service"; +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"; +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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { TaskService } from "@bitwarden/common/vault/tasks"; +import { DialogService } from "@bitwarden/components"; +import { StateProvider } from "@bitwarden/state"; +import { DecryptionFailureDialogComponent } from "@bitwarden/vault"; + +import { BrowserApi } from "../../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../../platform/browser/browser-popup-utils"; +import { IntroCarouselService } from "../../services/intro-carousel.service"; +import { VaultPopupAutofillService } from "../../services/vault-popup-autofill.service"; +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 { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service"; +import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-password-callout.component"; + +import { AutofillVaultListItemsComponent } from "./autofill-vault-list-items/autofill-vault-list-items.component"; +import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component"; +import { NewItemDropdownV2Component } from "./new-item-dropdown/new-item-dropdown-v2.component"; +import { VaultHeaderV2Component } from "./vault-header/vault-header-v2.component"; +import { VaultListItemsContainerComponent } from "./vault-list-items-container/vault-list-items-container.component"; +import { VaultV2Component } from "./vault-v2.component"; + +@Component({ + selector: "popup-header", + standalone: true, + template: "", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PopupHeaderStubComponent { + readonly pageTitle = input(""); +} + +@Component({ + selector: "app-vault-header-v2", + standalone: true, + template: "", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VaultHeaderV2StubComponent {} + +@Component({ + selector: "app-current-account", + standalone: true, + template: "", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class CurrentAccountStubComponent {} + +@Component({ + selector: "app-new-item-dropdown", + standalone: true, + template: "", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class NewItemDropdownStubComponent { + readonly initialValues = input(); +} + +@Component({ + selector: "app-pop-out", + standalone: true, + template: "", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class PopOutStubComponent {} + +@Component({ + selector: "blocked-injection-banner", + standalone: true, + template: "", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class BlockedInjectionBannerStubComponent {} + +@Component({ + selector: "vault-at-risk-password-callout", + standalone: true, + template: "", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class VaultAtRiskCalloutStubComponent {} + +@Component({ + selector: "app-autofill-vault-list-items", + standalone: true, + template: "", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class AutofillVaultListItemsStubComponent {} + +@Component({ + selector: "app-vault-list-items-container", + standalone: true, + template: "", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class VaultListItemsContainerStubComponent { + readonly title = input(); + readonly ciphers = input(); + readonly id = input(); + readonly disableSectionMargin = input(); + readonly collapsibleKey = input(); +} + +const mockDialogRef = { + close: jest.fn(), + afterClosed: jest.fn().mockReturnValue(of(undefined)), +} as unknown as import("@bitwarden/components").DialogRef; + +jest + .spyOn(PremiumUpgradeDialogComponent, "open") + .mockImplementation((_: DialogService) => mockDialogRef as any); + +jest + .spyOn(DecryptionFailureDialogComponent, "open") + .mockImplementation((_: DialogService, _params: any) => mockDialogRef as any); +jest.spyOn(BrowserApi, "isPopupOpen").mockResolvedValue(false); +jest.spyOn(BrowserPopupUtils, "openCurrentPagePopout").mockResolvedValue(); + +describe("VaultV2Component", () => { + let component: VaultV2Component; + + interface FakeAccount { + id: string; + } + + function queryAllSpotlights(fixture: any): HTMLElement[] { + return Array.from(fixture.nativeElement.querySelectorAll("bit-spotlight")) as HTMLElement[]; + } + + const itemsSvc: any = { + emptyVault$: new BehaviorSubject(false), + noFilteredResults$: new BehaviorSubject(false), + showDeactivatedOrg$: new BehaviorSubject(false), + favoriteCiphers$: new BehaviorSubject([]), + remainingCiphers$: new BehaviorSubject([]), + cipherCount$: new BehaviorSubject(0), + loading$: new BehaviorSubject(true), + } as Partial; + + const filtersSvc = { + allFilters$: new Subject(), + filters$: new BehaviorSubject({}), + filterVisibilityState$: new BehaviorSubject({}), + } as Partial; + + const accountActive$ = new BehaviorSubject({ id: "user-1" }); + + const cipherSvc = { + failedToDecryptCiphers$: jest.fn().mockReturnValue(of([])), + } as Partial; + + const nudgesSvc = { + showNudgeSpotlight$: jest.fn().mockImplementation((_type: NudgeType) => of(false)), + dismissNudge: jest.fn().mockResolvedValue(undefined), + } as Partial; + + const dialogSvc = {} as Partial; + + const introSvc = { + setIntroCarouselDismissed: jest.fn().mockResolvedValue(undefined), + } as Partial; + + const scrollSvc = { + start: jest.fn(), + stop: jest.fn(), + } as Partial; + + function getObs(cmp: any, key: string): Observable { + return cmp[key] as Observable; + } + + const hasPremiumFromAnySource$ = new BehaviorSubject(false); + + const billingSvc = { + hasPremiumFromAnySource$: (_: string) => hasPremiumFromAnySource$, + }; + + const vaultProfileSvc = { + getProfileCreationDate: jest + .fn() + .mockResolvedValue(new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)), // 8 days ago + }; + + beforeEach(async () => { + jest.clearAllMocks(); + await TestBed.configureTestingModule({ + imports: [VaultV2Component, RouterTestingModule], + providers: [ + { provide: VaultPopupItemsService, useValue: itemsSvc }, + { provide: VaultPopupListFiltersService, useValue: filtersSvc }, + { provide: VaultPopupScrollPositionService, useValue: scrollSvc }, + { + provide: AccountService, + useValue: { activeAccount$: accountActive$ }, + }, + { 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) }, + }, + { + provide: BillingAccountProfileStateService, + useValue: billingSvc, + }, + { + provide: I18nService, + useValue: { translate: (key: string) => key, t: (key: string) => key }, + }, + { provide: PopupRouterCacheService, useValue: mock() }, + { provide: RestrictedItemTypesService, useValue: { restricted$: new BehaviorSubject([]) } }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: AvatarService, useValue: mock() }, + { provide: ActivatedRoute, useValue: mock() }, + { provide: AuthService, useValue: mock() }, + { provide: AutofillService, useValue: mock() }, + { + provide: VaultPopupAutofillService, + useValue: mock(), + }, + { provide: TaskService, useValue: mock() }, + { provide: StateProvider, useValue: mock() }, + { + provide: ConfigService, + useValue: { + getFeatureFlag$: (_: string) => of(false), + }, + }, + { + provide: SearchService, + useValue: { isCipherSearching$: of(false) }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + TestBed.overrideComponent(VaultV2Component, { + remove: { + imports: [ + PopupHeaderComponent, + VaultHeaderV2Component, + CurrentAccountComponent, + NewItemDropdownV2Component, + PopOutComponent, + BlockedInjectionBanner, + AtRiskPasswordCalloutComponent, + AutofillVaultListItemsComponent, + VaultListItemsContainerComponent, + ], + }, + add: { + imports: [ + PopupHeaderStubComponent, + VaultHeaderV2StubComponent, + CurrentAccountStubComponent, + NewItemDropdownStubComponent, + PopOutStubComponent, + BlockedInjectionBannerStubComponent, + VaultAtRiskCalloutStubComponent, + AutofillVaultListItemsStubComponent, + VaultListItemsContainerStubComponent, + ], + }, + }); + + const fixture = TestBed.createComponent(VaultV2Component); + component = fixture.componentInstance; + }); + + describe("vaultState", () => { + type ExpectedKey = "Empty" | "DeactivatedOrg" | "NoResults" | null; + + const cases: [string, boolean, boolean, boolean, ExpectedKey][] = [ + ["null when none true", false, false, false, null], + ["Empty when empty true only", true, false, false, "Empty"], + ["DeactivatedOrg when only deactivated true", false, false, true, "DeactivatedOrg"], + ["NoResults when only noResults true", false, true, false, "NoResults"], + ]; + + it.each(cases)( + "%s", + fakeAsync( + ( + _label: string, + empty: boolean, + noResults: boolean, + deactivated: boolean, + expectedKey: ExpectedKey, + ) => { + const empty$ = itemsSvc.emptyVault$ as BehaviorSubject; + const noResults$ = itemsSvc.noFilteredResults$ as BehaviorSubject; + const deactivated$ = itemsSvc.showDeactivatedOrg$ as BehaviorSubject; + + empty$.next(empty); + noResults$.next(noResults); + deactivated$.next(deactivated); + tick(); + + const expectedValue = + expectedKey === null ? null : (component as any).VaultStateEnum[expectedKey]; + + expect((component as any).vaultState).toBe(expectedValue); + }, + ), + ); + }); + + it("loading$ is true when items loading or filters missing; false when both ready", () => { + const itemsLoading$ = itemsSvc.loading$ as unknown as BehaviorSubject; + const allFilters$ = filtersSvc.allFilters$ as unknown as Subject; + + const values: boolean[] = []; + getObs(component, "loading$").subscribe((v) => values.push(!!v)); + + itemsLoading$.next(true); + + allFilters$.next({}); + + itemsLoading$.next(false); + + expect(values[values.length - 1]).toBe(false); + }); + + it("ngAfterViewInit waits for allFilters$ then starts scroll position service", fakeAsync(() => { + 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 }); + tick(); + + expect(scrollSvc.start).toHaveBeenCalledTimes(1); + expect(scrollSvc.start).toHaveBeenCalledWith((component as any).virtualScrollElement); + + flush(); + })); + + it("showPremiumDialog opens PremiumUpgradeDialogComponent", () => { + component["showPremiumDialog"](); + expect(PremiumUpgradeDialogComponent.open).toHaveBeenCalledTimes(1); + }); + + it("navigateToImport navigates and opens popout if popup is open", fakeAsync(async () => { + (BrowserApi.isPopupOpen as jest.Mock).mockResolvedValueOnce(true); + + 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(() => { + (cipherSvc.failedToDecryptCiphers$ as any).mockReturnValue( + of([ + { id: "a", isDeleted: false }, + { id: "b", isDeleted: true }, + { id: "c", isDeleted: false }, + ]), + ); + + void component.ngOnInit(); + tick(); + + expect(introSvc.setIntroCarouselDismissed).toHaveBeenCalled(); + + expect(DecryptionFailureDialogComponent.open).toHaveBeenCalledWith(expect.any(Object), { + cipherIds: ["a", "c"], + }); + + flush(); + })); + + it("dismissVaultNudgeSpotlight forwards to NudgesService with active user id", fakeAsync(() => { + const spy = jest.spyOn(nudgesSvc, "dismissNudge").mockResolvedValue(undefined); + + accountActive$.next({ id: "user-xyz" }); + + void component.ngOnInit(); + tick(); + + void component["dismissVaultNudgeSpotlight"](NudgeType.HasVaultItems); + tick(); + + expect(spy).toHaveBeenCalledWith(NudgeType.HasVaultItems, "user-xyz"); + })); + + it("accountAgeInDays$ computes integer days since creation", (done) => { + getObs(component, "accountAgeInDays$").subscribe((days) => { + if (days !== null) { + expect(days).toBeGreaterThanOrEqual(7); + done(); + } + }); + + void component.ngOnInit(); + }); + + it("renders Premium spotlight when eligible and opens dialog on click", fakeAsync(() => { + itemsSvc.cipherCount$.next(10); + + hasPremiumFromAnySource$.next(false); + + (nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => + of(type === NudgeType.PremiumUpgrade), + ); + + const fixture = TestBed.createComponent(VaultV2Component); + const component = fixture.componentInstance; + + void component.ngOnInit(); + + fixture.detectChanges(); + tick(); + + fixture.detectChanges(); + + const spotlights = Array.from( + fixture.nativeElement.querySelectorAll("bit-spotlight"), + ) as HTMLElement[]; + expect(spotlights.length).toBe(1); + + const spotDe = fixture.debugElement.query(By.css("bit-spotlight")); + expect(spotDe).toBeTruthy(); + + spotDe.triggerEventHandler("onButtonClick", undefined); + fixture.detectChanges(); + + expect(PremiumUpgradeDialogComponent.open).toHaveBeenCalledTimes(1); + })); + + it("renders Empty-Vault spotlight when vaultState is Empty and nudge is on", fakeAsync(() => { + itemsSvc.emptyVault$.next(true); + + (nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => { + return of(type === NudgeType.EmptyVaultNudge); + }); + + const fixture = TestBed.createComponent(VaultV2Component); + fixture.detectChanges(); + tick(); + + const spotlights = queryAllSpotlights(fixture); + expect(spotlights.length).toBe(1); + + expect(fixture.nativeElement.textContent).toContain("emptyVaultNudgeTitle"); + })); + + it("renders Has-Items spotlight when vault has items and nudge is on", fakeAsync(() => { + itemsSvc.emptyVault$.next(false); + + (nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => { + return of(type === NudgeType.HasVaultItems); + }); + + const fixture = TestBed.createComponent(VaultV2Component); + fixture.detectChanges(); + tick(); + + const spotlights = queryAllSpotlights(fixture); + expect(spotlights.length).toBe(1); + + expect(fixture.nativeElement.textContent).toContain("hasItemsVaultNudgeTitle"); + })); + + it("does not render Premium spotlight when account is less than a week old", fakeAsync(() => { + itemsSvc.cipherCount$.next(10); + hasPremiumFromAnySource$.next(false); + + vaultProfileSvc.getProfileCreationDate = jest + .fn() + .mockResolvedValue(new Date(Date.now() - 3 * 24 * 60 * 60 * 1000)); // 3 days ago + + (nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => { + return of(type === NudgeType.PremiumUpgrade); + }); + + const fixture = TestBed.createComponent(VaultV2Component); + fixture.detectChanges(); + tick(); + + const spotlights = queryAllSpotlights(fixture); + expect(spotlights.length).toBe(0); + })); + + it("does not render Premium spotlight when vault has less than 5 items", fakeAsync(() => { + itemsSvc.cipherCount$.next(3); + hasPremiumFromAnySource$.next(false); + + (nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => { + return of(type === NudgeType.PremiumUpgrade); + }); + + const fixture = TestBed.createComponent(VaultV2Component); + fixture.detectChanges(); + tick(); + + const spotlights = queryAllSpotlights(fixture); + expect(spotlights.length).toBe(0); + })); + + it("does not render Premium spotlight when user already has premium", fakeAsync(() => { + itemsSvc.cipherCount$.next(10); + hasPremiumFromAnySource$.next(true); + + (nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => { + return of(type === NudgeType.PremiumUpgrade); + }); + + const fixture = TestBed.createComponent(VaultV2Component); + fixture.detectChanges(); + tick(); + + const spotlights = queryAllSpotlights(fixture); + expect(spotlights.length).toBe(0); + })); +}); 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 604cc6b73ef..9cee4f66b67 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,3 +1,4 @@ +import { LiveAnnouncer } from "@angular/cdk/a11y"; import { CdkVirtualScrollableElement, ScrollingModule } from "@angular/cdk/scrolling"; import { CommonModule } from "@angular/common"; import { AfterViewInit, Component, DestroyRef, OnDestroy, OnInit, ViewChild } from "@angular/core"; @@ -5,27 +6,36 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router, RouterModule } from "@angular/router"; import { combineLatest, + distinctUntilChanged, filter, firstValueFrom, + from, map, Observable, shareReplay, - startWith, switchMap, take, + tap, } 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { skeletonLoadingDelay } from "@bitwarden/common/vault/utils/skeleton-loading.operator"; import { ButtonModule, DialogService, @@ -41,11 +51,14 @@ import { PopOutComponent } from "../../../../platform/popup/components/pop-out.c import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; import { IntroCarouselService } from "../../services/intro-carousel.service"; -import { VaultPopupCopyButtonsService } from "../../services/vault-popup-copy-buttons.service"; import { VaultPopupItemsService } from "../../services/vault-popup-items.service"; import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service"; +import { VaultPopupLoadingService } from "../../services/vault-popup-loading.service"; import { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service"; import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-password-callout.component"; +import { VaultFadeInOutComponent } from "../vault-fade-in-out/vault-fade-in-out.component"; +import { VaultFadeInOutSkeletonComponent } from "../vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component"; +import { VaultLoadingSkeletonComponent } from "../vault-loading-skeleton/vault-loading-skeleton.component"; import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component"; import { @@ -64,6 +77,8 @@ const VaultState = { type VaultState = UnionOfValues; +// 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-vault", templateUrl: "vault-v2.component.html", @@ -86,9 +101,14 @@ type VaultState = UnionOfValues; SpotlightComponent, RouterModule, TypographyModule, + VaultLoadingSkeletonComponent, + VaultFadeInOutSkeletonComponent, + VaultFadeInOutComponent, ], }) 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; NudgeType = NudgeType; @@ -104,19 +124,80 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { ); activeUserId: UserId | null = null; + + private loading$ = this.vaultPopupLoadingService.loading$.pipe( + distinctUntilChanged(), + tap((loading) => { + const key = loading ? "loadingVault" : "vaultLoaded"; + void this.liveAnnouncer.announce(this.i18nService.translate(key), "polite"); + }), + ); + + protected skeletonFeatureFlag$ = this.configService.getFeatureFlag$( + FeatureFlag.VaultLoadingSkeletons, + ); + + private showPremiumNudgeSpotlight$ = this.activeUserId$.pipe( + switchMap((userId) => this.nudgesService.showNudgeSpotlight$(NudgeType.PremiumUpgrade, userId)), + ); + protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$; protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$; protected allFilters$ = this.vaultPopupListFiltersService.allFilters$; + protected cipherCount$ = this.vaultPopupItemsService.cipherCount$; + 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 loading$ = combineLatest([ - this.vaultPopupItemsService.loading$, - this.allFilters$, - // Added as a dependency to avoid flashing the copyActions on slower devices - this.vaultCopyButtonsService.showQuickCopyActions$, + protected showPremiumSpotlight$ = combineLatest([ + this.showPremiumNudgeSpotlight$, + this.showHasItemsVaultSpotlight$, + this.hasPremium$, + this.cipherCount$, + this.accountAgeInDays$, ]).pipe( - map(([itemsLoading, filters]) => itemsLoading || !filters), + map( + ([showPremiumNudge, showHasItemsNudge, hasPremium, count, age]) => + showPremiumNudge && !showHasItemsNudge && !hasPremium && count >= 5 && age >= 7, + ), shareReplay({ bufferSize: 1, refCount: true }), - startWith(true), + ); + + showPremiumDialog() { + 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.skeletonFeatureFlag$, + ]).pipe( + map( + ([loading, cipherSearching, skeletonsEnabled]) => + (loading || cipherSearching) && skeletonsEnabled, + ), + distinctUntilChanged(), + skeletonLoadingDelay(), ); protected newItemItemValues$: Observable = @@ -146,14 +227,20 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { private vaultPopupItemsService: VaultPopupItemsService, private vaultPopupListFiltersService: VaultPopupListFiltersService, private vaultScrollPositionService: VaultPopupScrollPositionService, + private vaultPopupLoadingService: VaultPopupLoadingService, private accountService: AccountService, private destroyRef: DestroyRef, private cipherService: CipherService, private dialogService: DialogService, - private vaultCopyButtonsService: VaultPopupCopyButtonsService, private introCarouselService: IntroCarouselService, private nudgesService: NudgesService, private router: Router, + private vaultProfileService: VaultProfileService, + private billingAccountService: BillingAccountProfileStateService, + private liveAnnouncer: LiveAnnouncer, + private i18nService: I18nService, + private configService: ConfigService, + private searchService: SearchService, ) { combineLatest([ this.vaultPopupItemsService.emptyVault$, diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts index 915a27e4fd1..1dea91c0b9f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts @@ -76,6 +76,8 @@ type LoadAction = | typeof COPY_VERIFICATION_CODE_ID | typeof UPDATE_PASSWORD; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-view-v2", templateUrl: "view-v2.component.html", @@ -328,6 +330,7 @@ export class ViewV2Component { const tab = await BrowserApi.getTab(senderTabId); await sendExtensionMessage("bgHandleReprompt", { tab, + cipherId: cipher.id, success: repromptSuccess, }); diff --git a/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts b/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts index fc302dd6c36..03111859165 100644 --- a/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts +++ b/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts @@ -1,10 +1,11 @@ import { inject } from "@angular/core"; import { CanActivateFn, Router } from "@angular/router"; -import { map, switchMap } from "rxjs"; +import { combineLatest, map, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { TaskService } from "@bitwarden/common/vault/tasks"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { ToastService } from "@bitwarden/components"; @@ -32,3 +33,38 @@ export const canAccessAtRiskPasswords: CanActivateFn = () => { }), ); }; + +export const hasAtRiskPasswords: CanActivateFn = () => { + const accountService = inject(AccountService); + const taskService = inject(TaskService); + const cipherService = inject(CipherService); + const router = inject(Router); + + return accountService.activeAccount$.pipe( + filterOutNullish(), + switchMap((user) => + combineLatest([ + taskService.pendingTasks$(user.id), + cipherService.cipherViews$(user.id).pipe( + filterOutNullish(), + map((ciphers) => Object.fromEntries(ciphers.map((c) => [c.id, c]))), + ), + ]).pipe( + map(([tasks, ciphers]) => { + const hasAtRiskCiphers = tasks.some( + (t) => + t.type === SecurityTaskType.UpdateAtRiskCredential && + t.cipherId != null && + ciphers[t.cipherId] != null && + !ciphers[t.cipherId].isDeleted, + ); + + if (!hasAtRiskCiphers) { + return router.createUrlTree(["/tabs/vault"]); + } + return true; + }), + ), + ), + ); +}; diff --git a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts new file mode 100644 index 00000000000..7ead8576b37 --- /dev/null +++ b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts @@ -0,0 +1,77 @@ +import { TestBed } from "@angular/core/testing"; +import { RouterStateSnapshot } from "@angular/router"; + +import { VaultV2Component } from "../components/vault-v2/vault-v2.component"; +import { VaultPopupItemsService } from "../services/vault-popup-items.service"; +import { VaultPopupListFiltersService } from "../services/vault-popup-list-filters.service"; + +import { clearVaultStateGuard } from "./clear-vault-state.guard"; + +describe("clearVaultStateGuard", () => { + let applyFilterSpy: jest.Mock; + let resetFilterFormSpy: jest.Mock; + + beforeEach(() => { + applyFilterSpy = jest.fn(); + resetFilterFormSpy = jest.fn(); + + TestBed.configureTestingModule({ + providers: [ + { + provide: VaultPopupItemsService, + useValue: { applyFilter: applyFilterSpy }, + }, + { + provide: VaultPopupListFiltersService, + useValue: { resetFilterForm: resetFilterFormSpy }, + }, + ], + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + "/view-cipher?cipherId=123", + "/edit-cipher?cipherId=123", + "/clone-cipher?cipherId=123", + "/assign-collections?cipherId=123", + ])("should not clear vault state when viewing or editing a cipher: %s", (url) => { + const nextState = { url } as RouterStateSnapshot; + + const result = TestBed.runInInjectionContext(() => + clearVaultStateGuard({} as VaultV2Component, null, null, nextState), + ); + + expect(result).toBe(true); + expect(applyFilterSpy).not.toHaveBeenCalled(); + expect(resetFilterFormSpy).not.toHaveBeenCalled(); + }); + + it.each(["/settings", "/tabs/settings"])( + "should clear vault state when navigating to non-cipher routes: %s", + (url) => { + const nextState = { url } as RouterStateSnapshot; + + const result = TestBed.runInInjectionContext(() => + clearVaultStateGuard({} as VaultV2Component, null, null, nextState), + ); + + expect(result).toBe(true); + expect(applyFilterSpy).toHaveBeenCalledWith(""); + expect(resetFilterFormSpy).toHaveBeenCalled(); + }, + ); + + it("should not clear vault state when not changing states", () => { + const result = TestBed.runInInjectionContext(() => + clearVaultStateGuard({} as VaultV2Component, null, null, null), + ); + + expect(result).toBe(true); + expect(applyFilterSpy).not.toHaveBeenCalled(); + expect(resetFilterFormSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts index e27090180d6..2a87db6e903 100644 --- a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts +++ b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts @@ -7,7 +7,8 @@ import { VaultPopupListFiltersService } from "../services/vault-popup-list-filte /** * Guard to clear the vault state (search and filter) when navigating away from the vault view. - * This ensures the search and filter state is reset when navigating between different tabs, except viewing a cipher. + * This ensures the search and filter state is reset when navigating between different tabs, + * except viewing or editing a cipher. */ export const clearVaultStateGuard: CanDeactivateFn = ( component: VaultV2Component, @@ -17,7 +18,7 @@ export const clearVaultStateGuard: CanDeactivateFn = ( ) => { const vaultPopupItemsService = inject(VaultPopupItemsService); const vaultPopupListFiltersService = inject(VaultPopupListFiltersService); - if (nextState && !isViewingCipher(nextState.url)) { + if (nextState && !isCipherOpen(nextState.url)) { vaultPopupItemsService.applyFilter(""); vaultPopupListFiltersService.resetFilterForm(); } @@ -25,4 +26,8 @@ export const clearVaultStateGuard: CanDeactivateFn = ( return true; }; -const isViewingCipher = (url: string): boolean => url.includes("view-cipher"); +const isCipherOpen = (url: string): boolean => + url.includes("view-cipher") || + url.includes("assign-collections") || + url.includes("edit-cipher") || + url.includes("clone-cipher"); diff --git a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts index 9a00bacd6b0..bf63cf1f668 100644 --- a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts +++ b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts @@ -2,25 +2,69 @@ import { TestBed } from "@angular/core/testing"; import { Router } from "@angular/router"; import { mock, MockProxy } from "jest-mock-extended"; +import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { DialogService } from "@bitwarden/components"; + import { BrowserPremiumUpgradePromptService } from "./browser-premium-upgrade-prompt.service"; describe("BrowserPremiumUpgradePromptService", () => { let service: BrowserPremiumUpgradePromptService; let router: MockProxy; + let configService: MockProxy; + let dialogService: MockProxy; beforeEach(async () => { router = mock(); + configService = mock(); + dialogService = mock(); + await TestBed.configureTestingModule({ - providers: [BrowserPremiumUpgradePromptService, { provide: Router, useValue: router }], + providers: [ + BrowserPremiumUpgradePromptService, + { provide: Router, useValue: router }, + { provide: ConfigService, useValue: configService }, + { provide: DialogService, useValue: dialogService }, + ], }).compileComponents(); service = TestBed.inject(BrowserPremiumUpgradePromptService); }); describe("promptForPremium", () => { - it("navigates to the premium update screen", async () => { + let openSpy: jest.SpyInstance; + + beforeEach(() => { + openSpy = jest.spyOn(PremiumUpgradeDialogComponent, "open").mockImplementation(); + }); + + afterEach(() => { + openSpy.mockRestore(); + }); + + it("opens the new premium upgrade dialog when feature flag is enabled", async () => { + configService.getFeatureFlag.mockResolvedValue(true); + await service.promptForPremium(); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); + expect(openSpy).toHaveBeenCalledWith(dialogService); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it("navigates to the premium update screen when feature flag is disabled", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + await service.promptForPremium(); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); expect(router.navigate).toHaveBeenCalledWith(["/premium"]); + expect(openSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts index 2909e3b3bd6..53f7ffd5f5a 100644 --- a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts +++ b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts @@ -1,18 +1,32 @@ import { inject } from "@angular/core"; import { Router } from "@angular/router"; +import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { DialogService } from "@bitwarden/components"; /** * This class handles the premium upgrade process for the browser extension. */ export class BrowserPremiumUpgradePromptService implements PremiumUpgradePromptService { private router = inject(Router); + private configService = inject(ConfigService); + private dialogService = inject(DialogService); async promptForPremium() { - /** - * Navigate to the premium update screen. - */ - await this.router.navigate(["/premium"]); + const showNewDialog = await this.configService.getFeatureFlag( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); + + if (showNewDialog) { + PremiumUpgradeDialogComponent.open(this.dialogService); + } else { + /** + * Navigate to the premium update screen. + */ + await this.router.navigate(["/premium"]); + } } } diff --git a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts index f271b255c3e..5818c6e32ff 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts @@ -204,6 +204,7 @@ describe("VaultPopupAutofillService", () => { describe("doAutofill()", () => { it("should return true if autofill is successful", async () => { + mockCipher.id = "test-cipher-id"; mockAutofillService.doAutoFill.mockResolvedValue(null); const result = await service.doAutofill(mockCipher); expect(result).toBe(true); @@ -251,6 +252,7 @@ describe("VaultPopupAutofillService", () => { }); it("should copy TOTP code to clipboard if available", async () => { + mockCipher.id = "test-cipher-id-with-totp"; const totpCode = "123456"; mockAutofillService.doAutoFill.mockResolvedValue(totpCode); await service.doAutofill(mockCipher); @@ -260,6 +262,18 @@ describe("VaultPopupAutofillService", () => { ); }); + it("skips password prompt when skipPasswordReprompt is true", async () => { + mockCipher.id = "cipher-with-reprompt"; + mockCipher.reprompt = CipherRepromptType.Password; + mockAutofillService.doAutoFill.mockResolvedValue(null); + + const result = await service.doAutofill(mockCipher, true, true); + + expect(result).toBe(true); + expect(mockPasswordRepromptService.showPasswordPrompt).not.toHaveBeenCalled(); + expect(mockAutofillService.doAutoFill).toHaveBeenCalled(); + }); + describe("closePopup", () => { beforeEach(() => { jest.spyOn(BrowserApi, "closePopup").mockImplementation(); @@ -405,5 +419,26 @@ describe("VaultPopupAutofillService", () => { }); }); }); + describe("handleAutofillSuggestionUsed", () => { + const cipherId = "cipher-123"; + + beforeEach(() => { + mockCipherService.updateLastUsedDate.mockResolvedValue(undefined); + }); + + it("updates last used date when there is an active user", async () => { + await service.handleAutofillSuggestionUsed({ cipherId }); + + expect(mockCipherService.updateLastUsedDate).toHaveBeenCalledTimes(1); + expect(mockCipherService.updateLastUsedDate).toHaveBeenCalledWith(cipherId, mockUserId); + }); + + it("does nothing when there is no active user", async () => { + accountService.activeAccount$ = of(null); + await service.handleAutofillSuggestionUsed({ cipherId }); + + expect(mockCipherService.updateLastUsedDate).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts index 2d30e857573..6feeec29efc 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts @@ -16,6 +16,7 @@ import { } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { isUrlInList } from "@bitwarden/common/autofill/utils"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -230,8 +231,10 @@ export class VaultPopupAutofillService { cipher: CipherView, tab: chrome.tabs.Tab, pageDetails: PageDetail[], + skipPasswordReprompt = false, ): Promise { if ( + !skipPasswordReprompt && cipher.reprompt !== CipherRepromptType.None && !(await this.passwordRepromptService.showPasswordPrompt()) ) { @@ -268,6 +271,7 @@ export class VaultPopupAutofillService { }); return false; } + await this.handleAutofillSuggestionUsed({ cipherId: cipher.id }); return true; } @@ -312,12 +316,22 @@ export class VaultPopupAutofillService { * Will copy any TOTP code to the clipboard if available after successful autofill. * @param cipher * @param closePopup If true, will close the popup window after successful autofill. Defaults to true. + * @param skipPasswordReprompt If true, skips the master password reprompt even if the cipher requires it. Defaults to false. */ - async doAutofill(cipher: CipherView, closePopup = true): Promise { + async doAutofill( + cipher: CipherView, + closePopup = true, + skipPasswordReprompt = false, + ): Promise { const tab = await firstValueFrom(this.currentAutofillTab$); const pageDetails = await firstValueFrom(this._currentPageDetails$); - const didAutofill = await this._internalDoAutofill(cipher, tab, pageDetails); + const didAutofill = await this._internalDoAutofill( + cipher, + tab, + pageDetails, + skipPasswordReprompt, + ); if (didAutofill && closePopup) { await this._closePopup(cipher, tab); @@ -326,6 +340,21 @@ export class VaultPopupAutofillService { return didAutofill; } + /** + * When a user autofills with an autofill suggestion outside of the inline menu, + * update the cipher's last used date. + * + * @param message - The message containing the cipher ID that was used + */ + async handleAutofillSuggestionUsed(message: { cipherId: string }) { + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(getOptionalUserId), + ); + if (activeUserId) { + await this.cipherService.updateLastUsedDate(message.cipherId, activeUserId); + } + } + /** * Attempts to autofill the given cipher and, upon successful autofill, saves the URI to the cipher. * Will copy any TOTP code to the clipboard if available after successful autofill. @@ -333,7 +362,11 @@ export class VaultPopupAutofillService { * @param closePopup If true, will close the popup window after successful autofill. * If false, will show a success toast instead. Defaults to true. */ - async doAutofillAndSave(cipher: CipherView, closePopup = true): Promise { + async doAutofillAndSave( + cipher: CipherView, + closePopup = true, + skipPasswordReprompt = false, + ): Promise { // We can only save URIs for login ciphers if (cipher.type !== CipherType.Login) { return false; @@ -342,7 +375,12 @@ export class VaultPopupAutofillService { const pageDetails = await firstValueFrom(this._currentPageDetails$); const tab = await firstValueFrom(this.currentAutofillTab$); - const didAutofill = await this._internalDoAutofill(cipher, tab, pageDetails); + const didAutofill = await this._internalDoAutofill( + cipher, + tab, + pageDetails, + skipPasswordReprompt, + ); if (!didAutofill) { return false; diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts index 6499719b64f..513e159f7aa 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts @@ -14,6 +14,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SyncService } from "@bitwarden/common/platform/sync"; import { mockAccountServiceWith, ObservableTracker } from "@bitwarden/common/spec"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; @@ -26,7 +27,6 @@ import { RestrictedItemTypesService, } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { CipherViewLikeUtils } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; -import { CipherArchiveService } from "@bitwarden/vault"; import { InlineMenuFieldQualificationService } from "../../../autofill/services/inline-menu-field-qualification.service"; import { BrowserApi } from "../../../platform/browser/browser-api"; diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index 3e4b793737e..321d7936806 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -26,6 +26,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; @@ -35,7 +36,6 @@ import { CipherViewLike, CipherViewLikeUtils, } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; -import { CipherArchiveService } from "@bitwarden/vault"; import { runInsideAngular } from "../../../platform/browser/run-inside-angular.operator"; import { PopupViewCacheService } from "../../../platform/popup/view-cache/popup-view-cache.service"; @@ -120,7 +120,7 @@ export class VaultPopupItemsService { .cipherListViews$(userId) .pipe(filter((ciphers) => ciphers != null)), this.cipherService.failedToDecryptCiphers$(userId), - this.restrictedItemTypesService.restricted$.pipe(startWith([])), + this.restrictedItemTypesService.restricted$, ]), ), map(([ciphers, failedToDecryptCiphers, restrictions]) => { @@ -261,6 +261,13 @@ export class VaultPopupItemsService { this.remainingCiphers$.pipe(map(() => false)), ).pipe(startWith(true), distinctUntilChanged(), shareReplay({ refCount: false, bufferSize: 1 })); + /** Observable that indicates whether there is search text present. + */ + hasSearchText$: Observable = this._hasSearchText.pipe( + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: true }), + ); + /** * Observable that indicates whether a filter or search text is currently applied to the ciphers. */ @@ -281,6 +288,11 @@ export class VaultPopupItemsService { map((ciphers) => !ciphers.length), ); + /** + * Observable that contains the count of ciphers in the active filtered list. + */ + cipherCount$: Observable = this._activeCipherList$.pipe(map((ciphers) => ciphers.length)); + /** * Observable that indicates whether there are no ciphers to show with the current filter. */ diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts index eecd1f2fd68..692e21d0084 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -761,11 +761,13 @@ function createSeededVaultPopupListFiltersService( const collectionServiceMock = { decryptedCollections$: () => seededCollections$, getAllNested: () => - seededCollections$.value.map((c) => ({ - children: [], - node: c, - parent: null, - })), + seededCollections$.value.map( + (c): TreeNode => ({ + children: [], + node: c, + parent: null as any, + }), + ), } as any; const folderServiceMock = { diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts index 05d0ea8d444..08db7d5d4ab 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts @@ -14,7 +14,11 @@ import { take, } from "rxjs"; -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { + CollectionService, + CollectionTypes, + CollectionView, +} from "@bitwarden/admin-console/common"; import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; import { DynamicTreeNode } from "@bitwarden/angular/vault/vault-filter/models/dynamic-tree-node.model"; import { sortDefaultCollections } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; @@ -473,7 +477,14 @@ export class VaultPopupListFiltersService { }); }), map((tree) => - tree.nestedList.map((c) => this.convertToChipSelectOption(c, "bwi-collection-shared")), + tree.nestedList.map((c) => + this.convertToChipSelectOption( + c, + c.node.type === CollectionTypes.DefaultUserCollection + ? "bwi-user" + : "bwi-collection-shared", + ), + ), ), shareReplay({ bufferSize: 1, refCount: true }), ); diff --git a/apps/browser/src/vault/popup/services/vault-popup-loading.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-loading.service.spec.ts new file mode 100644 index 00000000000..4b9c284b3b7 --- /dev/null +++ b/apps/browser/src/vault/popup/services/vault-popup-loading.service.spec.ts @@ -0,0 +1,72 @@ +import { TestBed } from "@angular/core/testing"; +import { firstValueFrom, skip, Subject } from "rxjs"; + +import { VaultPopupCopyButtonsService } from "./vault-popup-copy-buttons.service"; +import { VaultPopupItemsService } from "./vault-popup-items.service"; +import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; +import { VaultPopupLoadingService } from "./vault-popup-loading.service"; + +describe("VaultPopupLoadingService", () => { + let service: VaultPopupLoadingService; + let itemsLoading$: Subject; + let allFilters$: Subject; + let showQuickCopyActions$: Subject; + + beforeEach(() => { + itemsLoading$ = new Subject(); + allFilters$ = new Subject(); + showQuickCopyActions$ = new Subject(); + + TestBed.configureTestingModule({ + providers: [ + VaultPopupLoadingService, + { provide: VaultPopupItemsService, useValue: { loading$: itemsLoading$ } }, + { provide: VaultPopupListFiltersService, useValue: { allFilters$: allFilters$ } }, + { + provide: VaultPopupCopyButtonsService, + useValue: { showQuickCopyActions$: showQuickCopyActions$ }, + }, + ], + }); + + service = TestBed.inject(VaultPopupLoadingService); + }); + + it("emits true initially", async () => { + const loading = await firstValueFrom(service.loading$); + + expect(loading).toBe(true); + }); + + it("emits false when items are loaded and filters are available", async () => { + const loadingPromise = firstValueFrom(service.loading$.pipe(skip(1))); + + itemsLoading$.next(false); + allFilters$.next({}); + showQuickCopyActions$.next(true); + + expect(await loadingPromise).toBe(false); + }); + + it("emits true when filters are not available", async () => { + const loadingPromise = firstValueFrom(service.loading$.pipe(skip(2))); + + itemsLoading$.next(false); + allFilters$.next({}); + showQuickCopyActions$.next(true); + allFilters$.next(null); + + expect(await loadingPromise).toBe(true); + }); + + it("emits true when items are loading", async () => { + const loadingPromise = firstValueFrom(service.loading$.pipe(skip(2))); + + itemsLoading$.next(false); + allFilters$.next({}); + showQuickCopyActions$.next(true); + itemsLoading$.next(true); + + expect(await loadingPromise).toBe(true); + }); +}); diff --git a/apps/browser/src/vault/popup/services/vault-popup-loading.service.ts b/apps/browser/src/vault/popup/services/vault-popup-loading.service.ts new file mode 100644 index 00000000000..f56f2b8d8ee --- /dev/null +++ b/apps/browser/src/vault/popup/services/vault-popup-loading.service.ts @@ -0,0 +1,27 @@ +import { inject, Injectable } from "@angular/core"; +import { combineLatest, map, shareReplay, startWith } from "rxjs"; + +import { VaultPopupCopyButtonsService } from "./vault-popup-copy-buttons.service"; +import { VaultPopupItemsService } from "./vault-popup-items.service"; +import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; + +@Injectable({ + providedIn: "root", +}) +export class VaultPopupLoadingService { + private vaultPopupItemsService = inject(VaultPopupItemsService); + private vaultPopupListFiltersService = inject(VaultPopupListFiltersService); + private vaultCopyButtonsService = inject(VaultPopupCopyButtonsService); + + /** Loading state of the vault */ + loading$ = combineLatest([ + this.vaultPopupItemsService.loading$, + this.vaultPopupListFiltersService.allFilters$, + // Added as a dependency to avoid flashing the copyActions on slower devices + this.vaultCopyButtonsService.showQuickCopyActions$, + ]).pipe( + map(([itemsLoading, filters]) => itemsLoading || !filters), + shareReplay({ bufferSize: 1, refCount: true }), + startWith(true), + ); +} diff --git a/apps/browser/src/vault/popup/services/vault-popup-section.service.ts b/apps/browser/src/vault/popup/services/vault-popup-section.service.ts index ed641e0cdf7..b93eda72506 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-section.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-section.service.ts @@ -31,7 +31,7 @@ export class VaultPopupSectionService { private vaultPopupItemsService = inject(VaultPopupItemsService); private stateProvider = inject(StateProvider); - private hasFilterOrSearchApplied = toSignal( + private readonly hasFilterOrSearchApplied = toSignal( this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => hasFilter)), ); @@ -40,7 +40,7 @@ export class VaultPopupSectionService { * application-applied overrides. * `null` means there is no current override */ - private temporaryStateOverride = signal | null>(null); + private readonly temporaryStateOverride = signal | null>(null); constructor() { effect( @@ -71,7 +71,7 @@ export class VaultPopupSectionService { * Stored disk state for the open/close state of the sections, with an initial value provided * if the stored disk state does not yet exist. */ - private sectionOpenStoredState = toSignal( + private readonly sectionOpenStoredState = toSignal( this.sectionOpenStateProvider.state$.pipe(map((sectionOpen) => sectionOpen ?? INITIAL_OPEN)), // Indicates that the state value is loading { initialValue: null }, @@ -81,7 +81,7 @@ export class VaultPopupSectionService { * Indicates the current open/close display state of each section, accounting for temporary * non-persisted overrides. */ - sectionOpenDisplayState: Signal> = computed(() => ({ + readonly sectionOpenDisplayState: Signal> = computed(() => ({ ...this.sectionOpenStoredState(), ...this.temporaryStateOverride(), })); diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.html b/apps/browser/src/vault/popup/settings/appearance-v2.component.html index c9598c76db0..b58316a8d64 100644 --- a/apps/browser/src/vault/popup/settings/appearance-v2.component.html +++ b/apps/browser/src/vault/popup/settings/appearance-v2.component.html @@ -41,7 +41,7 @@ {{ "showAnimations" | i18n }} -

    {{ "vaultCustomization" | i18n }}

    +

    {{ "vaultCustomization" | i18n }}

    diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts index 738ec3ae1ff..9e1beab5787 100644 --- a/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts @@ -22,20 +22,30 @@ import { VaultPopupCopyButtonsService } from "../services/vault-popup-copy-butto import { AppearanceV2Component } from "./appearance-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 @Component({ selector: "popup-header", template: ``, }) class MockPopupHeaderComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() pageTitle: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() backAction: () => void; } +// 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: "popup-page", template: ``, }) class MockPopupPageComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() loading: boolean; } diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts b/apps/browser/src/vault/popup/settings/appearance-v2.component.ts index 23a609bd008..e6515ae7461 100644 --- a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/appearance-v2.component.ts @@ -33,6 +33,8 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co import { PopupSizeService } from "../../../platform/popup/layout/popup-size.service"; import { VaultPopupCopyButtonsService } from "../services/vault-popup-copy-buttons.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./appearance-v2.component.html", imports: [ diff --git a/apps/browser/src/vault/popup/settings/archive.component.html b/apps/browser/src/vault/popup/settings/archive.component.html index 5fb57814fff..a7b23dc5122 100644 --- a/apps/browser/src/vault/popup/settings/archive.component.html +++ b/apps/browser/src/vault/popup/settings/archive.component.html @@ -1,5 +1,5 @@ - + @@ -27,10 +27,10 @@
    {{ cipher.name }} - @if (cipher.hasAttachments) { + @if (CipherViewLikeUtils.hasAttachments(cipher)) { } - {{ cipher.subTitle }} + {{ CipherViewLikeUtils.subtitle(cipher) }} @@ -45,7 +45,7 @@ type="button" bitMenuItem (click)="restore(cipher)" - *ngIf="!cipher.decryptionFailure" + *ngIf="!hasDecryptionFailure(cipher)" > {{ "restore" | i18n }} 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 1676fea3c01..bad6011b2d8 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 @@ -12,7 +12,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { CipherId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogService, IconButtonModule, @@ -53,9 +52,13 @@ export class TrashListItemsContainerComponent { /** * The list of trashed items to display. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() ciphers: PopupCipherViewLike[] = []; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() headerText: string; @@ -81,10 +84,40 @@ export class TrashListItemsContainerComponent { return collections[0]?.name; } - async restore(cipher: CipherView) { + /** + * Check if a cipher has attachments. CipherView has a hasAttachments getter, + * while CipherListView has an attachments count property. + */ + hasAttachments(cipher: PopupCipherViewLike): boolean { + if ("hasAttachments" in cipher) { + return cipher.hasAttachments; + } + return cipher.attachments > 0; + } + + /** + * Get the subtitle for a cipher. CipherView has a subTitle getter, + * while CipherListView has a subtitle property. + */ + getSubtitle(cipher: PopupCipherViewLike): string | undefined { + if ("subTitle" in cipher) { + return cipher.subTitle; + } + return cipher.subtitle; + } + + /** + * Check if a cipher has a decryption failure. CipherView has this property, + * while CipherListView does not. + */ + hasDecryptionFailure(cipher: PopupCipherViewLike): boolean { + return "decryptionFailure" in cipher && cipher.decryptionFailure; + } + + async restore(cipher: PopupCipherViewLike) { try { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - await this.cipherService.restoreWithServer(cipher.id, activeUserId); + await this.cipherService.restoreWithServer(cipher.id as string, activeUserId); await this.router.navigate(["/trash"]); this.toastService.showToast({ @@ -97,7 +130,7 @@ export class TrashListItemsContainerComponent { } } - async delete(cipher: CipherView) { + async delete(cipher: PopupCipherViewLike) { const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); if (!repromptPassed) { @@ -116,7 +149,7 @@ export class TrashListItemsContainerComponent { try { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - await this.cipherService.deleteWithServer(cipher.id, activeUserId); + await this.cipherService.deleteWithServer(cipher.id as string, activeUserId); await this.router.navigate(["/trash"]); this.toastService.showToast({ @@ -129,8 +162,9 @@ export class TrashListItemsContainerComponent { } } - async onViewCipher(cipher: CipherView) { - if (cipher.decryptionFailure) { + async onViewCipher(cipher: PopupCipherViewLike) { + // CipherListView doesn't have decryptionFailure, so we use optional chaining + if ("decryptionFailure" in cipher && cipher.decryptionFailure) { DecryptionFailureDialogComponent.open(this.dialogService, { cipherIds: [cipher.id as CipherId], }); @@ -143,7 +177,7 @@ export class TrashListItemsContainerComponent { } await this.router.navigate(["/view-cipher"], { - queryParams: { cipherId: cipher.id, type: cipher.type }, + queryParams: { cipherId: cipher.id as string, type: cipher.type }, }); } } 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 630c55d0038..225640137e8 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 @@ -34,13 +34,27 @@ - @if (userCanArchive() || showArchiveFilter()) { - - - {{ "archive" | i18n }} - - - + @if (showArchiveItem()) { + @if (userCanArchive()) { + + + {{ "archiveNoun" | i18n }} + + + + } @else { + + + + {{ "archiveNoun" | i18n }} + @if (!userHasArchivedItems()) { + + } + + + + + } } 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 4e8a49b2591..c6db820c232 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 @@ -2,23 +2,28 @@ import { CommonModule } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; import { Router, RouterModule } from "@angular/router"; -import { firstValueFrom, switchMap } from "rxjs"; +import { firstValueFrom, map, switchMap } from "rxjs"; +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; 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 { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { BadgeComponent, ItemModule, ToastOptions, ToastService } from "@bitwarden/components"; -import { CipherArchiveService } from "@bitwarden/vault"; 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"; +import { BrowserPremiumUpgradePromptService } from "../services/browser-premium-upgrade-prompt.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({ templateUrl: "vault-settings-v2.component.html", imports: [ @@ -30,20 +35,28 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co PopOutComponent, ItemModule, BadgeComponent, + PremiumBadgeComponent, + ], + providers: [ + { provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService }, ], }) export class VaultSettingsV2Component implements OnInit, OnDestroy { lastSync = "--"; private userId$ = this.accountService.activeAccount$.pipe(getUserId); - // Check if user is premium user, they will be able to archive items - protected userCanArchive = toSignal( + protected readonly userCanArchive = toSignal( this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId))), ); - // Check if user has archived items (does not check if user is premium) - protected showArchiveFilter = toSignal( - this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.showArchiveVault$(userId))), + protected readonly showArchiveItem = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$()); + + protected readonly userHasArchivedItems = toSignal( + this.userId$.pipe( + switchMap((userId) => + this.cipherArchiveService.archivedCiphers$(userId).pipe(map((c) => c.length > 0)), + ), + ), ); protected emptyVaultImportBadge$ = this.accountService.activeAccount$.pipe( diff --git a/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.spec.ts b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.spec.ts index f2567ef4267..b84d17a8375 100644 --- a/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.spec.ts +++ b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.spec.ts @@ -1,12 +1,11 @@ -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { SecurityTask, TaskService } from "@bitwarden/common/vault/tasks"; -import { LogService } from "@bitwarden/logging"; -import { UserId } from "@bitwarden/user-core"; +import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; -import { BadgeService } from "../../platform/badge/badge.service"; +import { Tab } from "../../platform/badge/badge-browser-api"; +import { BadgeService, BadgeStateFunction } from "../../platform/badge/badge.service"; import { BadgeIcon } from "../../platform/badge/icon"; import { BadgeStatePriority } from "../../platform/badge/priority"; import { Unset } from "../../platform/badge/state"; @@ -18,34 +17,32 @@ describe("AtRiskCipherBadgeUpdaterService", () => { let service: AtRiskCipherBadgeUpdaterService; let setState: jest.Mock; - let clearState: jest.Mock; - let warning: jest.Mock; let getAllDecryptedForUrl: jest.Mock; let getTab: jest.Mock; let addListener: jest.Mock; - const activeAccount$ = new BehaviorSubject({ id: "test-account-id" }); - const cipherViews$ = new BehaviorSubject([]); - const pendingTasks$ = new BehaviorSubject([]); - const userId = "test-user-id" as UserId; + let activeAccount$: BehaviorSubject<{ id: string }>; + let cipherViews$: BehaviorSubject>; + let pendingTasks$: BehaviorSubject; beforeEach(async () => { setState = jest.fn().mockResolvedValue(undefined); - clearState = jest.fn().mockResolvedValue(undefined); - warning = jest.fn(); getAllDecryptedForUrl = jest.fn().mockResolvedValue([]); getTab = jest.fn(); addListener = jest.fn(); + activeAccount$ = new BehaviorSubject({ id: "test-account-id" }); + cipherViews$ = new BehaviorSubject>([]); + pendingTasks$ = new BehaviorSubject([]); + jest.spyOn(BrowserApi, "addListener").mockImplementation(addListener); jest.spyOn(BrowserApi, "getTab").mockImplementation(getTab); service = new AtRiskCipherBadgeUpdaterService( - { setState, clearState } as unknown as BadgeService, + { setState } as unknown as BadgeService, { activeAccount$ } as unknown as AccountService, - { cipherViews$, getAllDecryptedForUrl } as unknown as CipherService, - { warning } as unknown as LogService, - { pendingTasks$ } as unknown as TaskService, + { cipherViews$: () => cipherViews$, getAllDecryptedForUrl } as unknown as CipherService, + { pendingTasks$: () => pendingTasks$ } as unknown as TaskService, ); await service.init(); @@ -55,30 +52,41 @@ describe("AtRiskCipherBadgeUpdaterService", () => { jest.restoreAllMocks(); }); + it("registers dynamic state function on init", () => { + expect(setState).toHaveBeenCalledWith("at-risk-cipher-badge", expect.any(Function)); + }); + it("clears the tab state when there are no ciphers and no pending tasks", async () => { - const tab = { id: 1 } as chrome.tabs.Tab; + const tab: Tab = { tabId: 1, url: "https://bitwarden.com" }; + const stateFunction = setState.mock.calls[0][1]; - await service["setTabState"](tab, userId, []); + const state = await firstValueFrom(stateFunction(tab)); - expect(clearState).toHaveBeenCalledWith("at-risk-cipher-badge-1"); + expect(state).toBeUndefined(); }); it("sets state when there are pending tasks for the tab", async () => { - const tab = { id: 3, url: "https://bitwarden.com" } as chrome.tabs.Tab; - const pendingTasks: SecurityTask[] = [{ id: "task1", cipherId: "cipher1" } as SecurityTask]; + const tab: Tab = { tabId: 3, url: "https://bitwarden.com" }; + const stateFunction: BadgeStateFunction = setState.mock.calls[0][1]; + const pendingTasks: SecurityTask[] = [ + { + id: "task1", + cipherId: "cipher1", + type: SecurityTaskType.UpdateAtRiskCredential, + } as SecurityTask, + ]; + pendingTasks$.next(pendingTasks); getAllDecryptedForUrl.mockResolvedValueOnce([{ id: "cipher1" }]); - await service["setTabState"](tab, userId, pendingTasks); + const state = await firstValueFrom(stateFunction(tab)); - expect(setState).toHaveBeenCalledWith( - "at-risk-cipher-badge-3", - BadgeStatePriority.High, - { + expect(state).toEqual({ + priority: BadgeStatePriority.High, + state: { icon: BadgeIcon.Berry, text: Unset, backgroundColor: Unset, }, - 3, - ); + }); }); }); diff --git a/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts index 47364958ad8..a06c208ebe2 100644 --- a/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts +++ b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts @@ -1,26 +1,18 @@ -import { combineLatest, map, mergeMap, of, Subject, switchMap } from "rxjs"; +import { combineLatest, concatMap, map, of, switchMap } from "rxjs"; 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"; -import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; +import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { BadgeService } from "../../platform/badge/badge.service"; import { BadgeIcon } from "../../platform/badge/icon"; import { BadgeStatePriority } from "../../platform/badge/priority"; import { Unset } from "../../platform/badge/state"; -import { BrowserApi } from "../../platform/browser/browser-api"; -const StateName = (tabId: number) => `at-risk-cipher-badge-${tabId}`; +const StateName = "at-risk-cipher-badge"; export class AtRiskCipherBadgeUpdaterService { - private tabReplaced$ = new Subject<{ addedTab: chrome.tabs.Tab; removedTabId: number }>(); - private tabUpdated$ = new Subject(); - private tabRemoved$ = new Subject(); - private tabActivated$ = new Subject(); - private activeUserData$ = this.accountService.activeAccount$.pipe( filterOutNullish(), switchMap((user) => @@ -40,124 +32,36 @@ export class AtRiskCipherBadgeUpdaterService { private badgeService: BadgeService, private accountService: AccountService, private cipherService: CipherService, - private logService: LogService, private taskService: TaskService, - ) { - combineLatest({ - replaced: this.tabReplaced$, - activeUserData: this.activeUserData$, - }) - .pipe( - mergeMap(async ({ replaced, activeUserData: [userId, pendingTasks] }) => { - await this.clearTabState(replaced.removedTabId); - await this.setTabState(replaced.addedTab, userId, pendingTasks); - }), - ) - .subscribe(() => {}); - - combineLatest({ - tab: this.tabActivated$, - activeUserData: this.activeUserData$, - }) - .pipe( - mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => { - await this.setTabState(tab, userId, pendingTasks); - }), - ) - .subscribe(); - - combineLatest({ - tab: this.tabUpdated$, - activeUserData: this.activeUserData$, - }) - .pipe( - mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => { - await this.setTabState(tab, userId, pendingTasks); - }), - ) - .subscribe(); - - this.tabRemoved$ - .pipe( - mergeMap(async (tabId) => { - await this.clearTabState(tabId); - }), - ) - .subscribe(); - } + ) {} init() { - BrowserApi.addListener(chrome.tabs.onReplaced, async (addedTabId, removedTabId) => { - const newTab = await BrowserApi.getTab(addedTabId); - if (!newTab) { - this.logService.warning( - `Tab replaced event received but new tab not found (id: ${addedTabId})`, - ); - return; - } + this.badgeService.setState(StateName, (tab) => { + return this.activeUserData$.pipe( + concatMap(async ([userId, pendingTasks]) => { + const ciphers = tab.url + ? await this.cipherService.getAllDecryptedForUrl(tab.url, userId, [], undefined, true) + : []; - this.tabReplaced$.next({ - removedTabId, - addedTab: newTab, - }); + const hasPendingTasksForTab = pendingTasks.some((task) => + ciphers.some((cipher) => cipher.id === task.cipherId && !cipher.isDeleted), + ); + + if (!hasPendingTasksForTab) { + return undefined; + } + + return { + priority: BadgeStatePriority.High, + state: { + icon: BadgeIcon.Berry, + // Unset text and background color to use default badge appearance + text: Unset, + backgroundColor: Unset, + }, + }; + }), + ); }); - - BrowserApi.addListener(chrome.tabs.onUpdated, (_, changeInfo, tab) => { - if (changeInfo.url) { - this.tabUpdated$.next(tab); - } - }); - - BrowserApi.addListener(chrome.tabs.onActivated, async (activeInfo) => { - const tab = await BrowserApi.getTab(activeInfo.tabId); - if (!tab) { - this.logService.warning( - `Tab activated event received but tab not found (id: ${activeInfo.tabId})`, - ); - return; - } - - this.tabActivated$.next(tab); - }); - - BrowserApi.addListener(chrome.tabs.onRemoved, (tabId, _) => this.tabRemoved$.next(tabId)); - } - - /** Sets the pending task state for the tab */ - private async setTabState(tab: chrome.tabs.Tab, userId: UserId, pendingTasks: SecurityTask[]) { - if (!tab.id) { - this.logService.warning("Tab event received but tab id is undefined"); - return; - } - - const ciphers = tab.url - ? await this.cipherService.getAllDecryptedForUrl(tab.url, userId, [], undefined, true) - : []; - - const hasPendingTasksForTab = pendingTasks.some((task) => - ciphers.some((cipher) => cipher.id === task.cipherId && !cipher.isDeleted), - ); - - if (!hasPendingTasksForTab) { - await this.clearTabState(tab.id); - return; - } - - await this.badgeService.setState( - StateName(tab.id), - BadgeStatePriority.High, - { - icon: BadgeIcon.Berry, - // Unset text and background color to use default badge appearance - text: Unset, - backgroundColor: Unset, - }, - tab.id, - ); - } - - /** Clears the pending task state from a tab */ - private async clearTabState(tabId: number) { - await this.badgeService.clearState(StateName(tabId)); } } diff --git a/apps/browser/src/vault/services/fido2-user-verification.service.spec.ts b/apps/browser/src/vault/services/fido2-user-verification.service.spec.ts index 97a22bb2cf3..e55e3091244 100644 --- a/apps/browser/src/vault/services/fido2-user-verification.service.spec.ts +++ b/apps/browser/src/vault/services/fido2-user-verification.service.spec.ts @@ -1,11 +1,15 @@ import { MockProxy, mock } from "jest-mock-extended"; +import { of } from "rxjs"; import { UserVerificationDialogComponent } from "@bitwarden/auth/angular"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogService } from "@bitwarden/components"; +import { newGuid } from "@bitwarden/guid"; import { PasswordRepromptService } from "@bitwarden/vault"; // FIXME (PM-22628): Popup imports are forbidden in background @@ -31,21 +35,24 @@ describe("Fido2UserVerificationService", () => { let fido2UserVerificationService: Fido2UserVerificationService; let passwordRepromptService: MockProxy; - let userVerificationService: MockProxy; + let userDecryptionOptionsService: MockProxy; let dialogService: MockProxy; + let accountService: FakeAccountService; let cipher: CipherView; beforeEach(() => { passwordRepromptService = mock(); - userVerificationService = mock(); + userDecryptionOptionsService = mock(); dialogService = mock(); + accountService = mockAccountServiceWith(newGuid() as UserId); cipher = createCipherView(); fido2UserVerificationService = new Fido2UserVerificationService( passwordRepromptService, - userVerificationService, + userDecryptionOptionsService, dialogService, + accountService, ); (UserVerificationDialogComponent.open as jest.Mock).mockResolvedValue({ @@ -67,7 +74,7 @@ describe("Fido2UserVerificationService", () => { it("should call master password reprompt dialog if user is redirected from lock screen, has master password and master password reprompt is required", async () => { cipher.reprompt = CipherRepromptType.Password; - userVerificationService.hasMasterPassword.mockResolvedValue(true); + userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(true)); passwordRepromptService.showPasswordPrompt.mockResolvedValue(true); const result = await fido2UserVerificationService.handleUserVerification( @@ -82,7 +89,7 @@ describe("Fido2UserVerificationService", () => { it("should call user verification dialog if user is redirected from lock screen, does not have a master password and master password reprompt is required", async () => { cipher.reprompt = CipherRepromptType.Password; - userVerificationService.hasMasterPassword.mockResolvedValue(false); + userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false)); const result = await fido2UserVerificationService.handleUserVerification( true, @@ -98,7 +105,7 @@ describe("Fido2UserVerificationService", () => { it("should call user verification dialog if user is not redirected from lock screen, does not have a master password and master password reprompt is required", async () => { cipher.reprompt = CipherRepromptType.Password; - userVerificationService.hasMasterPassword.mockResolvedValue(false); + userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false)); const result = await fido2UserVerificationService.handleUserVerification( true, @@ -114,7 +121,7 @@ describe("Fido2UserVerificationService", () => { it("should call master password reprompt dialog if user is not redirected from lock screen, has a master password and master password reprompt is required", async () => { cipher.reprompt = CipherRepromptType.Password; - userVerificationService.hasMasterPassword.mockResolvedValue(false); + userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false)); passwordRepromptService.showPasswordPrompt.mockResolvedValue(true); const result = await fido2UserVerificationService.handleUserVerification( @@ -176,7 +183,7 @@ describe("Fido2UserVerificationService", () => { it("should call master password reprompt dialog if user is redirected from lock screen, has master password and master password reprompt is required", async () => { cipher.reprompt = CipherRepromptType.Password; - userVerificationService.hasMasterPassword.mockResolvedValue(true); + userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(true)); passwordRepromptService.showPasswordPrompt.mockResolvedValue(true); const result = await fido2UserVerificationService.handleUserVerification( @@ -191,7 +198,7 @@ describe("Fido2UserVerificationService", () => { it("should call user verification dialog if user is redirected from lock screen, does not have a master password and master password reprompt is required", async () => { cipher.reprompt = CipherRepromptType.Password; - userVerificationService.hasMasterPassword.mockResolvedValue(false); + userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false)); const result = await fido2UserVerificationService.handleUserVerification( false, @@ -207,7 +214,7 @@ describe("Fido2UserVerificationService", () => { it("should call user verification dialog if user is not redirected from lock screen, does not have a master password and master password reprompt is required", async () => { cipher.reprompt = CipherRepromptType.Password; - userVerificationService.hasMasterPassword.mockResolvedValue(false); + userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false)); const result = await fido2UserVerificationService.handleUserVerification( false, @@ -223,7 +230,7 @@ describe("Fido2UserVerificationService", () => { it("should call master password reprompt dialog if user is not redirected from lock screen, has a master password and master password reprompt is required", async () => { cipher.reprompt = CipherRepromptType.Password; - userVerificationService.hasMasterPassword.mockResolvedValue(false); + userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false)); passwordRepromptService.showPasswordPrompt.mockResolvedValue(true); const result = await fido2UserVerificationService.handleUserVerification( diff --git a/apps/browser/src/vault/services/fido2-user-verification.service.ts b/apps/browser/src/vault/services/fido2-user-verification.service.ts index 9bf9be70fc8..db3951d44d9 100644 --- a/apps/browser/src/vault/services/fido2-user-verification.service.ts +++ b/apps/browser/src/vault/services/fido2-user-verification.service.ts @@ -3,7 +3,8 @@ import { firstValueFrom } from "rxjs"; import { UserVerificationDialogComponent } from "@bitwarden/auth/angular"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -15,8 +16,9 @@ import { SetPinComponent } from "../../auth/popup/components/set-pin.component"; export class Fido2UserVerificationService { constructor( private passwordRepromptService: PasswordRepromptService, - private userVerificationService: UserVerificationService, + private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, private dialogService: DialogService, + private accountService: AccountService, ) {} /** @@ -78,7 +80,15 @@ export class Fido2UserVerificationService { } private async handleMasterPasswordReprompt(): Promise { - const hasMasterPassword = await this.userVerificationService.hasMasterPassword(); + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + + if (!activeAccount?.id) { + return false; + } + + const hasMasterPassword = await firstValueFrom( + this.userDecryptionOptionsService.hasMasterPasswordById$(activeAccount.id), + ); // TDE users have no master password, so we need to use the UserVerification prompt return hasMasterPassword diff --git a/apps/browser/store/locales/fr/copy.resx b/apps/browser/store/locales/fr/copy.resx index 327d39a23c4..3ccb9c26b71 100644 --- a/apps/browser/store/locales/fr/copy.resx +++ b/apps/browser/store/locales/fr/copy.resx @@ -168,7 +168,8 @@ Applications multiplateformes Sécurisez et partagez des données sensibles dans votre coffre Bitwarden à partir de n'importe quel navigateur, appareil mobile ou système d'exploitation de bureau, et plus encore. Bitwarden sécurise bien plus que les mots de passe -Les solutions de gestion de bout en bout des identifiants chiffrés de Bitwarden permettent aux organisations de tout sécuriser, y compris les secrets des développeurs et les expériences de clés de passe. Visitez Bitwarden.com pour en savoir plus sur Bitwarden Secrets Manager et Bitwarden Passwordless.dev ! +Les solutions de gestion de bout en bout des identifiants chiffrés de Bitwarden permettent aux organisations de tout sécuriser, y compris les secrets des développeurs et les expériences de clés d'accès. Visitez Bitwarden.com pour en savoir plus sur Bitwarden Secrets Manager et Bitwarden Passwordless.dev ! + À la maison, au travail ou en déplacement, Bitwarden sécurise facilement tous vos mots de passe, clés d'accès et informations sensibles. diff --git a/apps/browser/store/locales/hu/copy.resx b/apps/browser/store/locales/hu/copy.resx index 814ebabaada..e3a5c733eb5 100644 --- a/apps/browser/store/locales/hu/copy.resx +++ b/apps/browser/store/locales/hu/copy.resx @@ -118,10 +118,10 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Bitwarden Password Manager + Bitwarden Jelszókezelő - At home, at work, or on the go, Bitwarden easily secures all your passwords, passkeys, and sensitive information. + Legyen otthon, munkában, vagy úton, a Bitwarden könnyen biztosítja jelszavát, kulcsait, és kényes információit. Recognized as the best password manager by PCMag, WIRED, The Verge, CNET, G2, and more! @@ -169,7 +169,7 @@ End-to-end encrypted credential management solutions from Bitwarden empower orga - At home, at work, or on the go, Bitwarden easily secures all your passwords, passkeys, and sensitive information. + Legyen otthon, munkában, vagy úton, a Bitwarden könnyen biztosítja jelszavát, kulcsait, és kényes információit. A széf szinkronizálása és elérése több eszközön. diff --git a/apps/browser/store/locales/pt_BR/copy.resx b/apps/browser/store/locales/pt_BR/copy.resx index edf2351c92d..09855fd5798 100644 --- a/apps/browser/store/locales/pt_BR/copy.resx +++ b/apps/browser/store/locales/pt_BR/copy.resx @@ -118,66 +118,66 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Gerenciador de Senhas Bitwarden + Bitwarden Gerenciador de Senhas - Em casa, no trabalho, ou em qualquer lugar, o Bitwarden protege facilmente todas as suas senhas, chaves de acesso e informações sensíveis. + Onde quer que você esteja, a Bitwarden protege facilmente todas as suas senhas, chaves de acesso e informações sensíveis. - Reconhecido como o melhor gerenciador de senhas por "PCMag", 'WIRED', 'The Verge', 'CNET', 'G2', entre outros! + Reconhecido como o melhor gerenciador de senhas por PCMag, WIRED, The Verge, CNET, G2, e outros! PROTEJA A SUA VIDA DIGITAL -Deixe a sua vida digital segura e se proteja contra violações de dados gerando e salvando senhas únicas e fortes para cada conta pessoal. Mantendo tudo isso em um cofre criptografado de ponta a ponta em que apenas você pode acessar. +Proteja a sua vida digital e proteja-se de vazamentos de dados gerando e salvando senhas únicas e seguras, para cada conta. Mantenha tudo em um cofre criptografado de ponta a ponta, que só você pode acessar. -ACESSE OS SEUS DADOS, EM QUALQUER LUGAR, HORA E DISPOSITIVO -Gerencie, armazene, proteja e compartilhe senhas facilmente entre dispositivos ilimitados e sem restrições. +ACESSE SEUS DADOS, DE QUALQUER LUGAR, QUALQUER HORA, EM QUALQUER DISPOSITIVO +Gerencie, armazene, proteja, e compartilhe senhas ilimitadas entre dispositivos ilimitados, sem restrições. Tudo de forma fácil. -TODOS DEVERIAM TEM FERRAMENTAS PARA SE PROTEGER ONLINE -Utilize Bitwarden de graça sem anúncios ou venda dos seus dados. A Bitwarden acredita que todos deveriam ter a opção de estar seguro online. Nossos planos Premium oferecem recursos mais avançados. +TODOS DEVERIAM TER AS FERRAMENTAS PARA SE PROTEGER ON-LINE +Utilize o Bitwarden de graça, sem anúncios, e sem venda de dados. O Bitwarden acredita que todos deveriam ter a possibilidade de se proteger on-line. O plano Premium oferece acesso a recursos avançados. -EMPODERE O SEUS TIMES COM BITWARDEN -Os planos para times e empresas vêm com recursos profissionais para negócios. Alguns exemplos incluem integração SSO, auto-hospedagem, integração de diretório e provisionamento SCIM, políticas globais, acesso API, registro de eventos e mais. +EMPODERE SUAS EQUIPES COM O BITWARDEN +Os planos para Equipes e o Empresarial vêm com recursos empresariais profisionais. Alguns exemplos incluem integração com SSO, auto-hospedagem, integração de diretório e provisionamento de SCIM, políticas globais, acesso à API, registros de eventos, e mais. -Utilize o Bitwarden para proteger os seus colaboradores e compartilhar informações sensíveis com seus colegas. +Use o Bitwarden para protejer a sua força de trabalho e compartilhe informações sensíveis com colegas. -Mais razões para escolher Bitwarden: +Mais motivos para escolher o Bitwarden: -Criptografia mundialmente reconhecida -Suas senhas são protegidas com avançada criptografia ponta a ponta (AES-256, salted hashing, e PBKDF2 SHA-256) para que os seus dados estejam seguros e privados. +Criptografia reconhecida mundialmente +As senhas são protegidas com criptografia avançada de ponta a ponta (AES de 256 bits, hashing com salting, e PBKDF2 SHA-256) para que seus dados continuem seguros e privados. -Auditoria de Terceiros -A Bitwarden regularmente conduz auditorias de terceiros com notáveis empresas de segurança. Essas auditorias anuais incluem qualificação do código fonte e testes de invasão contra os IPs da Bitwarden, servidores e aplicações web. +Auditoria por terceiros +O Bitwarden conduz auditorias abrangentes de terceiros periodicamente com firmas de segurança nótaveis. Essas auditorias anuais incluem análise do código-fonte e teste de penetração entre IPs, servidores, e aplicativos web do Bitwarden. -2FA Avançado -Proteja o seu login com um autenticador de dois fatores, códigos de email ou credenciais FIDO2 WebAuthn como chave de segurança por hardware ou chave de acesso. +Autenticação de duas etapas avançada +Proteja sua autenticação com um autenticador de terceiros, códigos enviados por e-mail, ou credenciais de WebAuthn FIDO2, como uma chave de segurança física ou uma chave de acesso. -Envio Bitwarden -Transmita dados diretamente para outras pessoas enquanto mantém a segurança da criptografia ponta a ponta para limitar sua exposição. +Bitwarden Send +Transmita dados diretamente para outros enquanto mantém a segurança da criptografia de ponta a ponta, limitando exposição. -Gerador integrado -Crie diferentes senhas fortes e complexas e nomes de usuário únicos para cada site que visitar. Integre com provedores de email para privacidade adicional. +Gerador embutido +Crie senhas longas, complexas, e distintas, e nomes de usuário únicos para cada site que visita. Integre com provedores de alias de email para privacidade adicional. -Tradutores globais -As traduções da Bitwarden estão disponíveis para mais de 60 línguas, traduzidas pela comunidade global através do Crowdin. +Traduções globais +O Bitwarden tem traduções para mais de 60 idiomas, traduzidos pela comunidade global, pelo Crowdin. -Aplicações Multi-Plataforma -Proteja e compartilhe conteúdo confidencial dentro do Vault da Bitwarden de qualquer navegador, dispositivo móvel, desktop, entre outros. +Aplicativos multiplataforma +Proteja e compartilhe dados sensíveis dentro do seu cofre do Bitwarden de qualquer navegador, dispositivo móvel, sistema operacional de computador, e mais. -Bitwarden protege mais do que apenas senhas -Com nossas soluções em gerenciamento de credenciais criptografadas ponta a ponta, a Bitwarden empodera organizações para proteger qualquer informação, incluindo senhas de desenvolvedor e chaves de acesso. Visite Bitwarden.com para aprender mais sobre Bitwarden Secrets Manager e Bitwarden Passwordless.dev! +O Bitwarden protege mais do que só senhas +As soluções criptografadas de ponta a ponta de gerenciamento de credenciaisdo Bitwarden empoderam organizações a proteger tudo, incluindo segredos de desenvolvedor, e experiências com chaves de acesso. Visite bitwarden.com para aprender mais sobre o Bitwarden Gerenciador de Segredos e o Bitwarden Passworldless.dev! - Onde quer que você esteja, a Bitwarden protege facilmente todas as suas senha, chaves de acesso e informações sensíveis. + Onde quer que você esteja, a Bitwarden protege facilmente todas as suas senhas, chaves de acesso e informações sensíveis. - Sincronize e acesse o seu cofre através de múltiplos dispositivos + Sincronize e acesse o seu cofre entre vários dispositivos Gerencie todas as suas credenciais a partir de um cofre seguro - Autopreencha rapidamente as suas credenciais dentro de qualquer site que visitar + Preencha automaticamente as suas credenciais dentro de qualquer site que visitar O seu cofre é também acessível a partir do menu de contexto pelo clique no botão direito do mouse @@ -186,6 +186,6 @@ Com nossas soluções em gerenciamento de credenciais criptografadas ponta a pon Gera automaticamente senhas fortes, aleatórias e seguras - A sua informação é gerenciada com segurança utilizando encriptação AES-256 bits + A sua informação é gerenciada com segurança utilizando criptografia de AES de 256 bits diff --git a/apps/browser/store/locales/sk/copy.resx b/apps/browser/store/locales/sk/copy.resx index 2b7e903fe52..816f1bf7e8c 100644 --- a/apps/browser/store/locales/sk/copy.resx +++ b/apps/browser/store/locales/sk/copy.resx @@ -118,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Bitwarden – Bezplatný správca hesiel + Bitwarden – správca hesiel Bitwarden zabezpečí všetky vaše heslá, prístupové kľúče a citlivé informácie doma, v práci alebo na cestách. diff --git a/apps/browser/tailwind.config.js b/apps/browser/tailwind.config.js index 1ad56562bb3..134001bbf13 100644 --- a/apps/browser/tailwind.config.js +++ b/apps/browser/tailwind.config.js @@ -10,6 +10,7 @@ config.content = [ "../../libs/vault/src/**/*.{html,ts}", "../../libs/angular/src/**/*.{html,ts}", "../../libs/vault/src/**/*.{html,ts}", + "../../libs/pricing/src/**/*.{html,ts}", ]; module.exports = config; diff --git a/apps/browser/tsconfig.json b/apps/browser/tsconfig.json index 0fd6cac4230..6fb9dfbe46b 100644 --- a/apps/browser/tsconfig.json +++ b/apps/browser/tsconfig.json @@ -1,5 +1,8 @@ { "extends": "../../tsconfig.base", + "angularCompilerOptions": { + "strictTemplates": true + }, "include": [ "src", "../../libs/common/src/autofill/constants", diff --git a/apps/browser/webpack.base.js b/apps/browser/webpack.base.js index 872da6600b4..4bc2a90c4ff 100644 --- a/apps/browser/webpack.base.js +++ b/apps/browser/webpack.base.js @@ -10,14 +10,18 @@ const configurator = require("./config/config"); const manifest = require("./webpack/manifest"); const AngularCheckPlugin = require("./webpack/angular-check"); -module.exports.getEnv = function getEnv() { - const ENV = (process.env.ENV = process.env.NODE_ENV); +module.exports.getEnv = function getEnv(params) { + const ENV = params.env || (process.env.ENV = process.env.NODE_ENV); const manifestVersion = process.env.MANIFEST_VERSION == 3 ? 3 : 2; const browser = process.env.BROWSER ?? "chrome"; return { ENV, manifestVersion, browser }; }; +const DEFAULT_PARAMS = { + outputPath: path.resolve(__dirname, "build"), +}; + /** * @param {{ * configName: string; @@ -29,15 +33,21 @@ module.exports.getEnv = function getEnv() { * entry: string; * }; * tsConfig: string; - * additionalEntries?: { [outputPath: string]: string } + * outputPath?: string; + * mode?: string; + * env?: string; + * additionalEntries?: { [outputPath: string]: string }; + * importAliases?: import("webpack").ResolveOptions["alias"]; * }} params - The input parameters for building the config. */ module.exports.buildConfig = function buildConfig(params) { + params = { ...DEFAULT_PARAMS, ...params }; + if (process.env.NODE_ENV == null) { process.env.NODE_ENV = "development"; } - const { ENV, manifestVersion, browser } = module.exports.getEnv(); + const { ENV, manifestVersion, browser } = module.exports.getEnv(params); console.log(`Building Manifest Version ${manifestVersion} app - ${params.configName} version`); @@ -103,7 +113,7 @@ module.exports.buildConfig = function buildConfig(params) { { loader: "babel-loader", options: { - configFile: "../../babel.config.json", + configFile: path.resolve(__dirname, "../../babel.config.json"), cacheDirectory: ENV === "development", compact: ENV !== "development", }, @@ -130,43 +140,52 @@ module.exports.buildConfig = function buildConfig(params) { const plugins = [ new HtmlWebpackPlugin({ - template: "./src/popup/index.ejs", + template: path.resolve(__dirname, "src/popup/index.ejs"), filename: "popup/index.html", chunks: ["popup/polyfills", "popup/vendor-angular", "popup/vendor", "popup/main"], browser: browser, }), new HtmlWebpackPlugin({ - template: "./src/autofill/notification/bar.html", + template: path.resolve(__dirname, "src/autofill/notification/bar.html"), filename: "notification/bar.html", chunks: ["notification/bar"], }), new HtmlWebpackPlugin({ - template: "./src/autofill/overlay/inline-menu/pages/button/button.html", + template: path.resolve( + __dirname, + "src/autofill/overlay/inline-menu/pages/button/button.html", + ), filename: "overlay/menu-button.html", chunks: ["overlay/menu-button"], }), new HtmlWebpackPlugin({ - template: "./src/autofill/overlay/inline-menu/pages/list/list.html", + template: path.resolve(__dirname, "src/autofill/overlay/inline-menu/pages/list/list.html"), filename: "overlay/menu-list.html", chunks: ["overlay/menu-list"], }), new HtmlWebpackPlugin({ - template: "./src/autofill/overlay/inline-menu/pages/menu-container/menu-container.html", + template: path.resolve( + __dirname, + "src/autofill/overlay/inline-menu/pages/menu-container/menu-container.html", + ), filename: "overlay/menu.html", chunks: ["overlay/menu"], }), new CopyWebpackPlugin({ patterns: [ { - from: manifestVersion == 3 ? "./src/manifest.v3.json" : "./src/manifest.json", + from: + manifestVersion == 3 + ? path.resolve(__dirname, "src/manifest.v3.json") + : path.resolve(__dirname, "src/manifest.json"), to: "manifest.json", transform: manifest.transform(browser), }, - { from: "./src/managed_schema.json", to: "managed_schema.json" }, - { from: "./src/_locales", to: "_locales" }, - { from: "./src/images", to: "images" }, - { from: "./src/popup/images", to: "popup/images" }, - { from: "./src/autofill/content/autofill.css", to: "content" }, + { from: path.resolve(__dirname, "src/managed_schema.json"), to: "managed_schema.json" }, + { from: path.resolve(__dirname, "src/_locales"), to: "_locales" }, + { from: path.resolve(__dirname, "src/images"), to: "images" }, + { from: path.resolve(__dirname, "src/popup/images"), to: "popup/images" }, + { from: path.resolve(__dirname, "src/autofill/content/autofill.css"), to: "content" }, ], }), new MiniCssExtractPlugin({ @@ -196,33 +215,76 @@ module.exports.buildConfig = function buildConfig(params) { name: "main", mode: ENV, devtool: false, + entry: { - "popup/polyfills": "./src/popup/polyfills.ts", + "popup/polyfills": path.resolve(__dirname, "src/popup/polyfills.ts"), "popup/main": params.popup.entry, - "content/trigger-autofill-script-injection": - "./src/autofill/content/trigger-autofill-script-injection.ts", - "content/bootstrap-autofill": "./src/autofill/content/bootstrap-autofill.ts", - "content/bootstrap-autofill-overlay": "./src/autofill/content/bootstrap-autofill-overlay.ts", - "content/bootstrap-autofill-overlay-menu": - "./src/autofill/content/bootstrap-autofill-overlay-menu.ts", - "content/bootstrap-autofill-overlay-notifications": - "./src/autofill/content/bootstrap-autofill-overlay-notifications.ts", - "content/autofiller": "./src/autofill/content/autofiller.ts", - "content/auto-submit-login": "./src/autofill/content/auto-submit-login.ts", - "content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts", - "content/content-message-handler": "./src/autofill/content/content-message-handler.ts", - "content/fido2-content-script": "./src/autofill/fido2/content/fido2-content-script.ts", - "content/fido2-page-script": "./src/autofill/fido2/content/fido2-page-script.ts", - "content/ipc-content-script": "./src/platform/ipc/content/ipc-content-script.ts", - "notification/bar": "./src/autofill/notification/bar.ts", - "overlay/menu-button": - "./src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts", - "overlay/menu-list": - "./src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts", - "overlay/menu": - "./src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts", - "content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts", - "content/send-popup-open-message": "./src/vault/content/send-popup-open-message.ts", + "content/trigger-autofill-script-injection": path.resolve( + __dirname, + "src/autofill/content/trigger-autofill-script-injection.ts", + ), + "content/bootstrap-autofill": path.resolve( + __dirname, + "src/autofill/content/bootstrap-autofill.ts", + ), + "content/bootstrap-autofill-overlay": path.resolve( + __dirname, + "src/autofill/content/bootstrap-autofill-overlay.ts", + ), + "content/bootstrap-autofill-overlay-menu": path.resolve( + __dirname, + "src/autofill/content/bootstrap-autofill-overlay-menu.ts", + ), + "content/bootstrap-autofill-overlay-notifications": path.resolve( + __dirname, + "src/autofill/content/bootstrap-autofill-overlay-notifications.ts", + ), + "content/autofiller": path.resolve(__dirname, "src/autofill/content/autofiller.ts"), + "content/auto-submit-login": path.resolve( + __dirname, + "src/autofill/content/auto-submit-login.ts", + ), + "content/contextMenuHandler": path.resolve( + __dirname, + "src/autofill/content/context-menu-handler.ts", + ), + "content/content-message-handler": path.resolve( + __dirname, + "src/autofill/content/content-message-handler.ts", + ), + "content/fido2-content-script": path.resolve( + __dirname, + "src/autofill/fido2/content/fido2-content-script.ts", + ), + "content/fido2-page-script": path.resolve( + __dirname, + "src/autofill/fido2/content/fido2-page-script.ts", + ), + "content/ipc-content-script": path.resolve( + __dirname, + "src/platform/ipc/content/ipc-content-script.ts", + ), + "notification/bar": path.resolve(__dirname, "src/autofill/notification/bar.ts"), + "overlay/menu-button": path.resolve( + __dirname, + "src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts", + ), + "overlay/menu-list": path.resolve( + __dirname, + "src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts", + ), + "overlay/menu": path.resolve( + __dirname, + "src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts", + ), + "content/send-on-installed-message": path.resolve( + __dirname, + "src/vault/content/send-on-installed-message.ts", + ), + "content/send-popup-open-message": path.resolve( + __dirname, + "src/vault/content/send-popup-open-message.ts", + ), ...params.additionalEntries, }, cache: @@ -291,7 +353,7 @@ module.exports.buildConfig = function buildConfig(params) { resolve: { extensions: [".ts", ".js"], symlinks: false, - modules: [path.resolve("../../node_modules")], + modules: [path.resolve(__dirname, "../../node_modules")], fallback: { assert: false, buffer: require.resolve("buffer/"), @@ -301,12 +363,13 @@ module.exports.buildConfig = function buildConfig(params) { path: require.resolve("path-browserify"), }, cache: true, + alias: params.importAliases, }, output: { filename: "[name].js", chunkFilename: "assets/[name].js", webassemblyModuleFilename: "assets/[modulehash].wasm", - path: path.resolve(__dirname, "build"), + path: params.outputPath, clean: true, }, module: { @@ -335,7 +398,7 @@ module.exports.buildConfig = function buildConfig(params) { // Manifest V2 uses Background Pages which requires a html page. mainConfig.plugins.push( new HtmlWebpackPlugin({ - template: "./src/platform/background.html", + template: path.resolve(__dirname, "src/platform/background.html"), filename: "background.html", chunks: ["vendor", "background"], }), @@ -344,19 +407,23 @@ module.exports.buildConfig = function buildConfig(params) { // Manifest V2 background pages can be run through the regular build pipeline. // Since it's a standard webpage. mainConfig.entry.background = params.background.entry; - mainConfig.entry["content/fido2-page-script-delay-append-mv2"] = - "./src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts"; + mainConfig.entry["content/fido2-page-script-delay-append-mv2"] = path.resolve( + __dirname, + "src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts", + ); configs.push(mainConfig); } else { // Firefox does not use the offscreen API if (browser !== "firefox") { - mainConfig.entry["offscreen-document/offscreen-document"] = - "./src/platform/offscreen-document/offscreen-document.ts"; + mainConfig.entry["offscreen-document/offscreen-document"] = path.resolve( + __dirname, + "src/platform/offscreen-document/offscreen-document.ts", + ); mainConfig.plugins.push( new HtmlWebpackPlugin({ - template: "./src/platform/offscreen-document/index.html", + template: path.resolve(__dirname, "src/platform/offscreen-document/index.html"), filename: "offscreen-document/index.html", chunks: ["offscreen-document/offscreen-document"], }), @@ -372,11 +439,12 @@ module.exports.buildConfig = function buildConfig(params) { name: "background", mode: ENV, devtool: false, + entry: params.background.entry, target: target, output: { filename: "background.js", - path: path.resolve(__dirname, "build"), + path: params.outputPath, }, module: { rules: [ @@ -409,13 +477,14 @@ module.exports.buildConfig = function buildConfig(params) { resolve: { extensions: [".ts", ".js"], symlinks: false, - modules: [path.resolve("../../node_modules")], + modules: [path.resolve(__dirname, "../../node_modules")], plugins: [new TsconfigPathsPlugin()], fallback: { fs: false, path: require.resolve("path-browserify"), }, cache: true, + alias: params.importAliases, }, dependencies: ["main"], plugins: [...requiredPlugins, new AngularCheckPlugin()], @@ -428,8 +497,11 @@ module.exports.buildConfig = function buildConfig(params) { backgroundConfig.plugins.push( new CopyWebpackPlugin({ patterns: [ - { from: "./src/safari/mv3/fake-background.html", to: "background.html" }, - { from: "./src/safari/mv3/fake-vendor.js", to: "vendor.js" }, + { + from: path.resolve(__dirname, "src/safari/mv3/fake-background.html"), + to: "background.html", + }, + { from: path.resolve(__dirname, "src/safari/mv3/fake-vendor.js"), to: "vendor.js" }, ], }), ); diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index 9eac990ab61..cb0761c859b 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -1,13 +1,54 @@ +const path = require("path"); const { buildConfig } = require("./webpack.base"); -module.exports = buildConfig({ - configName: "OSS", - popup: { - entry: "./src/popup/main.ts", - entryModule: "src/popup/app.module#AppModule", - }, - background: { - entry: "./src/platform/background.ts", - }, - tsConfig: "tsconfig.json", -}); +module.exports = (webpackConfig, context) => { + // Detect if called by Nx (context parameter exists) + const isNxBuild = context && context.options; + + if (isNxBuild) { + // Nx build configuration + const mode = context.options.mode || "development"; + if (process.env.NODE_ENV == null) { + process.env.NODE_ENV = mode; + } + const ENV = (process.env.ENV = process.env.NODE_ENV); + + // Set environment variables from Nx context + if (context.options.env) { + Object.keys(context.options.env).forEach((key) => { + process.env[key] = context.options.env[key]; + }); + } + + return buildConfig({ + configName: "OSS", + popup: { + entry: path.resolve(__dirname, "src/popup/main.ts"), + entryModule: "src/popup/app.module#AppModule", + }, + background: { + entry: path.resolve(__dirname, "src/platform/background.ts"), + }, + tsConfig: path.resolve(__dirname, "tsconfig.json"), + outputPath: + context.context && context.context.root + ? path.resolve(context.context.root, context.options.outputPath) + : context.options.outputPath, + mode: mode, + env: ENV, + }); + } else { + // npm build configuration + return buildConfig({ + configName: "OSS", + popup: { + entry: path.resolve(__dirname, "src/popup/main.ts"), + entryModule: "src/popup/app.module#AppModule", + }, + background: { + entry: path.resolve(__dirname, "src/platform/background.ts"), + }, + tsConfig: "tsconfig.json", + }); + } +}; diff --git a/apps/cli/CLAUDE.md b/apps/cli/CLAUDE.md new file mode 100644 index 00000000000..72ec31eaaf5 --- /dev/null +++ b/apps/cli/CLAUDE.md @@ -0,0 +1,13 @@ +# CLI - Critical Rules + +- **ALWAYS** output structured JSON when `process.env.BW_RESPONSE === "true"` + - Use Response objects (MessageResponse, ListResponse, etc.) from `/apps/cli/src/models/response/` + - DON'T write free-form text that breaks JSON parsing + +- **NEVER** use `console.log()` for output + - Use `CliUtils.writeLn()` to respect `BW_QUIET` and `BW_RESPONSE` environment variables + - Use the `ConsoleLogService` from the `ServiceContainer` + +- **ALWAYS** respect `BW_CLEANEXIT` environment variable + - Exit code 0 even on errors when `BW_CLEANEXIT` is set + - Required for scripting environments that need clean exits diff --git a/apps/cli/package.json b/apps/cli/package.json index 659a68d13a5..adddc99b4d7 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.9.0", + "version": "2025.12.0", "keywords": [ "bitwarden", "password", @@ -13,7 +13,7 @@ "homepage": "https://bitwarden.com", "repository": { "type": "git", - "url": "https://github.com/bitwarden/clients" + "url": "git+https://github.com/bitwarden/clients.git" }, "license": "SEE LICENSE IN LICENSE.txt", "scripts": { @@ -75,20 +75,20 @@ "inquirer": "8.2.6", "jsdom": "26.1.0", "jszip": "3.10.1", - "koa": "2.16.1", + "koa": "2.16.3", "koa-bodyparser": "4.4.1", "koa-json": "2.0.2", "lowdb": "1.0.0", "lunr": "2.3.9", "multer": "2.0.2", "node-fetch": "2.6.12", - "node-forge": "1.3.1", + "node-forge": "1.3.2", "open": "10.1.2", "papaparse": "5.5.3", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", - "semver": "7.7.2", - "tldts": "7.0.1", + "semver": "7.7.3", + "tldts": "7.0.18", "zxcvbn": "4.4.2" } } diff --git a/apps/cli/project.json b/apps/cli/project.json new file mode 100644 index 00000000000..229738818a7 --- /dev/null +++ b/apps/cli/project.json @@ -0,0 +1,86 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "cli", + "projectType": "application", + "sourceRoot": "apps/cli/src", + "tags": ["scope:cli", "type:app"], + "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "oss-dev", + "options": { + "outputPath": "dist/apps/cli", + "webpackConfig": "apps/cli/webpack.config.js", + "tsConfig": "apps/cli/tsconfig.json", + "main": "apps/cli/src/bw.ts", + "target": "node", + "compiler": "tsc" + }, + "configurations": { + "oss": { + "mode": "production", + "outputPath": "dist/apps/cli/oss" + }, + "oss-dev": { + "mode": "development", + "outputPath": "dist/apps/cli/oss-dev" + }, + "commercial": { + "mode": "production", + "outputPath": "dist/apps/cli/commercial", + "webpackConfig": "bitwarden_license/bit-cli/webpack.config.js", + "main": "bitwarden_license/bit-cli/src/bw.ts", + "tsConfig": "bitwarden_license/bit-cli/tsconfig.json" + }, + "commercial-dev": { + "mode": "development", + "outputPath": "dist/apps/cli/commercial-dev", + "webpackConfig": "bitwarden_license/bit-cli/webpack.config.js", + "main": "bitwarden_license/bit-cli/src/bw.ts", + "tsConfig": "bitwarden_license/bit-cli/tsconfig.json" + } + } + }, + "serve": { + "executor": "@nx/webpack:webpack", + "defaultConfiguration": "oss-dev", + "options": { + "outputPath": "dist/apps/cli", + "webpackConfig": "apps/cli/webpack.config.js", + "tsConfig": "apps/cli/tsconfig.json", + "main": "apps/cli/src/bw.ts", + "target": "node", + "compiler": "tsc", + "watch": true + }, + "configurations": { + "oss-dev": { + "mode": "development", + "outputPath": "dist/apps/cli/oss-dev" + }, + "commercial-dev": { + "mode": "development", + "outputPath": "dist/apps/cli/commercial-dev", + "webpackConfig": "bitwarden_license/bit-cli/webpack.config.js", + "main": "bitwarden_license/bit-cli/src/bw.ts", + "tsConfig": "bitwarden_license/bit-cli/tsconfig.json" + } + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/cli/jest.config.js" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/cli/**/*.ts"] + } + } + } +} diff --git a/apps/cli/src/auth/commands/lock.command.ts b/apps/cli/src/auth/commands/lock.command.ts index f3b8018f40e..eef85980d58 100644 --- a/apps/cli/src/auth/commands/lock.command.ts +++ b/apps/cli/src/auth/commands/lock.command.ts @@ -1,16 +1,22 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; +import { firstValueFrom } from "rxjs"; + +import { LockService } from "@bitwarden/auth/common"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { Response } from "../../models/response"; import { MessageResponse } from "../../models/response/message.response"; export class LockCommand { - constructor(private vaultTimeoutService: VaultTimeoutService) {} + constructor( + private lockService: LockService, + private accountService: AccountService, + ) {} async run() { - await this.vaultTimeoutService.lock(); - process.env.BW_SESSION = null; + const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + await this.lockService.lock(activeUserId); + process.env.BW_SESSION = undefined; const res = new MessageResponse("Your vault is locked.", null); return Response.success(res); } diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index 133c9658ae7..d0ab062d0b3 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -14,14 +14,12 @@ import { SsoUrlService, UserApiLoginCredentials, } from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; @@ -29,11 +27,13 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request"; +import { TwoFactorService, TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; import { ClientType } from "@bitwarden/common/enums"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; 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"; @@ -61,7 +61,7 @@ export class LoginCommand { constructor( protected loginStrategyService: LoginStrategyServiceAbstraction, protected authService: AuthService, - protected apiService: ApiService, + protected twoFactorApiService: TwoFactorApiService, protected masterPasswordApiService: MasterPasswordApiService, protected cryptoFunctionService: CryptoFunctionService, protected environmentService: EnvironmentService, @@ -278,7 +278,7 @@ export class LoginCommand { const emailReq = new TwoFactorEmailRequest(); emailReq.email = await this.loginStrategyService.getEmail(); emailReq.masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash(); - await this.apiService.postTwoFactorEmail(emailReq); + await this.twoFactorApiService.postTwoFactorEmail(emailReq); } if (twoFactorToken == null) { @@ -369,6 +369,15 @@ export class LoginCommand { return await this.handleSuccessResponse(response); } catch (e) { + if ( + e instanceof ErrorResponse && + e.message === "Username or password is incorrect. Try again." + ) { + const env = await firstValueFrom(this.environmentService.environment$); + const host = Utils.getHost(env.getWebVaultUrl()); + return Response.error(this.i18nService.t("invalidMasterPasswordConfirmEmailAndHost", host)); + } + return Response.error(e); } } @@ -611,7 +620,7 @@ export class LoginCommand { const newPasswordHash = await this.keyService.hashMasterKey(masterPassword, newMasterKey); // Grab user key - const userKey = await this.keyService.getUserKey(); + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); if (!userKey) { throw new Error("User key not found."); } diff --git a/apps/cli/src/base-program.ts b/apps/cli/src/base-program.ts index 5957f08de89..69a5e4e1bde 100644 --- a/apps/cli/src/base-program.ts +++ b/apps/cli/src/base-program.ts @@ -7,7 +7,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { UserId } from "@bitwarden/common/types/guid"; -import { UnlockCommand } from "./auth/commands/unlock.command"; +import { UnlockCommand } from "./key-management/commands/unlock.command"; import { Response } from "./models/response"; import { ListResponse } from "./models/response/list.response"; import { MessageResponse } from "./models/response/message.response"; @@ -182,6 +182,8 @@ export abstract class BaseProgram { this.serviceContainer.organizationApiService, this.serviceContainer.logout, this.serviceContainer.i18nService, + this.serviceContainer.masterPasswordUnlockService, + this.serviceContainer.configService, ); const response = await command.run(null, null); if (!response.success) { diff --git a/apps/cli/src/commands/edit.command.ts b/apps/cli/src/commands/edit.command.ts index 92674aa3dcd..14a218c7141 100644 --- a/apps/cli/src/commands/edit.command.ts +++ b/apps/cli/src/commands/edit.command.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import * as inquirer from "inquirer"; import { firstValueFrom, map, switchMap } from "rxjs"; import { UpdateCollectionRequest } from "@bitwarden/admin-console/common"; @@ -9,6 +10,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request"; 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"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { CipherExport } from "@bitwarden/common/models/export/cipher.export"; import { CollectionExport } from "@bitwarden/common/models/export/collection.export"; @@ -40,6 +42,7 @@ export class EditCommand { private accountService: AccountService, private cliRestrictedItemTypesService: CliRestrictedItemTypesService, private policyService: PolicyService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} async run( @@ -92,6 +95,10 @@ export class EditCommand { private async editCipher(id: string, req: CipherExport) { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const cipher = await this.cipherService.get(id, activeUserId); + const hasPremium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId), + ); + if (cipher == null) { return Response.notFound(); } @@ -102,6 +109,17 @@ export class EditCommand { } cipherView = CipherExport.toView(req, cipherView); + // When a user is editing an archived cipher and does not have premium, automatically unarchive it + if (cipherView.isArchived && !hasPremium) { + const acceptedPrompt = await this.promptForArchiveEdit(); + + if (!acceptedPrompt) { + return Response.error("Edit cancelled."); + } + + cipherView.archivedDate = null; + } + const isCipherRestricted = await this.cliRestrictedItemTypesService.isCipherRestricted(cipherView); if (isCipherRestricted) { @@ -240,6 +258,43 @@ export class EditCommand { return Response.error(e); } } + + /** Prompt the user to accept movement of their cipher back to the their vault. */ + private async promptForArchiveEdit(): Promise { + // When user has disabled interactivity or does not have the ability to prompt, + // automatically move the item back to the vault and inform them. + if ( + process.env.BW_SERVE === "true" || + process.env.BW_NOINTERACTION === "true" || + !process.stdin.isTTY + ) { + CliUtils.writeLn( + "Archive is only available with a Premium subscription, which has ended. Your edit was saved and the item was moved back to your vault.", + ); + return true; + } + + const answer: inquirer.Answers = await inquirer.createPromptModule({ + output: process.stderr, + })({ + type: "list", + name: "confirm", + message: + "When you edit and save details for an archived item without a Premium subscription, it'll be moved from your archive back to your vault.", + choices: [ + { + name: "Move now", + value: "confirmed", + }, + { + name: "Cancel", + value: "cancel", + }, + ], + }); + + return answer.confirm === "confirmed"; + } } class Options { diff --git a/apps/cli/src/commands/get.command.ts b/apps/cli/src/commands/get.command.ts index a994ad3117c..93e711d748f 100644 --- a/apps/cli/src/commands/get.command.ts +++ b/apps/cli/src/commands/get.command.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom, map, switchMap } from "rxjs"; +import { filter, firstValueFrom, map, switchMap } from "rxjs"; import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -448,7 +448,9 @@ export class GetCommand extends DownloadCommand { this.collectionService.encryptedCollections$(activeUserId).pipe(getById(id)), ); if (collection != null) { - const orgKeys = await firstValueFrom(this.keyService.activeUserOrgKeys$); + const orgKeys = await firstValueFrom( + this.keyService.orgKeys$(activeUserId).pipe(filter((orgKeys) => orgKeys != null)), + ); decCollection = await collection.decrypt( orgKeys[collection.organizationId as OrganizationId], this.encryptService, diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index d8b4cfcfd10..ff210cf222d 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -16,6 +16,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { EventType } from "@bitwarden/common/enums"; import { ListResponse as ApiListResponse } from "@bitwarden/common/models/response/list.response"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; @@ -45,6 +46,7 @@ export class ListCommand { private accountService: AccountService, private keyService: KeyService, private cliRestrictedItemTypesService: CliRestrictedItemTypesService, + private cipherArchiveService: CipherArchiveService, ) {} async run(object: string, cmdOptions: Record): Promise { @@ -71,8 +73,13 @@ export class ListCommand { let ciphers: CipherView[]; const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const userCanArchive = await firstValueFrom( + this.cipherArchiveService.userCanArchive$(activeUserId), + ); options.trash = options.trash || false; + options.archived = userCanArchive && options.archived; + if (options.url != null && options.url.trim() !== "") { ciphers = await this.cipherService.getAllDecryptedForUrl(options.url, activeUserId); } else { @@ -85,14 +92,17 @@ export class ListCommand { options.organizationId != null ) { ciphers = ciphers.filter((c) => { - if (options.trash !== c.isDeleted) { + const matchesStateOptions = this.matchesStateOptions(c, options); + + if (!matchesStateOptions) { return false; } + if (options.folderId != null) { if (options.folderId === "notnull" && c.folderId != null) { return true; } - const folderId = options.folderId === "null" ? null : options.folderId; + const folderId = options.folderId === "null" ? undefined : options.folderId; if (folderId === c.folderId) { return true; } @@ -102,7 +112,8 @@ export class ListCommand { if (options.organizationId === "notnull" && c.organizationId != null) { return true; } - const organizationId = options.organizationId === "null" ? null : options.organizationId; + const organizationId = + options.organizationId === "null" ? undefined : options.organizationId; if (organizationId === c.organizationId) { return true; } @@ -131,11 +142,16 @@ export class ListCommand { return false; }); } else if (options.search == null || options.search.trim() === "") { - ciphers = ciphers.filter((c) => options.trash === c.isDeleted); + ciphers = ciphers.filter((c) => this.matchesStateOptions(c, options)); } if (options.search != null && options.search.trim() !== "") { - ciphers = this.searchService.searchCiphersBasic(ciphers, options.search, options.trash); + ciphers = this.searchService.searchCiphersBasic( + ciphers, + options.search, + options.trash, + options.archived, + ); } ciphers = await this.cliRestrictedItemTypesService.filterRestrictedCiphers(ciphers); @@ -287,6 +303,17 @@ export class ListCommand { const res = new ListResponse(organizations.map((o) => new OrganizationResponse(o))); return Response.success(res); } + + /** + * Checks if the cipher passes the state filter options. + * @returns true if the cipher matches the requested state + */ + private matchesStateOptions(c: CipherView, options: Options): boolean { + const passesTrashFilter = options.trash === c.isDeleted; + const passesArchivedFilter = options.archived === c.isArchived; + + return passesTrashFilter && passesArchivedFilter; + } } class Options { @@ -296,6 +323,7 @@ class Options { search: string; url: string; trash: boolean; + archived: boolean; constructor(passedOptions: Record) { this.organizationId = passedOptions?.organizationid || passedOptions?.organizationId; @@ -304,5 +332,6 @@ class Options { this.search = passedOptions?.search; this.url = passedOptions?.url; this.trash = CliUtils.convertBooleanOption(passedOptions?.trash); + this.archived = CliUtils.convertBooleanOption(passedOptions?.archived); } } diff --git a/apps/cli/src/commands/restore.command.ts b/apps/cli/src/commands/restore.command.ts index 0b30193ffd4..d8cefdfce5d 100644 --- a/apps/cli/src/commands/restore.command.ts +++ b/apps/cli/src/commands/restore.command.ts @@ -2,8 +2,14 @@ import { firstValueFrom } from "rxjs"; 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 { CipherId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { UserId } from "@bitwarden/user-core"; import { Response } from "../models/response"; @@ -12,6 +18,8 @@ export class RestoreCommand { private cipherService: CipherService, private accountService: AccountService, private cipherAuthorizationService: CipherAuthorizationService, + private cipherArchiveService: CipherArchiveService, + private configService: ConfigService, ) {} async run(object: string, id: string): Promise { @@ -30,10 +38,23 @@ export class RestoreCommand { private async restoreCipher(id: string) { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const cipher = await this.cipherService.get(id, activeUserId); + const isArchivedVaultEnabled = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive), + ); if (cipher == null) { return Response.notFound(); } + + if (cipher.archivedDate && isArchivedVaultEnabled) { + return this.restoreArchivedCipher(cipher, activeUserId); + } else { + return this.restoreDeletedCipher(cipher, activeUserId); + } + } + + /** Restores a cipher from the trash. */ + private async restoreDeletedCipher(cipher: Cipher, userId: UserId) { if (cipher.deletedDate == null) { return Response.badRequest("Cipher is not in trash."); } @@ -47,7 +68,17 @@ export class RestoreCommand { } try { - await this.cipherService.restoreWithServer(id, activeUserId); + await this.cipherService.restoreWithServer(cipher.id, userId); + return Response.success(); + } catch (e) { + return Response.error(e); + } + } + + /** Restore a cipher from the archive vault */ + private async restoreArchivedCipher(cipher: Cipher, userId: UserId) { + try { + await this.cipherArchiveService.unarchiveWithServer(cipher.id as CipherId, userId); return Response.success(); } catch (e) { return Response.error(e); diff --git a/apps/cli/src/commands/serve.command.ts b/apps/cli/src/commands/serve.command.ts index c0ec37d3c9c..5bf19333f35 100644 --- a/apps/cli/src/commands/serve.command.ts +++ b/apps/cli/src/commands/serve.command.ts @@ -51,7 +51,7 @@ export class ServeCommand { .use(koaBodyParser()) .use(koaJson({ pretty: false, param: "pretty" })); - this.serveConfigurator.configureRouter(router); + await this.serveConfigurator.configureRouter(router); server.use(router.routes()).use(router.allowedMethods()); diff --git a/apps/cli/src/key-management/cli-process-reload.service.ts b/apps/cli/src/key-management/cli-process-reload.service.ts new file mode 100644 index 00000000000..243de7cae43 --- /dev/null +++ b/apps/cli/src/key-management/cli-process-reload.service.ts @@ -0,0 +1,10 @@ +import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; + +/** + * CLI implementation of ProcessReloadServiceAbstraction. + * This is NOOP since there is no effective way to process reload the CLI. + */ +export class CliProcessReloadService extends ProcessReloadServiceAbstraction { + async startProcessReload(): Promise {} + async cancelProcessReload(): Promise {} +} diff --git a/apps/cli/src/key-management/commands/unlock.command.spec.ts b/apps/cli/src/key-management/commands/unlock.command.spec.ts new file mode 100644 index 00000000000..928a750dca6 --- /dev/null +++ b/apps/cli/src/key-management/commands/unlock.command.spec.ts @@ -0,0 +1,318 @@ +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; +import { MasterPasswordVerificationResponse } from "@bitwarden/common/auth/types/verification"; +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { MasterKey, UserKey } from "@bitwarden/common/types/key"; +import { KeyService } from "@bitwarden/key-management"; +import { ConsoleLogService } from "@bitwarden/logging"; +import { UserId } from "@bitwarden/user-core"; + +import { MessageResponse } from "../../models/response/message.response"; +import { I18nService } from "../../platform/services/i18n.service"; +import { ConvertToKeyConnectorCommand } from "../convert-to-key-connector.command"; + +import { UnlockCommand } from "./unlock.command"; + +describe("UnlockCommand", () => { + let command: UnlockCommand; + + const accountService = mock(); + const masterPasswordService = mock(); + const keyService = mock(); + const userVerificationService = mock(); + const cryptoFunctionService = mock(); + const logService = mock(); + const keyConnectorService = mock(); + const environmentService = mock(); + const organizationApiService = mock(); + const logout = jest.fn(); + const i18nService = mock(); + const masterPasswordUnlockService = mock(); + const configService = mock(); + + const mockMasterPassword = "testExample"; + const activeAccount: Account = { + id: "user-id" as UserId, + email: "user@example.com", + emailVerified: true, + name: "User", + }; + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + const mockSessionKey = new Uint8Array(64) as CsprngArray; + const b64sessionKey = Utils.fromBufferToB64(mockSessionKey); + const expectedSuccessMessage = new MessageResponse( + "Your vault is now unlocked!", + "\n" + + "To unlock your vault, set your session key to the `BW_SESSION` environment variable. ex:\n" + + '$ export BW_SESSION="' + + b64sessionKey + + '"\n' + + '> $env:BW_SESSION="' + + b64sessionKey + + '"\n\n' + + "You can also pass the session key to any command with the `--session` option. ex:\n" + + "$ bw list items --session " + + b64sessionKey, + ); + expectedSuccessMessage.raw = b64sessionKey; + + // Legacy test data + const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey; + + beforeEach(async () => { + jest.clearAllMocks(); + + i18nService.t.mockImplementation((key: string) => key); + accountService.activeAccount$ = of(activeAccount); + keyConnectorService.convertAccountRequired$ = of(false); + cryptoFunctionService.randomBytes.mockResolvedValue(mockSessionKey); + + command = new UnlockCommand( + accountService, + masterPasswordService, + keyService, + userVerificationService, + cryptoFunctionService, + logService, + keyConnectorService, + environmentService, + organizationApiService, + logout, + i18nService, + masterPasswordUnlockService, + configService, + ); + }); + + describe("run", () => { + test.each([null as unknown as Account, undefined as unknown as Account])( + "returns error response when the active account is %s", + async (account) => { + accountService.activeAccount$ = of(account); + + const response = await command.run(mockMasterPassword, {}); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(false); + expect(response.message).toEqual("No active account found"); + expect(keyService.setUserKey).not.toHaveBeenCalled(); + }, + ); + + test.each([null as unknown as string, undefined as unknown as string, ""])( + "returns error response when the provided password is %s", + async (mockMasterPassword) => { + process.env.BW_NOINTERACTION = "true"; + + const response = await command.run(mockMasterPassword, {}); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(false); + expect(response.message).toEqual( + "Master password is required. Try again in interactive mode or provide a password file or environment variable.", + ); + expect(keyService.setUserKey).not.toHaveBeenCalled(); + }, + ); + + describe("UnlockWithMasterPasswordUnlockData feature flag enabled", () => { + beforeEach(() => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + }); + + it("calls masterPasswordUnlockService successfully", async () => { + masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey); + + const response = await command.run(mockMasterPassword, {}); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(true); + expect(response.data).toEqual(expectedSuccessMessage); + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); + }); + + it("returns error response if unlockWithMasterPassword fails", async () => { + masterPasswordUnlockService.unlockWithMasterPassword.mockRejectedValue( + new Error("Unlock failed"), + ); + + const response = await command.run(mockMasterPassword, {}); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(false); + expect(response.message).toEqual("Unlock failed"); + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + expect(keyService.setUserKey).not.toHaveBeenCalled(); + }); + }); + + describe("unlock with feature flag off", () => { + beforeEach(() => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + }); + + it("calls decryptUserKeyWithMasterKey successfully", async () => { + userVerificationService.verifyUserByMasterPassword.mockResolvedValue({ + masterKey: mockMasterKey, + } as MasterPasswordVerificationResponse); + masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey); + + const response = await command.run(mockMasterPassword, {}); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(true); + expect(response.data).toEqual(expectedSuccessMessage); + expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( + { + type: VerificationType.MasterPassword, + secret: mockMasterPassword, + }, + activeAccount.id, + activeAccount.email, + ); + expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( + mockMasterKey, + activeAccount.id, + ); + expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); + }); + + it("returns error response when verifyUserByMasterPassword throws", async () => { + userVerificationService.verifyUserByMasterPassword.mockRejectedValue( + new Error("Verification failed"), + ); + + const response = await command.run(mockMasterPassword, {}); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(false); + expect(response.message).toEqual("Verification failed"); + expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( + { + type: VerificationType.MasterPassword, + secret: mockMasterPassword, + }, + activeAccount.id, + activeAccount.email, + ); + expect(masterPasswordService.decryptUserKeyWithMasterKey).not.toHaveBeenCalled(); + expect(keyService.setUserKey).not.toHaveBeenCalled(); + }); + }); + + describe("calls convertToKeyConnectorCommand if required", () => { + let convertToKeyConnectorSpy: jest.SpyInstance; + beforeEach(() => { + keyConnectorService.convertAccountRequired$ = of(true); + + // Feature flag on + masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey); + + // Feature flag off + userVerificationService.verifyUserByMasterPassword.mockResolvedValue({ + masterKey: mockMasterKey, + } as MasterPasswordVerificationResponse); + masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey); + }); + + test.each([true, false])("returns failure when feature flag is %s", async (flagValue) => { + configService.getFeatureFlag$.mockReturnValue(of(flagValue)); + + // Mock the ConvertToKeyConnectorCommand + const mockRun = jest.fn().mockResolvedValue({ success: false, message: "convert failed" }); + convertToKeyConnectorSpy = jest + .spyOn(ConvertToKeyConnectorCommand.prototype, "run") + .mockImplementation(mockRun); + + const response = await command.run(mockMasterPassword, {}); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(false); + expect(response.message).toEqual("convert failed"); + expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); + expect(convertToKeyConnectorSpy).toHaveBeenCalled(); + + if (flagValue === true) { + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + } else { + expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( + { + type: VerificationType.MasterPassword, + secret: mockMasterPassword, + }, + activeAccount.id, + activeAccount.email, + ); + expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( + mockMasterKey, + activeAccount.id, + ); + } + }); + + test.each([true, false])( + "returns expected success when feature flag is %s", + async (flagValue) => { + configService.getFeatureFlag$.mockReturnValue(of(flagValue)); + + // Mock the ConvertToKeyConnectorCommand + const mockRun = jest.fn().mockResolvedValue({ success: true }); + const convertToKeyConnectorSpy = jest + .spyOn(ConvertToKeyConnectorCommand.prototype, "run") + .mockImplementation(mockRun); + + const response = await command.run(mockMasterPassword, {}); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(true); + expect(response.data).toEqual(expectedSuccessMessage); + expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); + expect(convertToKeyConnectorSpy).toHaveBeenCalled(); + + if (flagValue === true) { + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + } else { + expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( + { + type: VerificationType.MasterPassword, + secret: mockMasterPassword, + }, + activeAccount.id, + activeAccount.email, + ); + expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( + mockMasterKey, + activeAccount.id, + ); + } + }, + ); + }); + }); +}); diff --git a/apps/cli/src/auth/commands/unlock.command.ts b/apps/cli/src/key-management/commands/unlock.command.ts similarity index 69% rename from apps/cli/src/auth/commands/unlock.command.ts rename to apps/cli/src/key-management/commands/unlock.command.ts index 812a89ed889..4ae8ce823a4 100644 --- a/apps/cli/src/auth/commands/unlock.command.ts +++ b/apps/cli/src/key-management/commands/unlock.command.ts @@ -1,26 +1,29 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom, map } from "rxjs"; +import { firstValueFrom } from "rxjs"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import { MasterKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; -import { ConvertToKeyConnectorCommand } from "../../key-management/convert-to-key-connector.command"; import { Response } from "../../models/response"; import { MessageResponse } from "../../models/response/message.response"; import { I18nService } from "../../platform/services/i18n.service"; import { CliUtils } from "../../utils"; +import { ConvertToKeyConnectorCommand } from "../convert-to-key-connector.command"; export class UnlockCommand { constructor( @@ -35,6 +38,8 @@ export class UnlockCommand { private organizationApiService: OrganizationApiServiceAbstraction, private logout: () => Promise, private i18nService: I18nService, + private masterPasswordUnlockService: MasterPasswordUnlockService, + private configService: ConfigService, ) {} async run(password: string, cmdOptions: Record) { @@ -48,30 +53,53 @@ export class UnlockCommand { } await this.setNewSessionKey(); - const [userId, email] = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])), - ); - - const verification = { - type: VerificationType.MasterPassword, - secret: password, - } as MasterPasswordVerification; - - let masterKey: MasterKey; - try { - const response = await this.userVerificationService.verifyUserByMasterPassword( - verification, - userId, - email, - ); - masterKey = response.masterKey; - } catch (e) { - // verification failure throws - return Response.error(e.message); + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + if (activeAccount == null) { + return Response.error("No active account found"); } + const userId = activeAccount.id; - const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey, userId); - await this.keyService.setUserKey(userKey, userId); + if ( + await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.UnlockWithMasterPasswordUnlockData), + ) + ) { + try { + const userKey = await this.masterPasswordUnlockService.unlockWithMasterPassword( + password, + userId, + ); + + await this.keyService.setUserKey(userKey, userId); + } catch (e) { + return Response.error(e.message); + } + } else { + const email = activeAccount.email; + const verification = { + type: VerificationType.MasterPassword, + secret: password, + } as MasterPasswordVerification; + + let masterKey: MasterKey; + try { + const response = await this.userVerificationService.verifyUserByMasterPassword( + verification, + userId, + email, + ); + masterKey = response.masterKey; + } catch (e) { + // verification failure throws + return Response.error(e.message); + } + + const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( + masterKey, + userId, + ); + await this.keyService.setUserKey(userKey, userId); + } if (await firstValueFrom(this.keyConnectorService.convertAccountRequired$)) { const convertToKeyConnectorCommand = new ConvertToKeyConnectorCommand( diff --git a/apps/cli/src/locales/en/messages.json b/apps/cli/src/locales/en/messages.json index 4a8c774ea42..18079bd2409 100644 --- a/apps/cli/src/locales/en/messages.json +++ b/apps/cli/src/locales/en/messages.json @@ -41,6 +41,15 @@ "invalidMasterPassword": { "message": "Invalid master password." }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "sessionTimeout": { "message": "Your session has timed out. Please go back and try logging in again." }, diff --git a/apps/cli/src/oss-serve-configurator.ts b/apps/cli/src/oss-serve-configurator.ts index 6ae2776eae7..bd51cf4dd91 100644 --- a/apps/cli/src/oss-serve-configurator.ts +++ b/apps/cli/src/oss-serve-configurator.ts @@ -5,15 +5,17 @@ import * as koaRouter from "@koa/router"; import * as koa from "koa"; import { firstValueFrom, map } from "rxjs"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; + import { ConfirmCommand } from "./admin-console/commands/confirm.command"; import { ShareCommand } from "./admin-console/commands/share.command"; import { LockCommand } from "./auth/commands/lock.command"; -import { UnlockCommand } from "./auth/commands/unlock.command"; import { EditCommand } from "./commands/edit.command"; import { GetCommand } from "./commands/get.command"; import { ListCommand } from "./commands/list.command"; import { RestoreCommand } from "./commands/restore.command"; import { StatusCommand } from "./commands/status.command"; +import { UnlockCommand } from "./key-management/commands/unlock.command"; import { Response } from "./models/response"; import { FileResponse } from "./models/response/file.response"; import { ServiceContainer } from "./service-container/service-container"; @@ -26,6 +28,7 @@ import { SendListCommand, SendRemovePasswordCommand, } from "./tools/send"; +import { ArchiveCommand } from "./vault/archive.command"; import { CreateCommand } from "./vault/create.command"; import { DeleteCommand } from "./vault/delete.command"; import { SyncCommand } from "./vault/sync.command"; @@ -40,6 +43,7 @@ export class OssServeConfigurator { private statusCommand: StatusCommand; private syncCommand: SyncCommand; private deleteCommand: DeleteCommand; + private archiveCommand: ArchiveCommand; private confirmCommand: ConfirmCommand; private restoreCommand: RestoreCommand; private lockCommand: LockCommand; @@ -81,6 +85,7 @@ export class OssServeConfigurator { this.serviceContainer.accountService, this.serviceContainer.keyService, this.serviceContainer.cliRestrictedItemTypesService, + this.serviceContainer.cipherArchiveService, ); this.createCommand = new CreateCommand( this.serviceContainer.cipherService, @@ -104,6 +109,7 @@ export class OssServeConfigurator { this.serviceContainer.accountService, this.serviceContainer.cliRestrictedItemTypesService, this.serviceContainer.policyService, + this.serviceContainer.billingAccountProfileStateService, ); this.generateCommand = new GenerateCommand( this.serviceContainer.passwordGenerationService, @@ -127,6 +133,13 @@ export class OssServeConfigurator { this.serviceContainer.accountService, this.serviceContainer.cliRestrictedItemTypesService, ); + this.archiveCommand = new ArchiveCommand( + this.serviceContainer.cipherService, + this.serviceContainer.accountService, + this.serviceContainer.configService, + this.serviceContainer.cipherArchiveService, + this.serviceContainer.billingAccountProfileStateService, + ); this.confirmCommand = new ConfirmCommand( this.serviceContainer.apiService, this.serviceContainer.keyService, @@ -140,12 +153,17 @@ export class OssServeConfigurator { this.serviceContainer.cipherService, this.serviceContainer.accountService, this.serviceContainer.cipherAuthorizationService, + this.serviceContainer.cipherArchiveService, + this.serviceContainer.configService, ); this.shareCommand = new ShareCommand( this.serviceContainer.cipherService, this.serviceContainer.accountService, ); - this.lockCommand = new LockCommand(this.serviceContainer.vaultTimeoutService); + this.lockCommand = new LockCommand( + serviceContainer.lockService, + serviceContainer.accountService, + ); this.unlockCommand = new UnlockCommand( this.serviceContainer.accountService, this.serviceContainer.masterPasswordService, @@ -158,6 +176,8 @@ export class OssServeConfigurator { this.serviceContainer.organizationApiService, async () => await this.serviceContainer.logout(), this.serviceContainer.i18nService, + this.serviceContainer.masterPasswordUnlockService, + this.serviceContainer.configService, ); this.sendCreateCommand = new SendCreateCommand( @@ -196,10 +216,11 @@ export class OssServeConfigurator { this.serviceContainer.sendService, this.serviceContainer.sendApiService, this.serviceContainer.environmentService, + this.serviceContainer.accountService, ); } - configureRouter(router: koaRouter) { + async configureRouter(router: koaRouter) { router.get("/generate", async (ctx, next) => { const response = await this.generateCommand.run(ctx.request.query); this.processResponse(ctx.response, response); @@ -401,6 +422,23 @@ export class OssServeConfigurator { this.processResponse(ctx.response, response); await next(); }); + + const isArchivedEnabled = await this.serviceContainer.configService.getFeatureFlag( + FeatureFlag.PM19148_InnovationArchive, + ); + + if (isArchivedEnabled) { + router.post("/archive/:object/:id", async (ctx, next) => { + if (await this.errorIfLocked(ctx.response)) { + await next(); + return; + } + let response: Response = null; + response = await this.archiveCommand.run(ctx.params.object, ctx.params.id); + this.processResponse(ctx.response, response); + await next(); + }); + } } protected processResponse(res: koa.Response, commandResponse: Response) { diff --git a/apps/cli/src/platform/services/cli-platform-utils.service.ts b/apps/cli/src/platform/services/cli-platform-utils.service.ts index 27f8e5268bd..acd2009a6d9 100644 --- a/apps/cli/src/platform/services/cli-platform-utils.service.ts +++ b/apps/cli/src/platform/services/cli-platform-utils.service.ts @@ -152,4 +152,8 @@ export class CliPlatformUtilsService implements PlatformUtilsService { getAutofillKeyboardShortcut(): Promise { return null; } + + async packageType(): Promise { + return null; + } } diff --git a/apps/cli/src/platform/services/cli-system.service.ts b/apps/cli/src/platform/services/cli-system.service.ts new file mode 100644 index 00000000000..5f647a0f88c --- /dev/null +++ b/apps/cli/src/platform/services/cli-system.service.ts @@ -0,0 +1,10 @@ +import { SystemService } from "@bitwarden/common/platform/abstractions/system.service"; + +/** + * CLI implementation of SystemService. + * The implementation is NOOP since these functions are meant for GUI clients. + */ +export class CliSystemService extends SystemService { + async clearClipboard(clipboardValue: string, timeoutMs?: number): Promise {} + async clearPendingClipboard(): Promise {} +} diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 4d541739aab..a5f12b34035 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -5,16 +5,17 @@ import { program, Command, OptionValues } from "commander"; import { firstValueFrom, of, switchMap } from "rxjs"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { LockCommand } from "./auth/commands/lock.command"; import { LoginCommand } from "./auth/commands/login.command"; import { LogoutCommand } from "./auth/commands/logout.command"; -import { UnlockCommand } from "./auth/commands/unlock.command"; import { BaseProgram } from "./base-program"; import { CompletionCommand } from "./commands/completion.command"; import { EncodeCommand } from "./commands/encode.command"; import { StatusCommand } from "./commands/status.command"; import { UpdateCommand } from "./commands/update.command"; +import { UnlockCommand } from "./key-management/commands/unlock.command"; import { Response } from "./models/response"; import { MessageResponse } from "./models/response/message.response"; import { ConfigCommand } from "./platform/commands/config.command"; @@ -26,6 +27,10 @@ const writeLn = CliUtils.writeLn; export class Program extends BaseProgram { async register() { + const isArchivedEnabled = await this.serviceContainer.configService.getFeatureFlag( + FeatureFlag.PM19148_InnovationArchive, + ); + program .option("--pretty", "Format output. JSON is tabbed with two spaces.") .option("--raw", "Return raw output instead of a descriptive message.") @@ -94,6 +99,9 @@ export class Program extends BaseProgram { " bw edit folder c7c7b60b-9c61-40f2-8ccd-36c49595ed72 eyJuYW1lIjoiTXkgRm9sZGVyMiJ9Cg==", ); writeLn(" bw delete item 99ee88d2-6046-4ea7-92c2-acac464b1412"); + if (isArchivedEnabled) { + writeLn(" bw archive item 99ee88d2-6046-4ea7-92c2-acac464b1412"); + } writeLn(" bw generate -lusn --length 18"); writeLn(" bw config server https://bitwarden.example.com"); writeLn(" bw send -f ./file.ext"); @@ -167,7 +175,7 @@ export class Program extends BaseProgram { const command = new LoginCommand( this.serviceContainer.loginStrategyService, this.serviceContainer.authService, - this.serviceContainer.apiService, + this.serviceContainer.twoFactorApiService, this.serviceContainer.masterPasswordApiService, this.serviceContainer.cryptoFunctionService, this.serviceContainer.environmentService, @@ -242,7 +250,10 @@ export class Program extends BaseProgram { return; } - const command = new LockCommand(this.serviceContainer.vaultTimeoutService); + const command = new LockCommand( + this.serviceContainer.lockService, + this.serviceContainer.accountService, + ); const response = await command.run(); this.processResponse(response); }); @@ -295,6 +306,8 @@ export class Program extends BaseProgram { this.serviceContainer.organizationApiService, async () => await this.serviceContainer.logout(), this.serviceContainer.i18nService, + this.serviceContainer.masterPasswordUnlockService, + this.serviceContainer.configService, ); const response = await command.run(password, cmd); this.processResponse(response); diff --git a/apps/cli/src/register-oss-programs.ts b/apps/cli/src/register-oss-programs.ts index 1fc1f0119d2..71d7aaa0d52 100644 --- a/apps/cli/src/register-oss-programs.ts +++ b/apps/cli/src/register-oss-programs.ts @@ -15,7 +15,7 @@ export async function registerOssPrograms(serviceContainer: ServiceContainer) { await program.register(); const vaultProgram = new VaultProgram(serviceContainer); - vaultProgram.register(); + await vaultProgram.register(); const sendProgram = new SendProgram(serviceContainer); sendProgram.register(); diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 7b148b2a3d5..122dd6ea052 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -20,6 +20,9 @@ import { SsoUrlService, AuthRequestApiServiceAbstraction, DefaultAuthRequestApiService, + DefaultLockService, + DefaultLogoutService, + LockService, } from "@bitwarden/auth/common"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service"; @@ -46,9 +49,14 @@ import { DefaultActiveUserAccessor } from "@bitwarden/common/auth/services/defau import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { MasterPasswordApiService } from "@bitwarden/common/auth/services/master-password/master-password-api.service.implementation"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; -import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service"; import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service"; import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; +import { + DefaultTwoFactorService, + TwoFactorService, + TwoFactorApiService, + DefaultTwoFactorApiService, +} from "@bitwarden/common/auth/two-factor"; import { AutofillSettingsService, AutofillSettingsServiceAbstraction, @@ -69,10 +77,15 @@ import { EncryptServiceImplementation } from "@bitwarden/common/key-management/c import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { DefaultMasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/services/default-master-password-unlock.service"; import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; +import { PinStateService } from "@bitwarden/common/key-management/pin/pin-state.service.implementation"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service"; import { DefaultVaultTimeoutService, DefaultVaultTimeoutSettingsService, @@ -88,7 +101,7 @@ import { } from "@bitwarden/common/platform/abstractions/environment.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; -import { KeySuffixOptions, LogLevelType } from "@bitwarden/common/platform/enums"; +import { LogLevelType } from "@bitwarden/common/platform/enums"; import { MessageSender } from "@bitwarden/common/platform/messaging"; import { TaskSchedulerService, @@ -125,6 +138,7 @@ import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.s import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; 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 { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { @@ -132,6 +146,7 @@ import { DefaultCipherAuthorizationService, } from "@bitwarden/common/vault/services/cipher-authorization.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"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; @@ -144,8 +159,10 @@ import { PasswordGenerationServiceAbstraction, } from "@bitwarden/generator-legacy"; import { + DefaultImportMetadataService, ImportApiService, ImportApiServiceAbstraction, + ImportMetadataServiceAbstraction, ImportService, ImportServiceAbstraction, } from "@bitwarden/importer-core"; @@ -189,9 +206,11 @@ import { } from "@bitwarden/vault-export-core"; import { CliBiometricsService } from "../key-management/cli-biometrics-service"; +import { CliProcessReloadService } from "../key-management/cli-process-reload.service"; import { flagEnabled } from "../platform/flags"; import { CliPlatformUtilsService } from "../platform/services/cli-platform-utils.service"; import { CliSdkLoadService } from "../platform/services/cli-sdk-load.service"; +import { CliSystemService } from "../platform/services/cli-system.service"; import { ConsoleLogService } from "../platform/services/console-log.service"; import { I18nService } from "../platform/services/i18n.service"; import { LowdbStorageService } from "../platform/services/lowdb-storage.service"; @@ -224,6 +243,7 @@ export class ServiceContainer { tokenService: TokenService; appIdService: AppIdService; apiService: NodeApiService; + twoFactorApiService: TwoFactorApiService; hibpApiService: HibpApiService; environmentService: EnvironmentService; cipherService: CipherService; @@ -244,6 +264,7 @@ export class ServiceContainer { auditService: AuditService; importService: ImportServiceAbstraction; importApiService: ImportApiServiceAbstraction; + importMetadataService: ImportMetadataServiceAbstraction; exportService: VaultExportServiceAbstraction; vaultExportApiService: VaultExportApiService; individualExportService: IndividualVaultExportServiceAbstraction; @@ -303,6 +324,10 @@ export class ServiceContainer { cipherEncryptionService: CipherEncryptionService; restrictedItemTypesService: RestrictedItemTypesService; cliRestrictedItemTypesService: CliRestrictedItemTypesService; + securityStateService: SecurityStateService; + masterPasswordUnlockService: MasterPasswordUnlockService; + cipherArchiveService: CipherArchiveService; + lockService: LockService; constructor() { let p = null; @@ -403,6 +428,8 @@ export class ServiceContainer { this.derivedStateProvider, ); + this.securityStateService = new DefaultSecurityStateService(this.stateProvider); + this.environmentService = new DefaultEnvironmentService( this.stateProvider, this.accountService, @@ -438,26 +465,13 @@ export class ServiceContainer { this.kdfConfigService = new DefaultKdfConfigService(this.stateProvider); this.masterPasswordService = new MasterPasswordService( this.stateProvider, - this.stateService, this.keyGenerationService, - this.encryptService, this.logService, this.cryptoFunctionService, this.accountService, ); - this.pinService = new PinService( - this.accountService, - this.cryptoFunctionService, - this.encryptService, - this.kdfConfigService, - this.keyGenerationService, - this.logService, - this.stateProvider, - ); - this.keyService = new KeyService( - this.pinService, this.masterPasswordService, this.keyGenerationService, this.cryptoFunctionService, @@ -470,6 +484,24 @@ export class ServiceContainer { this.kdfConfigService, ); + const pinStateService = new PinStateService(this.stateProvider); + this.pinService = new PinService( + this.accountService, + this.encryptService, + this.kdfConfigService, + this.keyGenerationService, + this.logService, + this.keyService, + this.sdkService, + pinStateService, + ); + + this.masterPasswordUnlockService = new DefaultMasterPasswordUnlockService( + this.masterPasswordService, + this.keyService, + this.logService, + ); + this.appIdService = new AppIdService(this.storageService, this.logService); const customUserAgent = @@ -480,7 +512,9 @@ export class ServiceContainer { ")"; this.biometricStateService = new DefaultBiometricStateService(this.stateProvider); - this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider); + this.userDecryptionOptionsService = new UserDecryptionOptionsService( + this.singleUserStateProvider, + ); this.ssoUrlService = new SsoUrlService(); this.organizationService = new DefaultOrganizationService(this.stateProvider); @@ -488,7 +522,7 @@ export class ServiceContainer { this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService( this.accountService, - this.pinService, + pinStateService, this.userDecryptionOptionsService, this.keyService, this.tokenService, @@ -520,6 +554,8 @@ export class ServiceContainer { this.configApiService = new ConfigApiService(this.apiService); + this.twoFactorApiService = new DefaultTwoFactorApiService(this.apiService); + this.authService = new AuthService( this.accountService, this.messagingService, @@ -537,13 +573,18 @@ export class ServiceContainer { this.authService, ); - this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); + this.domainSettingsService = new DefaultDomainSettingsService( + this.stateProvider, + this.policyService, + this.accountService, + ); this.fileUploadService = new FileUploadService(this.logService, this.apiService); this.sendStateProvider = new SendStateProvider(this.stateProvider); this.sendService = new SendService( + this.accountService, this.keyService, this.i18nService, this.keyGenerationService, @@ -592,10 +633,11 @@ export class ServiceContainer { this.stateProvider, ); - this.twoFactorService = new TwoFactorService( + this.twoFactorService = new DefaultTwoFactorService( this.i18nService, this.platformUtilsService, this.globalStateProvider, + this.twoFactorApiService, ); const sdkClientFactory = flagEnabled("sdk") @@ -609,6 +651,8 @@ export class ServiceContainer { this.accountService, this.kdfConfigService, this.keyService, + this.securityStateService, + this.apiService, this.stateProvider, this.configService, customUserAgent, @@ -634,6 +678,7 @@ export class ServiceContainer { this.apiService, this.stateProvider, this.authRequestApiService, + this.accountService, ); this.billingAccountProfileStateService = new DefaultBillingAccountProfileStateService( @@ -659,6 +704,7 @@ export class ServiceContainer { this.userDecryptionOptionsService, this.logService, this.configService, + this.accountService, ); this.loginStrategyService = new LoginStrategyService( @@ -730,6 +776,13 @@ export class ServiceContainer { this.messagingService, ); + this.cipherArchiveService = new DefaultCipherArchiveService( + this.cipherService, + this.apiService, + this.billingAccountProfileStateService, + this.configService, + ); + this.folderService = new FolderService( this.keyService, this.encryptService, @@ -740,9 +793,6 @@ export class ServiceContainer { this.folderApiService = new FolderApiService(this.folderService, this.apiService); - const lockedCallback = async (userId: UserId) => - await this.keyService.clearStoredUserKey(KeySuffixOptions.Auto, userId); - this.userVerificationApiService = new UserVerificationApiService(this.apiService); this.userVerificationService = new UserVerificationService( @@ -758,25 +808,35 @@ export class ServiceContainer { ); const biometricService = new CliBiometricsService(); + const logoutService = new DefaultLogoutService(this.messagingService); + const processReloadService = new CliProcessReloadService(); + const systemService = new CliSystemService(); + this.lockService = new DefaultLockService( + this.accountService, + biometricService, + this.vaultTimeoutSettingsService, + logoutService, + this.messagingService, + this.searchService, + this.folderService, + this.masterPasswordService, + this.stateEventRunnerService, + this.cipherService, + this.authService, + systemService, + processReloadService, + this.logService, + this.keyService, + ); this.vaultTimeoutService = new DefaultVaultTimeoutService( this.accountService, - this.masterPasswordService, - this.cipherService, - this.folderService, - this.collectionService, this.platformUtilsService, - this.messagingService, - this.searchService, - this.stateService, - this.tokenService, this.authService, this.vaultTimeoutSettingsService, - this.stateEventRunnerService, this.taskSchedulerService, this.logService, - biometricService, - lockedCallback, + this.lockService, undefined, ); @@ -807,12 +867,26 @@ export class ServiceContainer { this.tokenService, this.authService, this.stateProvider, + this.securityStateService, + this.kdfConfigService, ); this.totpService = new TotpService(this.sdkService); this.importApiService = new ImportApiService(this.apiService); + this.importMetadataService = new DefaultImportMetadataService( + createSystemServiceProvider( + new KeyServiceLegacyEncryptorProvider(this.encryptService, this.keyService), + this.stateProvider, + this.policyService, + buildExtensionRegistry(), + this.logService, + this.platformUtilsService, + this.configService, + ), + ); + this.importService = new ImportService( this.cipherService, this.folderService, @@ -824,15 +898,6 @@ export class ServiceContainer { this.pinService, this.accountService, this.restrictedItemTypesService, - createSystemServiceProvider( - new KeyServiceLegacyEncryptorProvider(this.encryptService, this.keyService), - this.stateProvider, - this.policyService, - buildExtensionRegistry(), - this.logService, - this.platformUtilsService, - this.configService, - ), ); this.individualExportService = new IndividualVaultExportService( @@ -917,7 +982,6 @@ export class ServiceContainer { this.eventUploadService.uploadEvents(userId as UserId), this.keyService.clearKeys(userId), this.cipherService.clear(userId), - // ! DO NOT REMOVE folderService.clear ! For more information see PM-25660 this.folderService.clear(userId), ]); @@ -943,6 +1007,7 @@ 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.ts b/apps/cli/src/tools/send/commands/create.command.ts index d4f544d39b7..7803f6f94d4 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -6,6 +6,7 @@ import * as path from "path"; import { firstValueFrom, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; @@ -142,7 +143,8 @@ export class SendCreateCommand { await this.sendApiService.save([encSend, fileData]); const newSend = await this.sendService.getFromState(encSend.id); - const decSend = await newSend.decrypt(); + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const decSend = await newSend.decrypt(activeUserId); const env = await firstValueFrom(this.environmentService.environment$); const res = new SendResponse(decSend, env.getWebVaultUrl()); return Response.success(res); diff --git a/apps/cli/src/tools/send/commands/edit.command.ts b/apps/cli/src/tools/send/commands/edit.command.ts index 09f89041cc5..bf53c8a5cb9 100644 --- a/apps/cli/src/tools/send/commands/edit.command.ts +++ b/apps/cli/src/tools/send/commands/edit.command.ts @@ -3,6 +3,7 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; @@ -83,7 +84,8 @@ export class SendEditCommand { return Response.error("Premium status is required to use this feature."); } - let sendView = await send.decrypt(); + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + let sendView = await send.decrypt(activeUserId); sendView = SendResponse.toView(req, sendView); try { diff --git a/apps/cli/src/tools/send/commands/get.command.ts b/apps/cli/src/tools/send/commands/get.command.ts index 2d6cc93c781..d5248733490 100644 --- a/apps/cli/src/tools/send/commands/get.command.ts +++ b/apps/cli/src/tools/send/commands/get.command.ts @@ -12,6 +12,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; +import { isGuid } from "@bitwarden/guid"; import { DownloadCommand } from "../../../commands/download.command"; import { Response } from "../../../models/response"; @@ -74,13 +75,13 @@ export class SendGetCommand extends DownloadCommand { } private async getSendView(id: string): Promise { - if (Utils.isGuid(id)) { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + if (isGuid(id)) { const send = await this.sendService.getFromState(id); if (send != null) { - return await send.decrypt(); + return await send.decrypt(activeUserId); } } else if (id.trim() !== "") { - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); let sends = await this.sendService.getAllDecryptedFromState(activeUserId); sends = this.searchService.searchSends(sends, id); if (sends.length > 1) { diff --git a/apps/cli/src/tools/send/commands/remove-password.command.ts b/apps/cli/src/tools/send/commands/remove-password.command.ts index 4f7add366be..74676d84a77 100644 --- a/apps/cli/src/tools/send/commands/remove-password.command.ts +++ b/apps/cli/src/tools/send/commands/remove-password.command.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { firstValueFrom } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { SendService } from "@bitwarden/common/tools/send/services//send.service.abstraction"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; @@ -14,6 +16,7 @@ export class SendRemovePasswordCommand { private sendService: SendService, private sendApiService: SendApiService, private environmentService: EnvironmentService, + private accountService: AccountService, ) {} async run(id: string) { @@ -21,7 +24,8 @@ export class SendRemovePasswordCommand { await this.sendApiService.removePassword(id); const updatedSend = await firstValueFrom(this.sendService.get$(id)); - const decSend = await updatedSend.decrypt(); + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const decSend = await updatedSend.decrypt(activeUserId); const env = await firstValueFrom(this.environmentService.environment$); const webVaultUrl = env.getWebVaultUrl(); const res = new SendResponse(decSend, webVaultUrl); diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index 2ea73f8c5c8..33bf4518ccd 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -297,6 +297,7 @@ export class SendProgram extends BaseProgram { this.serviceContainer.sendService, this.serviceContainer.sendApiService, this.serviceContainer.environmentService, + this.serviceContainer.accountService, ); const response = await cmd.run(id); this.processResponse(response); @@ -307,7 +308,7 @@ export class SendProgram extends BaseProgram { let sendFile = null; let sendText = null; let name = Utils.newGuid(); - let type = SendType.Text; + let type: SendType = SendType.Text; if (options.file != null) { data = path.resolve(data); if (!fs.existsSync(data)) { diff --git a/apps/cli/src/vault.program.ts b/apps/cli/src/vault.program.ts index 5b35f6b0499..21f87feab00 100644 --- a/apps/cli/src/vault.program.ts +++ b/apps/cli/src/vault.program.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { program, Command } from "commander"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; + import { ConfirmCommand } from "./admin-console/commands/confirm.command"; import { ShareCommand } from "./admin-console/commands/share.command"; import { BaseProgram } from "./base-program"; @@ -13,25 +15,34 @@ import { Response } from "./models/response"; import { ExportCommand } from "./tools/export.command"; import { ImportCommand } from "./tools/import.command"; import { CliUtils } from "./utils"; +import { ArchiveCommand } from "./vault/archive.command"; import { CreateCommand } from "./vault/create.command"; import { DeleteCommand } from "./vault/delete.command"; const writeLn = CliUtils.writeLn; export class VaultProgram extends BaseProgram { - register() { + async register() { + const isArchivedEnabled = await this.serviceContainer.configService.getFeatureFlag( + FeatureFlag.PM19148_InnovationArchive, + ); + program - .addCommand(this.listCommand()) + .addCommand(this.listCommand(isArchivedEnabled)) .addCommand(this.getCommand()) .addCommand(this.createCommand()) .addCommand(this.editCommand()) .addCommand(this.deleteCommand()) - .addCommand(this.restoreCommand()) + .addCommand(this.restoreCommand(isArchivedEnabled)) .addCommand(this.shareCommand("move", false)) .addCommand(this.confirmCommand()) .addCommand(this.importCommand()) .addCommand(this.exportCommand()) .addCommand(this.shareCommand("share", true)); + + if (isArchivedEnabled) { + program.addCommand(this.archiveCommand()); + } } private validateObject(requestedObject: string, validObjects: string[]): boolean { @@ -42,7 +53,7 @@ export class VaultProgram extends BaseProgram { Response.badRequest( 'Unknown object "' + requestedObject + - '". Allowed objects are ' + + '". Allowed objects are: ' + validObjects.join(", ") + ".", ), @@ -51,7 +62,7 @@ export class VaultProgram extends BaseProgram { return success; } - private listCommand(): Command { + private listCommand(isArchivedEnabled: boolean): Command { const listObjects = [ "items", "folders", @@ -61,7 +72,7 @@ export class VaultProgram extends BaseProgram { "organizations", ]; - return new Command("list") + const command = new Command("list") .argument("", "Valid objects are: " + listObjects.join(", ")) .description("List an array of objects from the vault.") .option("--search ", "Perform a search on the listed objects.") @@ -94,6 +105,9 @@ export class VaultProgram extends BaseProgram { " bw list items --folderid 60556c31-e649-4b5d-8daf-fc1c391a1bf2 --organizationid notnull", ); writeLn(" bw list items --trash"); + if (isArchivedEnabled) { + writeLn(" bw list items --archived"); + } writeLn(" bw list folders --search email"); writeLn(" bw list org-members --organizationid 60556c31-e649-4b5d-8daf-fc1c391a1bf2"); writeLn("", true); @@ -116,11 +130,18 @@ export class VaultProgram extends BaseProgram { this.serviceContainer.accountService, this.serviceContainer.keyService, this.serviceContainer.cliRestrictedItemTypesService, + this.serviceContainer.cipherArchiveService, ); const response = await command.run(object, cmd); this.processResponse(response); }); + + if (isArchivedEnabled) { + command.option("--archived", "Filter items that are archived."); + } + + return command; } private getCommand(): Command { @@ -286,6 +307,7 @@ export class VaultProgram extends BaseProgram { this.serviceContainer.accountService, this.serviceContainer.cliRestrictedItemTypesService, this.serviceContainer.policyService, + this.serviceContainer.billingAccountProfileStateService, ); const response = await command.run(object, id, encodedJson, cmd); this.processResponse(response); @@ -336,12 +358,41 @@ export class VaultProgram extends BaseProgram { }); } - private restoreCommand(): Command { + private archiveCommand(): Command { + const archiveObjects = ["item"]; + return new Command("archive") + .argument("", "Valid objects are: " + archiveObjects.join(", ")) + .argument("", "Object's globally unique `id`.") + .description("Archive an object from the vault.") + .on("--help", () => { + writeLn("\n Examples:"); + writeLn(""); + writeLn(" bw archive item 7063feab-4b10-472e-b64c-785e2b870b92"); + writeLn("", true); + }) + .action(async (object, id) => { + if (!this.validateObject(object, archiveObjects)) { + return; + } + + await this.exitIfLocked(); + const command = new ArchiveCommand( + this.serviceContainer.cipherService, + this.serviceContainer.accountService, + this.serviceContainer.configService, + this.serviceContainer.cipherArchiveService, + this.serviceContainer.billingAccountProfileStateService, + ); + const response = await command.run(object, id); + this.processResponse(response); + }); + } + + private restoreCommand(isArchivedEnabled: boolean): Command { const restoreObjects = ["item"]; - return new Command("restore") + const command = new Command("restore") .argument("", "Valid objects are: " + restoreObjects.join(", ")) .argument("", "Object's globally unique `id`.") - .description("Restores an object from the trash.") .on("--help", () => { writeLn("\n Examples:"); writeLn(""); @@ -358,10 +409,20 @@ export class VaultProgram extends BaseProgram { this.serviceContainer.cipherService, this.serviceContainer.accountService, this.serviceContainer.cipherAuthorizationService, + this.serviceContainer.cipherArchiveService, + this.serviceContainer.configService, ); const response = await command.run(object, id); this.processResponse(response); }); + + if (isArchivedEnabled) { + command.description("Restores an object from the trash or archive."); + } else { + command.description("Restores an object from the trash."); + } + + return command; } private shareCommand(commandName: string, deprecated: boolean): Command { diff --git a/apps/cli/src/vault/archive.command.ts b/apps/cli/src/vault/archive.command.ts new file mode 100644 index 00000000000..5ced2282c6d --- /dev/null +++ b/apps/cli/src/vault/archive.command.ts @@ -0,0 +1,109 @@ +import { firstValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherViewLikeUtils } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { UserId } from "@bitwarden/user-core"; + +import { Response } from "../models/response"; + +export class ArchiveCommand { + constructor( + private cipherService: CipherService, + private accountService: AccountService, + private configService: ConfigService, + private cipherArchiveService: CipherArchiveService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + ) {} + + async run(object: string, id: string): Promise { + const featureFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM19148_InnovationArchive, + ); + + if (!featureFlagEnabled) { + return Response.notFound(); + } + + if (id != null) { + id = id.toLowerCase(); + } + + const normalizedObject = object.toLowerCase(); + + if (normalizedObject === "item") { + return this.archiveCipher(id); + } + + return Response.badRequest("Unknown object."); + } + + private async archiveCipher(cipherId: string) { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const cipher = await this.cipherService.get(cipherId, activeUserId); + + if (cipher == null) { + return Response.notFound(); + } + + const cipherView = await this.cipherService.decrypt(cipher, activeUserId); + + const { canArchive, errorMessage } = await this.userCanArchiveCipher(cipherView, activeUserId); + + if (!canArchive) { + return Response.error(errorMessage); + } + + try { + await this.cipherArchiveService.archiveWithServer(cipherView.id as CipherId, activeUserId); + return Response.success(); + } catch (e) { + return Response.error(e); + } + } + + /** + * Determines if the user can archive the given cipher. + * When the user cannot archive the cipher, an appropriate error message is provided. + */ + private async userCanArchiveCipher( + cipher: CipherView, + userId: UserId, + ): Promise< + { canArchive: true; errorMessage?: never } | { canArchive: false; errorMessage: string } + > { + const hasPremiumFromAnySource = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId), + ); + + switch (true) { + case !hasPremiumFromAnySource: { + return { + canArchive: false, + errorMessage: "Premium status is required to use this feature.", + }; + } + case CipherViewLikeUtils.isArchived(cipher): { + return { canArchive: false, errorMessage: "Item is already archived." }; + } + case CipherViewLikeUtils.isDeleted(cipher): { + return { + canArchive: false, + errorMessage: "Item is in the trash, the item must be restored before archiving.", + }; + } + case cipher.organizationId != null: { + return { canArchive: false, errorMessage: "Cannot archive items in an organization." }; + } + default: + return { canArchive: true }; + } + } +} diff --git a/apps/cli/src/vault/create.command.ts b/apps/cli/src/vault/create.command.ts index 0892bb42214..5602c593942 100644 --- a/apps/cli/src/vault/create.command.ts +++ b/apps/cli/src/vault/create.command.ts @@ -20,6 +20,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { Folder } from "@bitwarden/common/vault/models/domain/folder"; import { KeyService } from "@bitwarden/key-management"; import { OrganizationCollectionRequest } from "../admin-console/models/request/organization-collection.request"; @@ -91,18 +92,18 @@ export class CreateCommand { } private async createCipher(req: CipherExport) { - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - - const cipherView = CipherExport.toView(req); - const isCipherTypeRestricted = - await this.cliRestrictedItemTypesService.isCipherRestricted(cipherView); - - if (isCipherTypeRestricted) { - return Response.error("Creating this item type is restricted by organizational policy."); - } - - const cipher = await this.cipherService.encrypt(CipherExport.toView(req), activeUserId); try { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + const cipherView = CipherExport.toView(req); + const isCipherTypeRestricted = + await this.cliRestrictedItemTypesService.isCipherRestricted(cipherView); + + if (isCipherTypeRestricted) { + 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); @@ -183,8 +184,8 @@ export class CreateCommand { const userKey = await this.keyService.getUserKey(activeUserId); const folder = await this.folderService.encrypt(FolderExport.toView(req), userKey); try { - await this.folderApiService.save(folder, activeUserId); - const newFolder = await this.folderService.get(folder.id, activeUserId); + const folderData = await this.folderApiService.save(folder, activeUserId); + const newFolder = new Folder(folderData); const decFolder = await newFolder.decrypt(); const res = new FolderResponse(decFolder); return Response.success(res); diff --git a/apps/cli/webpack.base.js b/apps/cli/webpack.base.js new file mode 100644 index 00000000000..532b0a747a0 --- /dev/null +++ b/apps/cli/webpack.base.js @@ -0,0 +1,126 @@ +const path = require("path"); +const webpack = require("webpack"); +const CopyWebpackPlugin = require("copy-webpack-plugin"); +const nodeExternals = require("webpack-node-externals"); +const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); +const config = require("./config/config"); + +module.exports.getEnv = function getEnv() { + const ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV; + return { ENV }; +}; + +const DEFAULT_PARAMS = { + localesPath: "./src/locales", + modulesPath: [path.resolve("../../node_modules")], + externalsModulesDir: "../../node_modules", + outputPath: path.resolve(__dirname, "build"), + watch: false, +}; + +/** + * + * @param {{ + * configName: string; + * entry: string; + * tsConfig: string; + * outputPath?: string; + * mode?: string; + * env?: string; + * modulesPath?: string[]; + * localesPath?: string; + * externalsModulesDir?: string; + * watch?: boolean; + * importAliases?: import("webpack").ResolveOptions["alias"]; + * }} params + */ +module.exports.buildConfig = function buildConfig(params) { + params = { ...DEFAULT_PARAMS, ...params }; + const ENV = params.env || module.exports.getEnv().ENV; + + const envConfig = config.load(ENV); + config.log(`Building CLI - ${params.configName} version`); + config.log(envConfig); + + const moduleRules = [ + { + test: /\.ts$/, + use: "ts-loader", + exclude: path.resolve(__dirname, "node_modules"), + }, + ]; + + const plugins = [ + new CopyWebpackPlugin({ + patterns: [{ from: params.localesPath, to: "locales" }], + }), + new webpack.DefinePlugin({ + "process.env.BWCLI_ENV": JSON.stringify(ENV), + }), + new webpack.BannerPlugin({ + banner: "#!/usr/bin/env node", + raw: true, + }), + new webpack.IgnorePlugin({ + resourceRegExp: /^encoding$/, + contextRegExp: /node-fetch/, + }), + new webpack.EnvironmentPlugin({ + ENV: ENV, + BWCLI_ENV: ENV, + FLAGS: envConfig.flags, + DEV_FLAGS: envConfig.devFlags, + }), + new webpack.IgnorePlugin({ + resourceRegExp: /canvas/, + contextRegExp: /jsdom$/, + }), + ]; + + const webpackConfig = { + mode: params.mode || ENV, + target: "node", + devtool: ENV === "development" ? "eval-source-map" : "source-map", + node: { + __dirname: false, + __filename: false, + }, + entry: { + bw: params.entry, + }, + optimization: { + minimize: false, + }, + resolve: { + extensions: [".ts", ".js"], + symlinks: false, + modules: params.modulesPath, + plugins: [new TsconfigPathsPlugin({ configFile: params.tsConfig })], + alias: params.importAliases, + }, + output: { + filename: "[name].js", + path: path.resolve(params.outputPath), + clean: true, + }, + module: { rules: moduleRules }, + plugins: plugins, + externals: [ + nodeExternals({ + modulesDir: params.externalsModulesDir, + allowlist: [/@bitwarden/], + }), + ], + experiments: { + asyncWebAssembly: true, + }, + }; + if (params.watch) { + webpackConfig.watch = true; + webpackConfig.watchOptions = { + ignored: /node_modules/, + poll: 1000, + }; + } + return webpackConfig; +}; diff --git a/apps/cli/webpack.config.js b/apps/cli/webpack.config.js index d5f66af73ec..b8eae3dce4d 100644 --- a/apps/cli/webpack.config.js +++ b/apps/cli/webpack.config.js @@ -1,89 +1,48 @@ const path = require("path"); -const webpack = require("webpack"); -const CopyWebpackPlugin = require("copy-webpack-plugin"); -const nodeExternals = require("webpack-node-externals"); -const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); -const config = require("./config/config"); +const { buildConfig } = require("./webpack.base"); -if (process.env.NODE_ENV == null) { - process.env.NODE_ENV = "development"; -} -const ENV = (process.env.ENV = process.env.NODE_ENV); +module.exports = (webpackConfig, context) => { + // Detect if called by Nx (context parameter exists) + const isNxBuild = context && context.options; -const envConfig = config.load(ENV); -config.log(envConfig); + if (isNxBuild) { + // Nx build configuration + const mode = context.options.mode || "development"; + if (process.env.NODE_ENV == null) { + process.env.NODE_ENV = mode; + } + const ENV = (process.env.ENV = process.env.NODE_ENV); -const moduleRules = [ - { - test: /\.ts$/, - use: "ts-loader", - exclude: path.resolve(__dirname, "node_modules"), - }, -]; + return buildConfig({ + configName: "OSS", + entry: context.options.main || "apps/cli/src/bw.ts", + tsConfig: "tsconfig.base.json", + outputPath: path.resolve(context.context.root, context.options.outputPath), + mode: mode, + env: ENV, + modulesPath: [path.resolve("node_modules")], + localesPath: "apps/cli/src/locales", + externalsModulesDir: "node_modules", + watch: context.options.watch || false, + }); + } else { + // npm build configuration + if (process.env.NODE_ENV == null) { + process.env.NODE_ENV = "development"; + } + const ENV = (process.env.ENV = process.env.NODE_ENV); + const mode = ENV; -const plugins = [ - new CopyWebpackPlugin({ - patterns: [{ from: "./src/locales", to: "locales" }], - }), - new webpack.DefinePlugin({ - "process.env.BWCLI_ENV": JSON.stringify(ENV), - }), - new webpack.BannerPlugin({ - banner: "#!/usr/bin/env node", - raw: true, - }), - new webpack.IgnorePlugin({ - resourceRegExp: /^encoding$/, - contextRegExp: /node-fetch/, - }), - new webpack.EnvironmentPlugin({ - ENV: ENV, - BWCLI_ENV: ENV, - FLAGS: envConfig.flags, - DEV_FLAGS: envConfig.devFlags, - }), - new webpack.IgnorePlugin({ - resourceRegExp: /canvas/, - contextRegExp: /jsdom$/, - }), -]; - -const webpackConfig = { - mode: ENV, - target: "node", - devtool: ENV === "development" ? "eval-source-map" : "source-map", - node: { - __dirname: false, - __filename: false, - }, - entry: { - bw: "./src/bw.ts", - }, - optimization: { - minimize: false, - }, - resolve: { - extensions: [".ts", ".js"], - symlinks: false, - modules: [path.resolve("../../node_modules")], - plugins: [new TsconfigPathsPlugin({ configFile: "./tsconfig.json" })], - }, - output: { - filename: "[name].js", - path: path.resolve(__dirname, "build"), - clean: true, - }, - module: { rules: moduleRules }, - plugins: plugins, - externals: [ - nodeExternals({ - modulesDir: "../../node_modules", - allowlist: [/@bitwarden/], - }), - ], - experiments: { - asyncWebAssembly: true, - }, + return buildConfig({ + configName: "OSS", + entry: "./src/bw.ts", + tsConfig: "./tsconfig.json", + outputPath: path.resolve(__dirname, "build"), + mode: mode, + env: ENV, + modulesPath: [path.resolve("../../node_modules")], + localesPath: "./src/locales", + externalsModulesDir: "../../node_modules", + }); + } }; - -module.exports = webpackConfig; diff --git a/apps/desktop/CLAUDE.md b/apps/desktop/CLAUDE.md new file mode 100644 index 00000000000..65b1952851a --- /dev/null +++ b/apps/desktop/CLAUDE.md @@ -0,0 +1,11 @@ +# Desktop (Electron) - Critical Rules + +- **CRITICAL**: Separate main process vs renderer process contexts + - Main process: Node.js + Electron APIs (files in `/apps/desktop/src/main/`) + - Renderer process: Browser-like environment (Angular app files) + - Use IPC (Inter-Process Communication) for cross-process communication + +- **NEVER** import Node.js modules directly in renderer process +- **NEVER** import Angular modules in the main process + - Use preload scripts or IPC to access Node.js functionality + - See `/apps/desktop/src/*/preload.ts` files for patterns diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index ca9965e5e18..f6380c747d8 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -120,9 +120,9 @@ checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" [[package]] name = "arboard" -version = "3.6.0" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55f533f8e0af236ffe5eb979b99381df3258853f00ba2e44b6e1955292c75227" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ "clipboard-win", "log", @@ -131,6 +131,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", + "windows-sys 0.60.2", "wl-clipboard-rs", "x11rb", ] @@ -317,9 +318,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", @@ -342,6 +343,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" name = "autotype" version = "0.0.0" dependencies = [ + "anyhow", + "mockall", + "serial_test", "tracing", "windows 0.61.1", "windows-core 0.61.0", @@ -438,27 +442,21 @@ dependencies = [ ] [[package]] -name = "bitwarden_chromium_importer" +name = "bitwarden_chromium_import_helper" version = "0.0.0" dependencies = [ - "aes", "aes-gcm", "anyhow", - "async-trait", "base64", - "cbc", - "hex", - "homedir", - "oo7", - "pbkdf2", - "rand 0.9.1", - "rusqlite", - "security-framework", - "serde", - "serde_json", - "sha1", + "chacha20poly1305", + "chromium_importer", + "clap", + "embed-resource", + "scopeguard", + "sysinfo", "tokio", - "winapi", + "tracing", + "tracing-subscriber", "windows 0.61.1", ] @@ -558,10 +556,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.4" +version = "1.2.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9157bbaa6b165880c27a4293a474c91cdcf265cc68cc829bf10be0964a391caf" +checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" dependencies = [ + "find-msvc-tools", "shlex", ] @@ -588,6 +587,45 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chromium_importer" +version = "0.0.0" +dependencies = [ + "aes", + "aes-gcm", + "anyhow", + "async-trait", + "base64", + "cbc", + "dirs", + "hex", + "oo7", + "pbkdf2", + "rand 0.9.1", + "rusqlite", + "security-framework", + "serde", + "serde_json", + "sha1", + "tokio", + "tracing", + "verifysign", + "windows 0.61.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -601,9 +639,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.40" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", "clap_derive", @@ -611,9 +649,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.40" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", @@ -623,9 +661,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.40" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck", "proc-macro2", @@ -648,17 +686,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "codespan-reporting" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" -dependencies = [ - "serde", - "termcolor", - "unicode-width", -] - [[package]] name = "colorchoice" version = "1.0.3" @@ -753,6 +780,22 @@ dependencies = [ "syn", ] +[[package]] +name = "ctor" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" + [[package]] name = "ctr" version = "0.9.2" @@ -789,78 +832,6 @@ dependencies = [ "syn", ] -[[package]] -name = "cxx" -version = "1.0.158" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a71ea7f29c73f7ffa64c50b83c9fe4d3a6d4be89a86b009eb80d5a6d3429d741" -dependencies = [ - "cc", - "cxxbridge-cmd", - "cxxbridge-flags", - "cxxbridge-macro", - "foldhash", - "link-cplusplus", -] - -[[package]] -name = "cxx-build" -version = "1.0.158" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a8232661d66dcf713394726157d3cfe0a89bfc85f52d6e9f9bbc2306797fe7" -dependencies = [ - "cc", - "codespan-reporting", - "proc-macro2", - "quote", - "scratch", - "syn", -] - -[[package]] -name = "cxxbridge-cmd" -version = "1.0.158" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f44296c8693e9ea226a48f6a122727f77aa9e9e338380cb021accaeeb7ee279" -dependencies = [ - "clap", - "codespan-reporting", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "cxxbridge-flags" -version = "1.0.158" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c42f69c181c176981ae44ba9876e2ea41ce8e574c296b38d06925ce9214fb8e4" - -[[package]] -name = "cxxbridge-macro" -version = "1.0.158" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8faff5d4467e0709448187df29ccbf3b0982cc426ee444a193f87b11afb565a8" -dependencies = [ - "proc-macro2", - "quote", - "rustversion", - "syn", -] - -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - [[package]] name = "der" version = "0.7.10" @@ -872,15 +843,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "deranged" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" -dependencies = [ - "powerfmt", -] - [[package]] name = "desktop_core" version = "0.0.0" @@ -891,35 +853,32 @@ dependencies = [ "ashpd", "base64", "bitwarden-russh", - "byteorder", "bytes", "cbc", + "chacha20poly1305", "core-foundation", "desktop_objc", "dirs", - "ed25519", "futures", "homedir", "interprocess", - "keytar", "libc", + "linux-keyutils", + "memsec", "oo7", "pin-project", - "pkcs8", "rand 0.9.1", - "rsa", - "russh-cryptovec", "scopeguard", "secmem-proc", "security-framework", "security-framework-sys", + "serde", + "serde_json", "sha2", - "ssh-encoding", "ssh-key", "sysinfo", "thiserror 2.0.12", "tokio", - "tokio-stream", "tokio-util", "tracing", "typenum", @@ -937,18 +896,14 @@ version = "0.0.0" dependencies = [ "anyhow", "autotype", - "base64", - "bitwarden_chromium_importer", + "chromium_importer", "desktop_core", - "hex", "napi", "napi-build", "napi-derive", "serde", "serde_json", "tokio", - "tokio-stream", - "tokio-util", "tracing", "tracing-subscriber", "windows-registry", @@ -961,9 +916,7 @@ version = "0.0.0" dependencies = [ "anyhow", "cc", - "core-foundation", "glob", - "thiserror 2.0.12", "tokio", "tracing", ] @@ -972,14 +925,13 @@ dependencies = [ name = "desktop_proxy" version = "0.0.0" dependencies = [ - "anyhow", "desktop_core", "embed_plist", "futures", - "log", - "simplelog", "tokio", "tokio-util", + "tracing", + "tracing-subscriber", ] [[package]] @@ -1012,7 +964,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1042,12 +994,33 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "downcast-rs" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dtor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58a0764cddb55ab28955347b45be00ade43d4d6f3ba4bf3dc354e4ec9432934" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "ecdsa" version = "0.16.9" @@ -1108,6 +1081,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embed-resource" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.5", + "vswhom", + "winreg", +] + [[package]] name = "embed_plist" version = "1.2.2" @@ -1218,6 +1205,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + [[package]] name = "fixedbitset" version = "0.4.2" @@ -1245,6 +1238,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + [[package]] name = "fs-err" version = "2.11.0" @@ -1444,12 +1443,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - [[package]] name = "hashbrown" version = "0.15.3" @@ -1465,7 +1458,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.15.3", + "hashbrown", ] [[package]] @@ -1630,7 +1623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", - "hashbrown 0.15.3", + "hashbrown", ] [[package]] @@ -1670,27 +1663,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" -[[package]] -name = "keytar" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d361c55fba09829ac620b040f5425bf239b1030c3d6820a84acac8da867dca4d" -dependencies = [ - "keytar-sys", -] - -[[package]] -name = "keytar-sys" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe908c6896705a1cb516cd6a5d956c63f08d95ace81b93253a98cd93e1e6a65a" -dependencies = [ - "cc", - "cxx", - "cxx-build", - "pkg-config", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1702,9 +1674,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.172" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libloading" @@ -1744,12 +1716,13 @@ dependencies = [ ] [[package]] -name = "link-cplusplus" -version = "1.0.10" +name = "linux-keyutils" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6f6da007f968f9def0d65a05b187e2960183de70c160204ecfccf0ee330212" +checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e" dependencies = [ - "cc", + "bitflags", + "libc", ] [[package]] @@ -1792,13 +1765,12 @@ version = "0.0.0" dependencies = [ "desktop_core", "futures", - "log", - "oslog", "serde", "serde_json", "tokio", - "tokio-util", "tracing", + "tracing-oslog", + "tracing-subscriber", "uniffi", ] @@ -1836,6 +1808,17 @@ dependencies = [ "autocfg", ] +[[package]] +name = "memsec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492" +dependencies = [ + "getrandom 0.2.16", + "libc", + "windows-sys 0.45.0", +] + [[package]] name = "mime" version = "0.3.17" @@ -1878,6 +1861,32 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "mockall" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "napi" version = "2.16.17" @@ -1885,7 +1894,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" dependencies = [ "bitflags", - "ctor", + "ctor 0.2.9", "napi-derive", "napi-sys", "once_cell", @@ -2040,12 +2049,6 @@ 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" @@ -2087,15 +2090,6 @@ dependencies = [ "libm", ] -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - [[package]] name = "objc2" version = "0.6.1" @@ -2257,17 +2251,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "oslog" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969" -dependencies = [ - "cc", - "dashmap", - "log", -] - [[package]] name = "p256" version = "0.13.2" @@ -2430,21 +2413,6 @@ dependencies = [ "spki", ] -[[package]] -name = "pkcs5" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" -dependencies = [ - "aes", - "cbc", - "der", - "pbkdf2", - "scrypt", - "sha2", - "spki", -] - [[package]] name = "pkcs8" version = "0.10.2" @@ -2452,8 +2420,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der", - "pkcs5", - "rand_core 0.6.4", "spki", ] @@ -2516,12 +2482,6 @@ 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" @@ -2531,6 +2491,32 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -2558,6 +2544,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process_isolation" +version = "0.0.0" +dependencies = [ + "ctor 0.5.0", + "desktop_core", + "libc", + "tracing", +] + [[package]] name = "quick-xml" version = "0.37.5" @@ -2802,12 +2798,6 @@ dependencies = [ "rustix 1.0.7", ] -[[package]] -name = "rustversion" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" - [[package]] name = "ryu" version = "1.0.20" @@ -2815,12 +2805,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] -name = "salsa20" -version = "0.10.2" +name = "scc" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" dependencies = [ - "cipher", + "sdd", ] [[package]] @@ -2829,12 +2819,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "scratch" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f6280af86e5f559536da57a45ebc84948833b3bee313a7dd25232e09c878a52" - [[package]] name = "scroll" version = "0.12.0" @@ -2856,15 +2840,10 @@ dependencies = [ ] [[package]] -name = "scrypt" -version = "0.11.0" +name = "sdd" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" -dependencies = [ - "pbkdf2", - "salsa20", - "sha2", -] +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" [[package]] name = "sec1" @@ -2970,6 +2949,40 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +dependencies = [ + "serde", +] + +[[package]] +name = "serial_test" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +dependencies = [ + "futures", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3026,17 +3039,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "simplelog" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" -dependencies = [ - "log", - "termcolor", - "time", -] - [[package]] name = "siphasher" version = "0.3.11" @@ -3212,13 +3214,10 @@ dependencies = [ ] [[package]] -name = "termcolor" -version = "1.4.1" +name = "termtree" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "textwrap" @@ -3278,39 +3277,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "time" -version = "0.3.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" -dependencies = [ - "deranged", - "itoa", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" - -[[package]] -name = "time-macros" -version = "0.2.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.8.1" @@ -3351,17 +3317,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-stream" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "tokio-util" version = "0.7.13" @@ -3384,12 +3339,36 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.7.0", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +dependencies = [ + "serde", +] + [[package]] name = "toml_edit" version = "0.22.26" @@ -3397,10 +3376,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 0.6.9", "winnow", ] +[[package]] +name = "toml_parser" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" + [[package]] name = "tracing" version = "0.1.41" @@ -3444,6 +3438,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-oslog" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76902d2a8d5f9f55a81155c08971734071968c90f2d9bfe645fe700579b2950" +dependencies = [ + "cc", + "cfg-if", + "tracing-core", + "tracing-subscriber", +] + [[package]] name = "tracing-subscriber" version = "0.3.20" @@ -3477,9 +3483,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "uds_windows" @@ -3510,12 +3516,6 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" -[[package]] -name = "unicode-width" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" - [[package]] name = "uniffi" version = "0.28.3" @@ -3550,7 +3550,7 @@ dependencies = [ "paste", "serde", "textwrap", - "toml", + "toml 0.5.11", "uniffi_meta", "uniffi_udl", ] @@ -3604,7 +3604,7 @@ dependencies = [ "quote", "serde", "syn", - "toml", + "toml 0.5.11", "uniffi_meta", ] @@ -3692,12 +3692,44 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "verifysign" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ebfe12e38930c3b851aea35e93fab1a6c29279cad7e8e273f29a21678fee8c0" +dependencies = [ + "core-foundation", + "sha1", + "sha2", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -3814,15 +3846,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" -dependencies = [ - "windows-sys 0.59.0", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3848,7 +3871,7 @@ dependencies = [ "windows-collections", "windows-core 0.61.0", "windows-future", - "windows-link", + "windows-link 0.1.3", "windows-numerics", ] @@ -3881,9 +3904,9 @@ checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" dependencies = [ "windows-implement 0.60.0", "windows-interface 0.59.1", - "windows-link", + "windows-link 0.1.3", "windows-result 0.3.4", - "windows-strings", + "windows-strings 0.4.2", ] [[package]] @@ -3893,7 +3916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32" dependencies = [ "windows-core 0.61.0", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -3946,6 +3969,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-numerics" version = "0.2.0" @@ -3953,18 +3982,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ "windows-core 0.61.0", - "windows-link", + "windows-link 0.1.3", ] [[package]] name = "windows-registry" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link", - "windows-result 0.3.4", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -3982,7 +4011,16 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", ] [[package]] @@ -3991,7 +4029,25 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", ] [[package]] @@ -4021,6 +4077,30 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -4058,7 +4138,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -4069,6 +4149,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -4087,6 +4173,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -4105,6 +4197,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -4135,6 +4233,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -4162,6 +4266,12 @@ dependencies = [ "windows-core 0.61.0", ] +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -4180,6 +4290,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -4198,6 +4314,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -4225,6 +4347,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" @@ -4417,9 +4549,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ "zeroize_derive", ] diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 0a637b12de9..0b09daa9bdd 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -2,10 +2,12 @@ resolver = "2" members = [ "autotype", - "bitwarden_chromium_importer", + "bitwarden_chromium_import_helper", + "chromium_importer", "core", "macos_provider", "napi", + "process_isolation", "proxy", "windows_plugin_authenticator" ] @@ -18,15 +20,18 @@ publish = false [workspace.dependencies] aes = "=0.8.4" +aes-gcm = "=0.10.3" anyhow = "=1.0.94" -arboard = { version = "=3.6.0", default-features = false } +arboard = { version = "=3.6.1", default-features = false } ashpd = "=0.11.0" base64 = "=0.22.1" bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "a641316227227f8777fdf56ac9fa2d6b5f7fe662" } byteorder = "=1.5.0" bytes = "=1.10.1" cbc = "=0.1.2" +chacha20poly1305 = "=0.10.1" core-foundation = "=0.10.1" +ctor = "=0.5.0" dirs = "=6.0.0" ed25519 = "=2.2.3" embed_plist = "=1.2.2" @@ -34,14 +39,13 @@ futures = "=0.3.31" hex = "=0.4.3" homedir = "=0.3.4" interprocess = "=2.2.1" -keytar = "=0.1.6" -libc = "=0.2.172" -log = "=0.4.25" +libc = "=0.2.177" +linux-keyutils = "=0.2.4" +memsec = "=0.7.0" napi = "=2.16.17" napi-build = "=2.2.0" napi-derive = "=2.16.13" oo7 = "=0.4.3" -oslog = "=0.2.0" pin-project = "=1.1.10" pkcs8 = "=0.10.2" rand = "=0.9.1" @@ -54,28 +58,37 @@ security-framework-sys = "=2.15.0" serde = "=1.0.209" serde_json = "=1.0.127" sha2 = "=0.10.8" -simplelog = "=0.12.2" ssh-encoding = "=0.2.0" ssh-key = { version = "=0.6.7", default-features = false } sysinfo = "=0.35.0" thiserror = "=2.0.12" tokio = "=1.45.0" -tokio-stream = "=0.1.15" tokio-util = "=0.7.13" tracing = "=0.1.41" -tracing-subscriber = { version = "=0.3.20", features = ["fmt", "env-filter"] } -typenum = "=1.18.0" +tracing-subscriber = { version = "=0.3.20", features = [ + "fmt", + "env-filter", + "tracing-log", +] } +typenum = "=1.19.0" uniffi = "=0.28.3" widestring = "=1.2.0" windows = "=0.61.1" windows-core = "=0.61.0" windows-future = "=0.2.0" -windows-registry = "=0.5.3" +windows-registry = "=0.6.1" zbus = "=5.11.0" zbus_polkit = "=5.0.0" zeroizing-alloc = "=0.1.0" [workspace.lints.clippy] +disallowed-macros = "deny" + +# Dis-allow println and eprintln, which are typically used in debugging. +# Use `tracing` and `tracing-subscriber` crates for observability needs. +print_stderr = "deny" +print_stdout = "deny" + +string_slice = "warn" unused_async = "deny" unwrap_used = "deny" -string_slice = "warn" diff --git a/apps/desktop/desktop_native/autotype/Cargo.toml b/apps/desktop/desktop_native/autotype/Cargo.toml index ceccd0c890a..267074d0bc8 100644 --- a/apps/desktop/desktop_native/autotype/Cargo.toml +++ b/apps/desktop/desktop_native/autotype/Cargo.toml @@ -5,7 +5,12 @@ license.workspace = true edition.workspace = true publish.workspace = true +[dependencies] +anyhow = { workspace = true } + [target.'cfg(windows)'.dependencies] +mockall = "=0.13.1" +serial_test = "=3.2.0" tracing.workspace = true windows = { workspace = true, features = [ "Win32_UI_Input_KeyboardAndMouse", diff --git a/apps/desktop/desktop_native/autotype/src/lib.rs b/apps/desktop/desktop_native/autotype/src/lib.rs index f1aab2ba164..c87fea23b60 100644 --- a/apps/desktop/desktop_native/autotype/src/lib.rs +++ b/apps/desktop/desktop_native/autotype/src/lib.rs @@ -1,22 +1,33 @@ +use anyhow::Result; + #[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "macos", path = "macos.rs")] -#[cfg_attr(target_os = "windows", path = "windows.rs")] +#[cfg_attr(target_os = "windows", path = "windows/mod.rs")] mod windowing; /// Gets the title bar string for the foreground window. /// -/// TODO: The error handling will be improved in a future PR: PM-23615 -#[allow(clippy::result_unit_err)] -pub fn get_foreground_window_title() -> std::result::Result { +/// # Errors +/// +/// This function returns an `anyhow::Error` if there is any +/// issue obtaining the window title. Detailed reasons will +/// vary based on platform implementation. +pub fn get_foreground_window_title() -> Result { windowing::get_foreground_window_title() } /// Attempts to type the input text wherever the user's cursor is. /// -/// `input` must be an array of utf-16 encoded characters to insert. +/// # Arguments /// -/// TODO: The error handling will be improved in a future PR: PM-23615 -#[allow(clippy::result_unit_err)] -pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> std::result::Result<(), ()> { +/// * `input` an array of utf-16 encoded characters to insert. +/// * `keyboard_shortcut` a vector of valid shortcut keys: Control, Alt, Super, Shift, letters a - Z +/// +/// # Errors +/// +/// This function returns an `anyhow::Error` if there is any +/// issue in typing the input. Detailed reasons will +/// vary based on platform implementation. +pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { windowing::type_input(input, keyboard_shortcut) } diff --git a/apps/desktop/desktop_native/autotype/src/linux.rs b/apps/desktop/desktop_native/autotype/src/linux.rs index 148b1aab6eb..9fda0ed9e33 100644 --- a/apps/desktop/desktop_native/autotype/src/linux.rs +++ b/apps/desktop/desktop_native/autotype/src/linux.rs @@ -1,10 +1,7 @@ -pub fn get_foreground_window_title() -> std::result::Result { +pub fn get_foreground_window_title() -> anyhow::Result { todo!("Bitwarden does not yet support Linux autotype"); } -pub fn type_input( - _input: Vec, - _keyboard_shortcut: Vec, -) -> std::result::Result<(), ()> { +pub fn type_input(_input: Vec, _keyboard_shortcut: Vec) -> anyhow::Result<()> { todo!("Bitwarden does not yet support Linux autotype"); } diff --git a/apps/desktop/desktop_native/autotype/src/macos.rs b/apps/desktop/desktop_native/autotype/src/macos.rs index 5542e7a3a6b..c6681a3291e 100644 --- a/apps/desktop/desktop_native/autotype/src/macos.rs +++ b/apps/desktop/desktop_native/autotype/src/macos.rs @@ -1,10 +1,7 @@ -pub fn get_foreground_window_title() -> std::result::Result { +pub fn get_foreground_window_title() -> anyhow::Result { todo!("Bitwarden does not yet support macOS autotype"); } -pub fn type_input( - _input: Vec, - _keyboard_shortcut: Vec, -) -> std::result::Result<(), ()> { +pub fn type_input(_input: Vec, _keyboard_shortcut: Vec) -> anyhow::Result<()> { todo!("Bitwarden does not yet support macOS autotype"); } diff --git a/apps/desktop/desktop_native/autotype/src/windows/mod.rs b/apps/desktop/desktop_native/autotype/src/windows/mod.rs new file mode 100644 index 00000000000..3ea63b2b8f4 --- /dev/null +++ b/apps/desktop/desktop_native/autotype/src/windows/mod.rs @@ -0,0 +1,41 @@ +use anyhow::Result; +use tracing::debug; +use windows::Win32::Foundation::{GetLastError, SetLastError, WIN32_ERROR}; + +mod type_input; +mod window_title; + +/// The error code from Win32 API that represents a non-error. +const WIN32_SUCCESS: WIN32_ERROR = WIN32_ERROR(0); + +/// `ErrorOperations` provides an interface to the Win32 API for dealing with +/// win32 errors. +#[cfg_attr(test, mockall::automock)] +trait ErrorOperations { + /// https://learn.microsoft.com/en-us/windows/win32/api/errhandlingapi/nf-errhandlingapi-setlasterror + fn set_last_error(err: u32) { + debug!(err, "Calling SetLastError"); + unsafe { + SetLastError(WIN32_ERROR(err)); + } + } + + /// https://learn.microsoft.com/en-us/windows/win32/api/errhandlingapi/nf-errhandlingapi-getlasterror + fn get_last_error() -> WIN32_ERROR { + let last_err = unsafe { GetLastError() }; + debug!("GetLastError(): {}", last_err.to_hresult().message()); + last_err + } +} + +/// Default implementation for Win32 API errors. +struct Win32ErrorOperations; +impl ErrorOperations for Win32ErrorOperations {} + +pub fn get_foreground_window_title() -> Result { + window_title::get_foreground_window_title() +} + +pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { + type_input::type_input(input, keyboard_shortcut) +} diff --git a/apps/desktop/desktop_native/autotype/src/windows.rs b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs similarity index 50% rename from apps/desktop/desktop_native/autotype/src/windows.rs rename to apps/desktop/desktop_native/autotype/src/windows/type_input.rs index 1d39d3f7ae5..10f30f5ee4f 100644 --- a/apps/desktop/desktop_native/autotype/src/windows.rs +++ b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs @@ -1,69 +1,85 @@ -use std::ffi::OsString; -use std::os::windows::ffi::OsStringExt; - -use tracing::debug; -use windows::Win32::Foundation::{GetLastError, HWND}; +use anyhow::{anyhow, Result}; +use tracing::{debug, error}; use windows::Win32::UI::Input::KeyboardAndMouse::{ SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_UNICODE, VIRTUAL_KEY, }; -use windows::Win32::UI::WindowsAndMessaging::{ - GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW, -}; -/// Gets the title bar string for the foreground window. -pub fn get_foreground_window_title() -> std::result::Result { - let Ok(window_handle) = get_foreground_window() else { - return Err(()); - }; - let Ok(Some(window_title)) = get_window_title(window_handle) else { - return Err(()); - }; +use super::{ErrorOperations, Win32ErrorOperations}; - Ok(window_title) +/// `InputOperations` provides an interface to Window32 API for +/// working with inputs. +#[cfg_attr(test, mockall::automock)] +trait InputOperations { + /// Attempts to type the provided input wherever the user's cursor is. + /// + /// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput + fn send_input(inputs: &[INPUT]) -> u32; +} + +struct Win32InputOperations; + +impl InputOperations for Win32InputOperations { + fn send_input(inputs: &[INPUT]) -> u32 { + const INPUT_STRUCT_SIZE: i32 = std::mem::size_of::() as i32; + let insert_count = unsafe { SendInput(inputs, INPUT_STRUCT_SIZE) }; + + debug!(insert_count, "SendInput() called."); + + insert_count + } } /// Attempts to type the input text wherever the user's cursor is. /// /// `input` must be a vector of utf-16 encoded characters to insert. -/// `keyboard_shortcut` must be a vector of Strings, where valid shortcut keys: Control, Alt, Super, Shift, letters a - Z +/// `keyboard_shortcut` must be a vector of Strings, where valid shortcut keys: Control, Alt, Super, +/// Shift, letters a - Z /// /// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput -pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<(), ()> { - const TAB_KEY: u8 = 9; +pub(super) fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { + // the length of this vec is always shortcut keys to release + (2x length of input chars) + let mut keyboard_inputs: Vec = + Vec::with_capacity(keyboard_shortcut.len() + (input.len() * 2)); - let mut keyboard_inputs: Vec = Vec::new(); + debug!(?keyboard_shortcut, "Converting keyboard shortcut to input."); // Add key "up" inputs for the shortcut for key in keyboard_shortcut { keyboard_inputs.push(convert_shortcut_key_to_up_input(key)?); } - // Add key "down" and "up" inputs for the input - // (currently in this form: {username}/t{password}) + add_input(&input, &mut keyboard_inputs); + + send_input::(keyboard_inputs) +} + +// Add key "down" and "up" inputs for the input +// (currently in this form: {username}/t{password}) +fn add_input(input: &[u16], keyboard_inputs: &mut Vec) { + const TAB_KEY: u8 = 9; + for i in input { - let next_down_input = if i == TAB_KEY.into() { - build_virtual_key_input(InputKeyPress::Down, i as u8) + let next_down_input = if *i == TAB_KEY.into() { + build_virtual_key_input(InputKeyPress::Down, *i as u8) } else { - build_unicode_input(InputKeyPress::Down, i) + build_unicode_input(InputKeyPress::Down, *i) }; - let next_up_input = if i == TAB_KEY.into() { - build_virtual_key_input(InputKeyPress::Up, i as u8) + let next_up_input = if *i == TAB_KEY.into() { + build_virtual_key_input(InputKeyPress::Up, *i as u8) } else { - build_unicode_input(InputKeyPress::Up, i) + build_unicode_input(InputKeyPress::Up, *i) }; keyboard_inputs.push(next_down_input); keyboard_inputs.push(next_up_input); } - - send_input(keyboard_inputs) } /// Converts a valid shortcut key to an "up" keyboard input. /// /// `input` must be a valid shortcut key: Control, Alt, Super, Shift, letters [a-z][A-Z] -fn convert_shortcut_key_to_up_input(key: String) -> Result { +fn convert_shortcut_key_to_up_input(key: String) -> Result { const SHIFT_KEY: u8 = 0x10; const SHIFT_KEY_STR: &str = "Shift"; const CONTROL_KEY: u8 = 0x11; @@ -89,9 +105,15 @@ fn convert_shortcut_key_to_up_input(key: String) -> Result { /// Because we only accept [a-z][A-Z], the decimal u16 /// cast of the letter is safe because the unicode code point /// of these characters fits in a u16. -fn get_alphabetic_hotkey(letter: String) -> Result { +fn get_alphabetic_hotkey(letter: String) -> Result { if letter.len() != 1 { - return Err(()); + error!( + len = letter.len(), + "Final keyboard shortcut key should be a single character." + ); + return Err(anyhow!( + "Final keyboard shortcut key should be a single character: {letter}" + )); } let c = letter.chars().next().expect("letter is size 1"); @@ -99,65 +121,20 @@ fn get_alphabetic_hotkey(letter: String) -> Result { // is_ascii_alphabetic() checks for: // U+0041 `A` ..= U+005A `Z`, or U+0061 `a` ..= U+007A `z` if !c.is_ascii_alphabetic() { - return Err(()); + error!(letter = %c, "Letter is not ASCII Alphabetic ([a-z][A-Z])."); + return Err(anyhow!( + "Letter is not ASCII Alphabetic ([a-z][A-Z]): '{letter}'", + )); } - Ok(c as u16) + let c = c as u16; + + debug!(c, letter, "Got alphabetic hotkey."); + + Ok(c) } -/// Gets the foreground window handle. -/// -/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getforegroundwindow -fn get_foreground_window() -> Result { - let foreground_window_handle = unsafe { GetForegroundWindow() }; - - if foreground_window_handle.is_invalid() { - return Err(()); - } - - Ok(foreground_window_handle) -} - -/// Gets the length of the window title bar text. -/// -/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextlengthw -fn get_window_title_length(window_handle: HWND) -> Result { - if window_handle.is_invalid() { - return Err(()); - } - - match usize::try_from(unsafe { GetWindowTextLengthW(window_handle) }) { - Ok(length) => Ok(length), - Err(_) => Err(()), - } -} - -/// Gets the window title bar title. -/// -/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextw -fn get_window_title(window_handle: HWND) -> Result, ()> { - if window_handle.is_invalid() { - return Err(()); - } - - let window_title_length = get_window_title_length(window_handle)?; - if window_title_length == 0 { - return Ok(None); - } - - let mut buffer: Vec = vec![0; window_title_length + 1]; // add extra space for the null character - - let window_title_length = unsafe { GetWindowTextW(window_handle, &mut buffer) }; - if window_title_length == 0 { - return Ok(None); - } - - let window_title = OsString::from_wide(&buffer); - - Ok(Some(window_title.to_string_lossy().into_owned())) -} - -/// Used in build_input() to specify if an input key is being pressed (down) or released (up). +/// An input key can be either pressed (down), or released (up). enum InputKeyPress { Down, Up, @@ -230,19 +207,27 @@ fn build_virtual_key_input(key_press: InputKeyPress, virtual_key: u8) -> INPUT { } } -/// Attempts to type the provided input wherever the user's cursor is. -/// -/// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput -fn send_input(inputs: Vec) -> Result<(), ()> { - let insert_count = unsafe { SendInput(&inputs, std::mem::size_of::() as i32) }; - - let e = unsafe { GetLastError().to_hresult().message() }; - debug!("type_input() called, GetLastError() is: {:?}", e); +fn send_input(inputs: Vec) -> Result<()> +where + I: InputOperations, + E: ErrorOperations, +{ + let insert_count = I::send_input(&inputs); if insert_count == 0 { - return Err(()); // input was blocked by another thread + let last_err = E::get_last_error().to_hresult().message(); + error!(GetLastError = %last_err, "SendInput sent 0 inputs. Input was blocked by another thread."); + + return Err(anyhow!("SendInput sent 0 inputs. Input was blocked by another thread. GetLastError: {last_err}")); } else if insert_count != inputs.len() as u32 { - return Err(()); // input insertion not completed + let last_err = E::get_last_error().to_hresult().message(); + error!(sent = %insert_count, expected = inputs.len(), GetLastError = %last_err, + "SendInput sent does not match expected." + ); + return Err(anyhow!( + "SendInput does not match expected. sent: {insert_count}, expected: {}", + inputs.len() + )); } Ok(()) @@ -250,29 +235,85 @@ fn send_input(inputs: Vec) -> Result<(), ()> { #[cfg(test)] mod tests { + //! For the mocking of the traits that are static methods, we need to use the `serial_test` + //! crate in order to mock those, since the mock expectations set have to be global in + //! absence of a `self`. More info: https://docs.rs/mockall/latest/mockall/#static-methods + + use serial_test::serial; + use windows::Win32::Foundation::WIN32_ERROR; + use super::*; + use crate::windowing::MockErrorOperations; #[test] - fn get_alphabetic_hot_key_happy() { + fn get_alphabetic_hot_key_succeeds() { for c in ('a'..='z').chain('A'..='Z') { let letter = c.to_string(); - println!("{}", letter); let converted = get_alphabetic_hotkey(letter).unwrap(); assert_eq!(converted, c as u16); } } #[test] - #[should_panic = ""] + #[should_panic = "Final keyboard shortcut key should be a single character: foo"] fn get_alphabetic_hot_key_fail_not_single_char() { let letter = String::from("foo"); get_alphabetic_hotkey(letter).unwrap(); } #[test] - #[should_panic = ""] + #[should_panic = "Letter is not ASCII Alphabetic ([a-z][A-Z]): '}'"] fn get_alphabetic_hot_key_fail_not_alphabetic() { - let letter = String::from("🚀"); + let letter = String::from("}"); get_alphabetic_hotkey(letter).unwrap(); } + + #[test] + #[serial] + fn send_input_succeeds() { + let ctxi = MockInputOperations::send_input_context(); + ctxi.expect().returning(|_| 1); + + send_input::(vec![build_unicode_input( + InputKeyPress::Up, + 0, + )]) + .unwrap(); + } + + #[test] + #[serial] + #[should_panic( + expected = "SendInput sent 0 inputs. Input was blocked by another thread. GetLastError:" + )] + fn send_input_fails_sent_zero() { + let ctxi = MockInputOperations::send_input_context(); + ctxi.expect().returning(|_| 0); + + let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.expect().returning(|| WIN32_ERROR(1)); + + send_input::(vec![build_unicode_input( + InputKeyPress::Up, + 0, + )]) + .unwrap(); + } + + #[test] + #[serial] + #[should_panic(expected = "SendInput does not match expected. sent: 2, expected: 1")] + fn send_input_fails_sent_mismatch() { + let ctxi = MockInputOperations::send_input_context(); + ctxi.expect().returning(|_| 2); + + let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.expect().returning(|| WIN32_ERROR(1)); + + send_input::(vec![build_unicode_input( + InputKeyPress::Up, + 0, + )]) + .unwrap(); + } } diff --git a/apps/desktop/desktop_native/autotype/src/windows/window_title.rs b/apps/desktop/desktop_native/autotype/src/windows/window_title.rs new file mode 100644 index 00000000000..d56a811ab5c --- /dev/null +++ b/apps/desktop/desktop_native/autotype/src/windows/window_title.rs @@ -0,0 +1,298 @@ +use std::{ffi::OsString, os::windows::ffi::OsStringExt}; + +use anyhow::{anyhow, Result}; +use tracing::{debug, error, warn}; +use windows::Win32::{ + Foundation::HWND, + UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW}, +}; + +use super::{ErrorOperations, Win32ErrorOperations, WIN32_SUCCESS}; + +#[cfg_attr(test, mockall::automock)] +trait WindowHandleOperations { + // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextlengthw + fn get_window_text_length_w(&self) -> Result; + + // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextw + fn get_window_text_w(&self, buffer: &mut Vec) -> Result; +} + +/// `WindowHandle` provides a light wrapper over the `HWND` (which is just a void *). +/// The raw pointer can become invalid during runtime so it's validity must be checked +/// before usage. +struct WindowHandle { + handle: HWND, +} + +impl WindowHandle { + /// Create a new `WindowHandle` + fn new(handle: HWND) -> Self { + Self { handle } + } + + /// Assert that the raw pointer is valid. + fn validate(&self) -> Result<()> { + if self.handle.is_invalid() { + error!("Window handle is invalid."); + return Err(anyhow!("Window handle is invalid.")); + } + Ok(()) + } +} + +impl WindowHandleOperations for WindowHandle { + fn get_window_text_length_w(&self) -> Result { + self.validate()?; + let length = unsafe { GetWindowTextLengthW(self.handle) }; + Ok(length) + } + + fn get_window_text_w(&self, buffer: &mut Vec) -> Result { + self.validate()?; + let len_written = unsafe { GetWindowTextW(self.handle, buffer) }; + Ok(len_written) + } +} + +/// Gets the title bar string for the foreground window. +pub(super) fn get_foreground_window_title() -> Result { + let window_handle = get_foreground_window_handle()?; + + let expected_window_title_length = + get_window_title_length::(&window_handle)?; + + get_window_title::( + &window_handle, + expected_window_title_length, + ) +} + +/// Retrieves the foreground window handle and validates it. +fn get_foreground_window_handle() -> Result { + // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getforegroundwindow + let handle = unsafe { GetForegroundWindow() }; + + debug!("GetForegroundWindow() called."); + + let window_handle = WindowHandle::new(handle); + window_handle.validate()?; + + Ok(window_handle) +} + +/// # Returns +/// +/// The length of the window title. +/// +/// # Errors +/// +/// - If the length zero and GetLastError() != 0, return the GetLastError() message. +fn get_window_title_length(window_handle: &H) -> Result +where + H: WindowHandleOperations, + E: ErrorOperations, +{ + // GetWindowTextLengthW does not itself clear the last error so we must do it ourselves. + E::set_last_error(0); + + let length = window_handle.get_window_text_length_w()?; + + let length = usize::try_from(length)?; + + debug!(length, "window text length retrieved from handle."); + + if length == 0 { + // attempt to retreive win32 error + let last_err = E::get_last_error(); + if last_err != WIN32_SUCCESS { + let last_err = last_err.to_hresult().message(); + error!(last_err, "Error getting window text length."); + return Err(anyhow!("Error getting window text length: {last_err}")); + } + } + + Ok(length) +} + +/// Gets the window title bar title using the expected length to determine size of buffer +/// to store it. +/// +/// # Returns +/// +/// If the `expected_title_length` is zero, return an Ok result containing empty string. It +/// Isn't considered an error by the Win32 API. +/// +/// Otherwise, return the retrieved window title string. +/// +/// # Errors +/// +/// - If the actual window title length (what the win32 API declares was written into the buffer), +/// is length zero and GetLastError() != 0 , return the GetLastError() message. +fn get_window_title(window_handle: &H, expected_title_length: usize) -> Result +where + H: WindowHandleOperations, + E: ErrorOperations, +{ + if expected_title_length == 0 { + // This isn't considered an error by the windows API, but in practice it means we can't + // match against the title so we'll stop here. + // The upstream will make a contains comparison on what we return, so an empty string + // will not result on a match. + warn!("Window title length is zero."); + return Ok(String::from("")); + } + + let mut buffer: Vec = vec![0; expected_title_length + 1]; // add extra space for the null character + + let actual_window_title_length = window_handle.get_window_text_w(&mut buffer)?; + + debug!(actual_window_title_length, "window title retrieved."); + + if actual_window_title_length == 0 { + // attempt to retreive win32 error + let last_err = E::get_last_error(); + if last_err != WIN32_SUCCESS { + let last_err = last_err.to_hresult().message(); + error!(last_err, "Error retrieving window title."); + return Err(anyhow!("Error retrieving window title: {last_err}")); + } + // in practice, we should not get to the below code, since we asserted the len > 0 + // above. but it is an extra protection in case the windows API didn't set an error. + warn!(expected_title_length, "No window title retrieved."); + } + + let window_title = OsString::from_wide(&buffer); + + Ok(window_title.to_string_lossy().into_owned()) +} + +#[cfg(test)] +mod tests { + //! For the mocking of the traits that are static methods, we need to use the `serial_test` + //! crate in order to mock those, since the mock expectations set have to be global in + //! absence of a `self`. More info: https://docs.rs/mockall/latest/mockall/#static-methods + + use mockall::predicate; + use serial_test::serial; + use windows::Win32::Foundation::WIN32_ERROR; + + use super::*; + use crate::windowing::MockErrorOperations; + + #[test] + #[serial] + fn get_window_title_length_can_be_zero() { + let mut mock_handle = MockWindowHandleOperations::new(); + + let ctxse = MockErrorOperations::set_last_error_context(); + ctxse + .expect() + .once() + .with(predicate::eq(0)) + .returning(|_| {}); + + mock_handle + .expect_get_window_text_length_w() + .once() + .returning(|| Ok(0)); + + let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.expect().returning(|| WIN32_ERROR(0)); + + let len = get_window_title_length::( + &mock_handle, + ) + .unwrap(); + + assert_eq!(len, 0); + } + + #[test] + #[serial] + #[should_panic(expected = "Error getting window text length:")] + fn get_window_title_length_fails() { + let mut mock_handle = MockWindowHandleOperations::new(); + + let ctxse = MockErrorOperations::set_last_error_context(); + ctxse.expect().with(predicate::eq(0)).returning(|_| {}); + + mock_handle + .expect_get_window_text_length_w() + .once() + .returning(|| Ok(0)); + + let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.expect().returning(|| WIN32_ERROR(1)); + + get_window_title_length::(&mock_handle) + .unwrap(); + } + + #[test] + fn get_window_title_succeeds() { + let mut mock_handle = MockWindowHandleOperations::new(); + + mock_handle + .expect_get_window_text_w() + .once() + .returning(|buffer| { + buffer.fill_with(|| 42); // because why not + Ok(42) + }); + + let title = + get_window_title::(&mock_handle, 42) + .unwrap(); + + assert_eq!(title.len(), 43); // That extra slot in the buffer for null char + + assert_eq!(title, "*******************************************"); + } + + #[test] + fn get_window_title_returns_empty_string() { + let mock_handle = MockWindowHandleOperations::new(); + + let title = + get_window_title::(&mock_handle, 0) + .unwrap(); + + assert_eq!(title, ""); + } + + #[test] + #[serial] + #[should_panic(expected = "Error retrieving window title:")] + fn get_window_title_fails_with_last_error() { + let mut mock_handle = MockWindowHandleOperations::new(); + + mock_handle + .expect_get_window_text_w() + .once() + .returning(|_| Ok(0)); + + let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.expect().returning(|| WIN32_ERROR(1)); + + get_window_title::(&mock_handle, 42) + .unwrap(); + } + + #[test] + #[serial] + fn get_window_title_doesnt_fail_but_reads_zero() { + let mut mock_handle = MockWindowHandleOperations::new(); + + mock_handle + .expect_get_window_text_w() + .once() + .returning(|_| Ok(0)); + + let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.expect().returning(|| WIN32_ERROR(0)); + + get_window_title::(&mock_handle, 42) + .unwrap(); + } +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml new file mode 100644 index 00000000000..ff641731661 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "bitwarden_chromium_import_helper" +version.workspace = true +license.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] + +[target.'cfg(target_os = "windows")'.dependencies] +aes-gcm = { 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 } + +[build-dependencies] +embed-resource = "=3.0.6" + +[lints] +workspace = true diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/build.rs b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/build.rs new file mode 100644 index 00000000000..326929ec7c8 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/build.rs @@ -0,0 +1,9 @@ +fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").expect("to be set by cargo") == "windows" { + println!("cargo:rerun-if-changed=resources.rc"); + + embed_resource::compile("resources.rc", embed_resource::NONE) + .manifest_optional() + .expect("to compile resources"); + } +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/resources.rc b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/resources.rc new file mode 100644 index 00000000000..c300cc5d77f --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/resources.rc @@ -0,0 +1 @@ +1 ICON "../../resources/icon.ico" diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/main.rs b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/main.rs new file mode 100644 index 00000000000..036e04de16b --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/main.rs @@ -0,0 +1,13 @@ +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(target_os = "windows")] +#[tokio::main] +async fn main() { + windows::main().await; +} + +#[cfg(not(target_os = "windows"))] +fn main() { + // Empty +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/config.rs b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/config.rs new file mode 100644 index 00000000000..cf05b4de524 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/config.rs @@ -0,0 +1,2 @@ +// List of SYSTEM process names to try to impersonate +pub(crate) const SYSTEM_PROCESS_NAMES: [&str; 2] = ["services.exe", "winlogon.exe"]; diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/crypto.rs b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/crypto.rs new file mode 100644 index 00000000000..c335a4b296a --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/crypto.rs @@ -0,0 +1,279 @@ +use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit}; +use anyhow::{anyhow, Result}; +use base64::{engine::general_purpose, Engine as _}; +use chacha20poly1305::ChaCha20Poly1305; +use chromium_importer::chromium::crypt_unprotect_data; +use scopeguard::defer; +use tracing::debug; +use windows::{ + core::w, + Win32::Security::Cryptography::{ + self, NCryptOpenKey, NCryptOpenStorageProvider, CERT_KEY_SPEC, CRYPTPROTECT_UI_FORBIDDEN, + NCRYPT_FLAGS, NCRYPT_KEY_HANDLE, NCRYPT_PROV_HANDLE, NCRYPT_SILENT_FLAG, + }, +}; + +use super::impersonate::{start_impersonating, stop_impersonating}; + +// +// Base64 +// + +pub(crate) fn decode_base64(data_base64: &str) -> Result> { + debug!("Decoding base64 data: {}", data_base64); + + let data = general_purpose::STANDARD.decode(data_base64).map_err(|e| { + debug!("Failed to decode base64: {}", e); + e + })?; + + Ok(data) +} + +pub(crate) fn encode_base64(data: &[u8]) -> String { + general_purpose::STANDARD.encode(data) +} + +// +// DPAPI decryption +// + +pub(crate) fn decrypt_with_dpapi_as_system(encrypted: &[u8]) -> Result> { + // Impersonate a SYSTEM process to be able to decrypt data encrypted for the machine + let system_token = start_impersonating()?; + defer! { + debug!("Stopping impersonation"); + _ = stop_impersonating(system_token); + } + + decrypt_with_dpapi_as_user(encrypted, true) +} + +pub(crate) fn decrypt_with_dpapi_as_user(encrypted: &[u8], expect_appb: bool) -> Result> { + let system_decrypted = decrypt_with_dpapi(encrypted, expect_appb)?; + debug!( + "Decrypted data with SYSTEM {} bytes", + system_decrypted.len() + ); + + Ok(system_decrypted) +} + +fn decrypt_with_dpapi(data: &[u8], expect_appb: bool) -> Result> { + if expect_appb && (data.len() < 5 || !data.starts_with(b"APPB")) { + const ERR_MSG: &str = "Ciphertext is too short or does not start with 'APPB'"; + debug!("{}", ERR_MSG); + return Err(anyhow!(ERR_MSG)); + } + + let data = if expect_appb { &data[4..] } else { data }; + + crypt_unprotect_data(data, CRYPTPROTECT_UI_FORBIDDEN) +} + +// +// Chromium key decoding +// + +pub(crate) fn decode_abe_key_blob(blob_data: &[u8]) -> Result> { + // Parse and skip the header + let header_len = u32::from_le_bytes(get_safe(blob_data, 0, 4)?.try_into()?) as usize; + debug!("ABE key blob header length: {}", header_len); + + // Parse content length + let content_len_offset = 4 + header_len; + let content_len = + u32::from_le_bytes(get_safe(blob_data, content_len_offset, 4)?.try_into()?) as usize; + debug!("ABE key blob content length: {}", content_len); + + if content_len < 32 { + return Err(anyhow!( + "Corrupted ABE key blob: content length is less than 32" + )); + } + + let content_offset = content_len_offset + 4; + let content = get_safe(blob_data, content_offset, content_len)?; + + // When the size is exactly 32 bytes, it's a plain key. It's used in unbranded Chromium builds, + // Brave, possibly Edge + if content_len == 32 { + return Ok(content.to_vec()); + } + + let version = content[0]; + debug!("ABE key blob version: {}", version); + + let key_blob = &content[1..]; + match version { + // Google Chrome v1 key encrypted with a hardcoded AES key + 1_u8 => decrypt_abe_key_blob_chrome_aes(key_blob), + // Google Chrome v2 key encrypted with a hardcoded ChaCha20 key + 2_u8 => decrypt_abe_key_blob_chrome_chacha20(key_blob), + // Google Chrome v3 key encrypted with CNG APIs + 3_u8 => decrypt_abe_key_blob_chrome_cng(key_blob), + v => Err(anyhow!("Unsupported ABE key blob version: {}", v)), + } +} + +fn get_safe(data: &[u8], start: usize, len: usize) -> Result<&[u8]> { + let end = start + len; + data.get(start..end).ok_or_else(|| { + anyhow!( + "Corrupted ABE key blob: expected bytes {}..{}, got {}", + start, + end, + data.len() + ) + }) +} + +fn decrypt_abe_key_blob_chrome_aes(blob: &[u8]) -> Result> { + const GOOGLE_AES_KEY: &[u8] = &[ + 0xB3, 0x1C, 0x6E, 0x24, 0x1A, 0xC8, 0x46, 0x72, 0x8D, 0xA9, 0xC1, 0xFA, 0xC4, 0x93, 0x66, + 0x51, 0xCF, 0xFB, 0x94, 0x4D, 0x14, 0x3A, 0xB8, 0x16, 0x27, 0x6B, 0xCC, 0x6D, 0xA0, 0x28, + 0x47, 0x87, + ]; + let aes_key = Key::::from_slice(GOOGLE_AES_KEY); + let cipher = Aes256Gcm::new(aes_key); + + decrypt_abe_key_blob_with_aead(blob, &cipher, "v1 (AES flavor)") +} + +fn decrypt_abe_key_blob_chrome_chacha20(blob: &[u8]) -> Result> { + const GOOGLE_CHACHA20_KEY: &[u8] = &[ + 0xE9, 0x8F, 0x37, 0xD7, 0xF4, 0xE1, 0xFA, 0x43, 0x3D, 0x19, 0x30, 0x4D, 0xC2, 0x25, 0x80, + 0x42, 0x09, 0x0E, 0x2D, 0x1D, 0x7E, 0xEA, 0x76, 0x70, 0xD4, 0x1F, 0x73, 0x8D, 0x08, 0x72, + 0x96, 0x60, + ]; + + let chacha20_key = chacha20poly1305::Key::from_slice(GOOGLE_CHACHA20_KEY); + let cipher = ChaCha20Poly1305::new(chacha20_key); + + decrypt_abe_key_blob_with_aead(blob, &cipher, "v2 (ChaCha20 flavor)") +} + +fn decrypt_abe_key_blob_with_aead(blob: &[u8], cipher: &C, version: &str) -> Result> +where + C: Aead, +{ + if blob.len() < 60 { + return Err(anyhow!( + "Corrupted ABE key blob: expected at least 60 bytes, got {} bytes", + blob.len() + )); + } + + let iv = &blob[0..12]; + let ciphertext = &blob[12..12 + 48]; + + debug!("Google ABE {} detected: {:?} {:?}", version, iv, ciphertext); + + let decrypted = cipher + .decrypt(iv.into(), ciphertext) + .map_err(|e| anyhow!("Failed to decrypt v20 key with {}: {}", version, e))?; + + Ok(decrypted) +} + +fn decrypt_abe_key_blob_chrome_cng(blob: &[u8]) -> Result> { + if blob.len() < 92 { + return Err(anyhow!( + "Corrupted ABE key blob: expected at least 92 bytes, got {} bytes", + blob.len() + )); + } + + let encrypted_aes_key: [u8; 32] = blob[0..32].try_into()?; + let iv: [u8; 12] = blob[32..32 + 12].try_into()?; + let ciphertext: [u8; 48] = blob[44..44 + 48].try_into()?; + + debug!( + "Google ABE v3 (CNG flavor) detected: {:?} {:?} {:?}", + encrypted_aes_key, iv, ciphertext + ); + + // First, decrypt the AES key with CNG API + let decrypted_aes_key: Vec = { + let system_token = start_impersonating()?; + defer! { + debug!("Stopping impersonation"); + _ = stop_impersonating(system_token); + } + decrypt_with_cng(&encrypted_aes_key)? + }; + + const GOOGLE_XOR_KEY: [u8; 32] = [ + 0xCC, 0xF8, 0xA1, 0xCE, 0xC5, 0x66, 0x05, 0xB8, 0x51, 0x75, 0x52, 0xBA, 0x1A, 0x2D, 0x06, + 0x1C, 0x03, 0xA2, 0x9E, 0x90, 0x27, 0x4F, 0xB2, 0xFC, 0xF5, 0x9B, 0xA4, 0xB7, 0x5C, 0x39, + 0x23, 0x90, + ]; + + // XOR the decrypted AES key with the hardcoded key + let aes_key: Vec = decrypted_aes_key + .into_iter() + .zip(GOOGLE_XOR_KEY) + .map(|(a, b)| a ^ b) + .collect(); + + // Decrypt the actual ABE key with the decrypted AES key + let cipher = Aes256Gcm::new(aes_key.as_slice().into()); + let key = cipher + .decrypt((&iv).into(), ciphertext.as_ref()) + .map_err(|e| anyhow!("Failed to decrypt v20 key with AES-GCM: {}", e))?; + + Ok(key) +} + +fn decrypt_with_cng(ciphertext: &[u8]) -> Result> { + // 1. Open the cryptographic provider + let mut provider = NCRYPT_PROV_HANDLE::default(); + unsafe { + NCryptOpenStorageProvider( + &mut provider, + w!("Microsoft Software Key Storage Provider"), + 0, + )?; + }; + + // Don't forget to free the provider + defer!(unsafe { + _ = Cryptography::NCryptFreeObject(provider.into()); + }); + + // 2. Open the key + let mut key = NCRYPT_KEY_HANDLE::default(); + unsafe { + NCryptOpenKey( + provider, + &mut key, + w!("Google Chromekey1"), + CERT_KEY_SPEC::default(), + NCRYPT_FLAGS::default(), + )?; + }; + + // Don't forget to free the key + defer!(unsafe { + _ = Cryptography::NCryptFreeObject(key.into()); + }); + + // 3. Decrypt the data (assume the plaintext is not larger than the ciphertext) + let mut plaintext = vec![0; ciphertext.len()]; + let mut plaintext_len = 0; + unsafe { + Cryptography::NCryptDecrypt( + key, + ciphertext.into(), + None, + Some(&mut plaintext), + &mut plaintext_len, + NCRYPT_SILENT_FLAG, + )?; + }; + + // In case the plaintext is smaller than the ciphertext + plaintext.truncate(plaintext_len as usize); + + Ok(plaintext) +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/impersonate.rs b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/impersonate.rs new file mode 100644 index 00000000000..22006b8db14 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/impersonate.rs @@ -0,0 +1,140 @@ +use anyhow::{anyhow, Result}; +use sysinfo::System; +use tracing::debug; +use windows::{ + core::BOOL, + Wdk::System::SystemServices::SE_DEBUG_PRIVILEGE, + Win32::{ + Foundation::{CloseHandle, HANDLE, NTSTATUS, STATUS_SUCCESS}, + Security::{ + self, DuplicateToken, ImpersonateLoggedOnUser, RevertToSelf, TOKEN_DUPLICATE, + TOKEN_QUERY, + }, + System::Threading::{OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION}, + }, +}; + +use super::config::SYSTEM_PROCESS_NAMES; + +#[link(name = "ntdll")] +unsafe extern "system" { + unsafe fn RtlAdjustPrivilege( + privilege: i32, + enable: BOOL, + current_thread: BOOL, + previous_value: *mut BOOL, + ) -> NTSTATUS; +} + +pub(crate) fn start_impersonating() -> Result { + // Need to enable SE_DEBUG_PRIVILEGE to enumerate and open SYSTEM processes + enable_debug_privilege()?; + + // Find a SYSTEM process and get its token. Not every SYSTEM process allows token duplication, + // so try several. + let (token, pid, name) = find_system_process_with_token(get_system_pid_list())?; + + // Impersonate the SYSTEM process + unsafe { + ImpersonateLoggedOnUser(token)?; + }; + debug!("Impersonating system process '{}' (PID: {})", name, pid); + + Ok(token) +} + +pub(crate) fn stop_impersonating(token: HANDLE) -> Result<()> { + unsafe { + RevertToSelf()?; + CloseHandle(token)?; + }; + Ok(()) +} + +fn find_system_process_with_token( + pids: Vec<(u32, &'static str)>, +) -> Result<(HANDLE, u32, &'static str)> { + for (pid, name) in pids { + match get_system_token_from_pid(pid) { + Err(_) => { + debug!( + "Failed to open process handle '{}' (PID: {}), skipping", + name, pid + ); + continue; + } + Ok(system_handle) => { + return Ok((system_handle, pid, name)); + } + } + } + Err(anyhow!("Failed to get system token from any process")) +} + +fn get_system_token_from_pid(pid: u32) -> Result { + let handle = get_process_handle(pid)?; + let token = get_system_token(handle)?; + unsafe { + CloseHandle(handle)?; + }; + Ok(token) +} + +fn get_system_token(handle: HANDLE) -> Result { + let token_handle = unsafe { + let mut token_handle = HANDLE::default(); + OpenProcessToken(handle, TOKEN_DUPLICATE | TOKEN_QUERY, &mut token_handle)?; + token_handle + }; + + let duplicate_token = unsafe { + let mut duplicate_token = HANDLE::default(); + DuplicateToken( + token_handle, + Security::SECURITY_IMPERSONATION_LEVEL(2), + &mut duplicate_token, + )?; + CloseHandle(token_handle)?; + duplicate_token + }; + + Ok(duplicate_token) +} + +fn get_system_pid_list() -> Vec<(u32, &'static str)> { + let sys = System::new_all(); + SYSTEM_PROCESS_NAMES + .iter() + .flat_map(|&name| { + sys.processes_by_exact_name(name.as_ref()) + .map(move |process| (process.pid().as_u32(), name)) + }) + .collect() +} + +fn get_process_handle(pid: u32) -> Result { + let hprocess = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?; + Ok(hprocess) +} + +fn enable_debug_privilege() -> Result<()> { + let mut previous_value = BOOL(0); + let status = unsafe { + debug!("Setting SE_DEBUG_PRIVILEGE to 1 via RtlAdjustPrivilege"); + RtlAdjustPrivilege(SE_DEBUG_PRIVILEGE, BOOL(1), BOOL(0), &mut previous_value) + }; + + match status { + STATUS_SUCCESS => { + debug!( + "SE_DEBUG_PRIVILEGE set to 1, was {} before", + previous_value.as_bool() + ); + Ok(()) + } + _ => { + debug!("RtlAdjustPrivilege failed with status: 0x{:X}", status.0); + Err(anyhow!("Failed to adjust privilege")) + } + } +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/log.rs b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/log.rs new file mode 100644 index 00000000000..aa00a2f61b7 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/log.rs @@ -0,0 +1,29 @@ +use chromium_importer::config::{ENABLE_DEVELOPER_LOGGING, LOG_FILENAME}; +use tracing::{error, level_filters::LevelFilter}; +use tracing_subscriber::{ + fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer as _, +}; + +pub(crate) fn init_logging() { + if ENABLE_DEVELOPER_LOGGING { + // We only log to a file. It's impossible to see stdout/stderr when this exe is launched + // from ShellExecuteW. + match std::fs::File::create(LOG_FILENAME) { + Ok(file) => { + let file_filter = EnvFilter::builder() + .with_default_directive(LevelFilter::DEBUG.into()) + .from_env_lossy(); + + let file_layer = fmt::layer() + .with_writer(file) + .with_ansi(false) + .with_filter(file_filter); + + tracing_subscriber::registry().with(file_layer).init(); + } + Err(error) => { + error!(%error, ?LOG_FILENAME, "Could not create log file."); + } + } + } +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/main.rs b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/main.rs new file mode 100644 index 00000000000..560135b8ce4 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/main.rs @@ -0,0 +1,225 @@ +use std::{ + ffi::OsString, + os::windows::{ffi::OsStringExt as _, io::AsRawHandle}, + path::PathBuf, + time::Duration, +}; + +use anyhow::{anyhow, Result}; +use chromium_importer::chromium::{verify_signature, ADMIN_TO_USER_PIPE_NAME}; +use clap::Parser; +use scopeguard::defer; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::windows::named_pipe::{ClientOptions, NamedPipeClient}, + time, +}; +use tracing::{debug, error}; +use windows::Win32::{ + Foundation::{CloseHandle, ERROR_PIPE_BUSY, HANDLE}, + System::{ + Pipes::GetNamedPipeServerProcessId, + Threading::{ + OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_WIN32, + PROCESS_QUERY_LIMITED_INFORMATION, + }, + }, + UI::Shell::IsUserAnAdmin, +}; + +use super::{ + crypto::{ + decode_abe_key_blob, decode_base64, decrypt_with_dpapi_as_system, + decrypt_with_dpapi_as_user, encode_base64, + }, + log::init_logging, +}; + +#[derive(Parser)] +#[command(name = "bitwarden_chromium_import_helper")] +#[command(about = "Admin tool for ABE service management")] +struct Args { + #[arg(long, help = "Base64 encoded encrypted data string")] + encrypted: String, +} + +async fn open_pipe_client(pipe_name: &'static str) -> Result { + let max_attempts = 5; + for _ in 0..max_attempts { + match ClientOptions::new().open(pipe_name) { + Ok(client) => { + debug!("Successfully connected to the pipe!"); + return Ok(client); + } + Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY.0 as i32) => { + debug!("Pipe is busy, retrying in 50ms..."); + } + Err(e) => { + debug!("Failed to connect to pipe: {}", &e); + return Err(e.into()); + } + } + + time::sleep(Duration::from_millis(50)).await; + } + + Err(anyhow!( + "Failed to connect to pipe after {} attempts", + max_attempts + )) +} + +async fn send_message_with_client(client: &mut NamedPipeClient, message: &str) -> Result { + client.write_all(message.as_bytes()).await?; + + // Try to receive a response for this message + let mut buffer = vec![0u8; 64 * 1024]; + match client.read(&mut buffer).await { + Ok(0) => Err(anyhow!( + "Server closed the connection (0 bytes read) on message" + )), + Ok(bytes_received) => { + let response = String::from_utf8_lossy(&buffer[..bytes_received]); + Ok(response.to_string()) + } + Err(e) => Err(anyhow!("Failed to receive response for message: {}", e)), + } +} + +fn get_named_pipe_server_pid(client: &NamedPipeClient) -> Result { + let handle = HANDLE(client.as_raw_handle() as _); + let mut pid: u32 = 0; + unsafe { GetNamedPipeServerProcessId(handle, &mut pid) }?; + Ok(pid) +} + +fn resolve_process_executable_path(pid: u32) -> Result { + debug!("Resolving process executable path for PID {}", pid); + + // Open the process handle + let hprocess = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?; + debug!("Opened process handle for PID {}", pid); + + // Close when no longer needed + defer! { + debug!("Closing process handle for PID {}", pid); + unsafe { + _ = CloseHandle(hprocess); + } + }; + + let mut exe_name = vec![0u16; 32 * 1024]; + let mut exe_name_length = exe_name.len() as u32; + unsafe { + QueryFullProcessImageNameW( + hprocess, + PROCESS_NAME_WIN32, + windows::core::PWSTR(exe_name.as_mut_ptr()), + &mut exe_name_length, + ) + }?; + debug!( + "QueryFullProcessImageNameW returned {} bytes", + exe_name_length + ); + + exe_name.truncate(exe_name_length as usize); + Ok(PathBuf::from(OsString::from_wide(&exe_name))) +} + +async fn send_error_to_user(client: &mut NamedPipeClient, error_message: &str) { + _ = send_to_user(client, &format!("!{}", error_message)).await +} + +async fn send_to_user(client: &mut NamedPipeClient, message: &str) -> Result<()> { + let _ = send_message_with_client(client, message).await?; + Ok(()) +} + +fn is_admin() -> bool { + unsafe { IsUserAnAdmin().as_bool() } +} + +async fn open_and_validate_pipe_server(pipe_name: &'static str) -> Result { + let client = open_pipe_client(pipe_name).await?; + + let server_pid = get_named_pipe_server_pid(&client)?; + debug!("Connected to pipe server PID {}", server_pid); + + // Validate the server end process signature + let exe_path = resolve_process_executable_path(server_pid)?; + + debug!("Pipe server executable path: {}", exe_path.display()); + + if !verify_signature(&exe_path)? { + return Err(anyhow!("Pipe server signature is not valid")); + } + + debug!("Pipe server signature verified for PID {}", server_pid); + + Ok(client) +} + +fn run() -> Result { + debug!("Starting bitwarden_chromium_import_helper.exe"); + + let args = Args::try_parse()?; + + if !is_admin() { + return Err(anyhow!("Expected to run with admin privileges")); + } + + debug!("Running as ADMINISTRATOR"); + + let encrypted = decode_base64(&args.encrypted)?; + debug!( + "Decoded encrypted data [{}] {:?}", + encrypted.len(), + encrypted + ); + + let system_decrypted = decrypt_with_dpapi_as_system(&encrypted)?; + debug!( + "Decrypted data with DPAPI as SYSTEM {} {:?}", + system_decrypted.len(), + system_decrypted + ); + + let user_decrypted = decrypt_with_dpapi_as_user(&system_decrypted, false)?; + debug!( + "Decrypted data with DPAPI as USER {} {:?}", + user_decrypted.len(), + user_decrypted + ); + + let key = decode_abe_key_blob(&user_decrypted)?; + debug!("Decoded ABE key blob {} {:?}", key.len(), key); + + Ok(encode_base64(&key)) +} + +pub(crate) async fn main() { + init_logging(); + + let mut client = match open_and_validate_pipe_server(ADMIN_TO_USER_PIPE_NAME).await { + Ok(client) => client, + Err(e) => { + error!( + "Failed to open pipe {} to send result/error: {}", + ADMIN_TO_USER_PIPE_NAME, e + ); + return; + } + }; + + match run() { + Ok(system_decrypted_base64) => { + debug!("Sending response back to user"); + let _ = send_to_user(&mut client, &system_decrypted_base64).await; + } + Err(e) => { + debug!("Error: {}", e); + send_error_to_user(&mut client, &format!("{}", e)).await; + } + } +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/mod.rs b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/mod.rs new file mode 100644 index 00000000000..d745dc27618 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/mod.rs @@ -0,0 +1,7 @@ +mod config; +mod crypto; +mod impersonate; +mod log; +mod main; + +pub(crate) use main::main; diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/README.md b/apps/desktop/desktop_native/bitwarden_chromium_importer/README.md deleted file mode 100644 index 498dd3ac67d..00000000000 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/README.md +++ /dev/null @@ -1,156 +0,0 @@ -# Windows ABE Architecture - -## Overview - -The Windows Application Bound Encryption (ABE) consists of three main components that work together: - -- **client library** -- Library that is part of the desktop client application -- **admin.exe** -- Service launcher running as ADMINISTRATOR -- **service.exe** -- Background Windows service running as SYSTEM - -_(The names of the binaries will be changed for the released product.)_ - -## The goal - -The goal of this subsystem is to decrypt the master encryption key with which the login information -is encrypted on the local system in Windows. This applies to the most recent versions of Chrome and -Edge (untested yet) that are using the ABE/v20 encryption scheme for some of the local profiles. - -The general idea of this encryption scheme is that Chrome generates a unique random encryption key, -then encrypts it at the user level with a fixed key. It then sends it to the Windows Data Protection -API at the user level, and then, using an installed service, encrypts it with the Windows Data -Protection API at the system level on top of that. This triply encrypted key is later stored in the -`Local State` file. - -The next paragraphs describe what is done at each level to decrypt the key. - -## 1. Client library - -This is a Rust module that is part of the Chromium importer. It only compiles and runs on Windows -(see `abe.rs` and `abe_config.rs`). Its main task is to launch `admin.exe` with elevated privileges -by presenting the user with the UAC screen. See the `abe::decrypt_with_admin_and_service` invocation -in `windows.rs`. - -This function takes three arguments: - -1. Absolute path to `admin.exe` -2. Absolute path to `service.exe` -3. Base64 string of the ABE key extracted from the browser's local state - -It's not possible to install the service from the user-level executable. So first, we have to -elevate the privileges and run `admin.exe` as ADMINISTRATOR. This is done by calling `ShellExecute` -with the `runas` verb. Since it's not trivial to read the standard output from an application -launched in this way, a named pipe server is created at the user level, which waits for the response -from `admin.exe` after it has been launched. - -The name of the service executable and the data to be decrypted are passed via the command line to -`admin.exe` like this: - -```bat -admin.exe --service-exe "c:\temp\service.exe" --encrypted "QVBQQgEAAADQjJ3fARXREYx6AMBPwpfrAQAAA..." -``` - -**At this point, the user must permit the action to be performed on the UAC screen.** - -## 2. Admin executable - -This executable receives the full path of `service.exe` and the data to be decrypted. - -First, it installs the service to run as SYSTEM and waits for it to start running. The service -creates a named pipe server that the admin-level executable communicates with (see the `service.exe` -description further down). - -It sends the base64 string to the pipe server in a raw message and waits for the answer. The answer -could be a success or a failure. In case of success, it's a base64 string decrypted at the system -level. In case of failure, it's an error message prefixed with an `!`. In either case, the response -is sent to the named pipe server created by the user. The user responds with `ok` (ignored). - -After that, the executable stops and uninstalls the service and then exits. - -## 3. System service - -The service starts and creates a named pipe server for communication between `admin.exe` and the -system service. Please note that it is not possible to communicate between the user and the system -service directly via a named pipe. Thus, this three-layered approach is necessary. - -Once the service is started, it waits for the incoming message via the named pipe. The expected -message is a base64 string to be decrypted. The data is decrypted via the Windows Data Protection -API `CryptUnprotectData` and sent back in response to this incoming message in base64 encoding. In -case of an error, the error message is sent back prefixed with an `!`. - -The service keeps running and servicing more requests if there are any, until it's stopped and -removed from the system. Even though we send only one request, the service is designed to handle as -many clients with as many messages as needed and could be installed on the system permanently if -necessary. - -## 4. Back to client library - -The decrypted base64-encoded string comes back from the admin executable to the named pipe server at -the user level. At this point, it has been decrypted only once at the system level. - -In the next step, the string is decrypted at the user level with the same Windows Data Protection -API. - -And as the third step, it's decrypted with a hard-coded key found in the `elevation_service.exe` -from the Chrome installation. Based on the version of the encrypted string (encoded in the string -itself), it's either AES-256-GCM or ChaCha20Poly1305 encryption scheme. The details can be found in -`windows.rs`. - -After all of these steps, we have the master key which can be used to decrypt the password -information stored in the local database. - -## Summary - -The Windows ABE decryption process involves a three-tier architecture with named pipe communication: - -```mermaid -sequenceDiagram - participant Client as Client Library (User) - participant Admin as admin.exe (Administrator) - participant Service as service.exe (System) - - Client->>Client: Create named pipe server - Note over Client: \\.\pipe\BitwardenEncryptionService-admin-user - - Client->>Admin: Launch with UAC elevation - Note over Client,Admin: --service-exe c:\path\to\service.exe - Note over Client,Admin: --encrypted QVBQQgEAAADQjJ3fARXRE... - - Client->>Client: Wait for response - - Admin->>Service: Install & start service - Note over Admin,Service: c:\path\to\service.exe - - Service->>Service: Create named pipe server - Note over Service: \\.\pipe\BitwardenEncryptionService-service-admin - - Service->>Service: Wait for message - - Admin->>Service: Send encrypted data via admin-service pipe - Note over Admin,Service: QVBQQgEAAADQjJ3fARXRE... - - Admin->>Admin: Wait for response - - Service->>Service: Decrypt with system-level DPAPI - - Service->>Admin: Return decrypted data via admin-service pipe - Note over Service,Admin: EjRWeXN0ZW0gU2VydmljZQ... - - Admin->>Client: Send result via named user-admin pipe - Note over Client,Admin: EjRWeXN0ZW0gU2VydmljZQ... - - Client->>Admin: Send ACK to admin - Note over Client,Admin: ok - - Admin->>Service: Stop & uninstall service - Service-->>Admin: Exit - - Admin-->>Client: Exit - - Client->>Client: Decrypt with user-level DPAPI - - Client->>Client: Decrypt with hardcoded key - Note over Client: AES-256-GCM or ChaCha20Poly1305 - - Client->>Client: Done -``` diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs deleted file mode 100644 index e6442e21742..00000000000 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Cryptographic primitives used in the SDK - -use anyhow::{anyhow, Result}; - -use aes::cipher::{ - block_padding::Pkcs7, generic_array::GenericArray, typenum::U32, BlockDecryptMut, KeyIvInit, -}; - -pub fn decrypt_aes256(iv: &[u8; 16], data: &[u8], key: GenericArray) -> Result> { - let iv = GenericArray::from_slice(iv); - let mut data = data.to_vec(); - cbc::Decryptor::::new(&key, iv) - .decrypt_padded_mut::(&mut data) - .map_err(|_| anyhow!("Failed to decrypt data"))?; - - Ok(data) -} - -#[cfg(test)] -mod tests { - use aes::cipher::{ - generic_array::{sequence::GenericSequence, GenericArray}, - ArrayLength, - }; - use base64::{engine::general_purpose::STANDARD, Engine}; - - pub fn generate_vec(length: usize, offset: u8, increment: u8) -> Vec { - (0..length).map(|i| offset + i as u8 * increment).collect() - } - pub fn generate_generic_array>( - offset: u8, - increment: u8, - ) -> GenericArray { - GenericArray::generate(|i| offset + i as u8 * increment) - } - - #[test] - fn test_decrypt_aes256() { - let iv = generate_vec(16, 0, 1); - let iv: &[u8; 16] = iv.as_slice().try_into().unwrap(); - let key = generate_generic_array(0, 1); - let data: Vec = STANDARD.decode("ByUF8vhyX4ddU9gcooznwA==").unwrap(); - - let decrypted = super::decrypt_aes256(iv, &data, key).unwrap(); - - assert_eq!(String::from_utf8(decrypted).unwrap(), "EncryptMe!\u{6}\u{6}\u{6}\u{6}\u{6}\u{6}"); - } -} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs deleted file mode 100644 index b0a399d6321..00000000000 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod chromium; diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs deleted file mode 100644 index e7dffe93dba..00000000000 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs +++ /dev/null @@ -1,205 +0,0 @@ -use aes_gcm::aead::Aead; -use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce}; -use anyhow::{anyhow, Result}; -use async_trait::async_trait; -use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use winapi::shared::minwindef::{BOOL, BYTE, DWORD}; -use winapi::um::{dpapi::CryptUnprotectData, wincrypt::DATA_BLOB}; -use windows::Win32::Foundation::{LocalFree, HLOCAL}; - -use crate::chromium::{BrowserConfig, CryptoService, LocalState}; - -#[allow(dead_code)] -mod util; - -// -// Public API -// - -pub const SUPPORTED_BROWSERS: [BrowserConfig; 6] = [ - BrowserConfig { - name: "Chrome", - data_dir: "AppData/Local/Google/Chrome/User Data", - }, - BrowserConfig { - name: "Chromium", - data_dir: "AppData/Local/Chromium/User Data", - }, - BrowserConfig { - name: "Microsoft Edge", - data_dir: "AppData/Local/Microsoft/Edge/User Data", - }, - BrowserConfig { - name: "Brave", - data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data", - }, - BrowserConfig { - name: "Opera", - data_dir: "AppData/Roaming/Opera Software/Opera Stable", - }, - BrowserConfig { - name: "Vivaldi", - data_dir: "AppData/Local/Vivaldi/User Data", - }, -]; - -pub fn get_crypto_service( - _browser_name: &str, - local_state: &LocalState, -) -> Result> { - Ok(Box::new(WindowsCryptoService::new(local_state))) -} - -// -// CryptoService -// -struct WindowsCryptoService { - master_key: Option>, - encrypted_key: Option, -} - -impl WindowsCryptoService { - pub(crate) fn new(local_state: &LocalState) -> Self { - Self { - master_key: None, - encrypted_key: local_state - .os_crypt - .as_ref() - .and_then(|c| c.encrypted_key.clone()), - } - } -} - -#[async_trait] -impl CryptoService for WindowsCryptoService { - async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result { - if encrypted.is_empty() { - return Ok(String::new()); - } - - // On Windows only v10 and v20 are supported at the moment - let (version, no_prefix) = - util::split_encrypted_string_and_validate(encrypted, &["v10", "v20"])?; - - // v10 is already stripped; Windows Chrome uses AES-GCM: [12 bytes IV][ciphertext][16 bytes auth tag] - const IV_SIZE: usize = 12; - const TAG_SIZE: usize = 16; - const MIN_LENGTH: usize = IV_SIZE + TAG_SIZE; - - if no_prefix.len() < MIN_LENGTH { - return Err(anyhow!( - "Corrupted entry: expected at least {} bytes, got {} bytes", - MIN_LENGTH, - no_prefix.len() - )); - } - - // Allow empty passwords - if no_prefix.len() == MIN_LENGTH { - return Ok(String::new()); - } - - if self.master_key.is_none() { - self.master_key = Some(self.get_master_key(version)?); - } - - let key = self - .master_key - .as_ref() - .ok_or_else(|| anyhow!("Failed to retrieve key"))?; - let key = Key::::from_slice(key); - let cipher = Aes256Gcm::new(key); - let nonce = Nonce::from_slice(&no_prefix[..IV_SIZE]); - - let decrypted_bytes = cipher - .decrypt(nonce, no_prefix[IV_SIZE..].as_ref()) - .map_err(|e| anyhow!("Decryption failed: {}", e))?; - - let plaintext = String::from_utf8(decrypted_bytes) - .map_err(|e| anyhow!("Failed to convert decrypted data to UTF-8: {}", e))?; - - Ok(plaintext) - } -} - -impl WindowsCryptoService { - fn get_master_key(&mut self, version: &str) -> Result> { - match version { - "v10" => self.get_master_key_v10(), - _ => Err(anyhow!("Unsupported version: {}", version)), - } - } - - fn get_master_key_v10(&mut self) -> Result> { - if self.encrypted_key.is_none() { - return Err(anyhow!( - "Encrypted master key is not found in the local browser state" - )); - } - - let key = self - .encrypted_key - .as_ref() - .ok_or_else(|| anyhow!("Failed to retrieve key"))?; - let key_bytes = BASE64_STANDARD - .decode(key) - .map_err(|e| anyhow!("Encrypted master key is not a valid base64 string: {}", e))?; - - if key_bytes.len() <= 5 || &key_bytes[..5] != b"DPAPI" { - return Err(anyhow!("Encrypted master key is not encrypted with DPAPI")); - } - - let key = unprotect_data_win(&key_bytes[5..]) - .map_err(|e| anyhow!("Failed to unprotect the master key: {}", e))?; - - Ok(key) - } -} - -fn unprotect_data_win(data: &[u8]) -> Result> { - if data.is_empty() { - return Ok(Vec::new()); - } - - let mut data_in = DATA_BLOB { - cbData: data.len() as DWORD, - pbData: data.as_ptr() as *mut BYTE, - }; - - let mut data_out = DATA_BLOB { - cbData: 0, - pbData: std::ptr::null_mut(), - }; - - let result: BOOL = unsafe { - // BOOL from winapi (i32) - CryptUnprotectData( - &mut data_in, - std::ptr::null_mut(), // ppszDataDescr: *mut LPWSTR (*mut *mut u16) - std::ptr::null_mut(), // pOptionalEntropy: *mut DATA_BLOB - std::ptr::null_mut(), // pvReserved: LPVOID (*mut c_void) - std::ptr::null_mut(), // pPromptStruct: *mut CRYPTPROTECT_PROMPTSTRUCT - 0, // dwFlags: DWORD - &mut data_out, - ) - }; - - if result == 0 { - return Err(anyhow!("CryptUnprotectData failed")); - } - - if data_out.pbData.is_null() || data_out.cbData == 0 { - return Ok(Vec::new()); - } - - let output_slice = - unsafe { std::slice::from_raw_parts(data_out.pbData, data_out.cbData as usize) }; - - unsafe { - if !data_out.pbData.is_null() { - LocalFree(Some(HLOCAL(data_out.pbData as *mut std::ffi::c_void))); - } - } - - Ok(output_slice.to_vec()) -} diff --git a/apps/desktop/desktop_native/build.js b/apps/desktop/desktop_native/build.js index 125cb1bb567..a7ed89a9c17 100644 --- a/apps/desktop/desktop_native/build.js +++ b/apps/desktop/desktop_native/build.js @@ -45,6 +45,39 @@ function buildProxyBin(target, release = true) { } } +function buildImporterBinaries(target, release = true) { + // These binaries are only built for Windows, so we can skip them on other platforms + if (process.platform !== "win32") { + return; + } + + const bin = "bitwarden_chromium_import_helper"; + const targetArg = target ? `--target ${target}` : ""; + const releaseArg = release ? "--release" : ""; + child_process.execSync(`cargo build --bin ${bin} ${releaseArg} ${targetArg}`); + + if (target) { + // Copy the resulting binary to the dist folder + const targetFolder = release ? "release" : "debug"; + const nodeArch = rustTargetsMap[target].nodeArch; + fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `${bin}.exe`), path.join(__dirname, "dist", `${bin}.${process.platform}-${nodeArch}.exe`)); + } +} + +function buildProcessIsolation() { + if (process.platform !== "linux") { + return; + } + + child_process.execSync(`cargo build --release`, { + stdio: 'inherit', + cwd: path.join(__dirname, "process_isolation") + }); + + console.log("Copying process isolation library to dist folder"); + fs.copyFileSync(path.join(__dirname, "target", "release", "libprocess_isolation.so"), path.join(__dirname, "dist", `libprocess_isolation.so`)); +} + function installTarget(target) { child_process.execSync(`rustup target add ${target}`, { stdio: 'inherit', cwd: __dirname }); } @@ -53,6 +86,8 @@ if (!crossPlatform && !target) { console.log(`Building native modules in ${mode} mode for the native architecture`); buildNapiModule(false, mode === "release"); buildProxyBin(false, mode === "release"); + buildImporterBinaries(false, mode === "release"); + buildProcessIsolation(); return; } @@ -61,6 +96,8 @@ if (target) { installTarget(target); buildNapiModule(target, mode === "release"); buildProxyBin(target, mode === "release"); + buildImporterBinaries(false, mode === "release"); + buildProcessIsolation(); return; } @@ -78,4 +115,6 @@ platformTargets.forEach(([target, _]) => { installTarget(target); buildNapiModule(target); buildProxyBin(target); + buildImporterBinaries(target); + buildProcessIsolation(); }); diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml b/apps/desktop/desktop_native/chromium_importer/Cargo.toml similarity index 63% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml rename to apps/desktop/desktop_native/chromium_importer/Cargo.toml index d1efbd006f0..9e9a9e0fee8 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml +++ b/apps/desktop/desktop_native/chromium_importer/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "bitwarden_chromium_importer" +name = "chromium_importer" edition = { workspace = true } license = { workspace = true } version = { workspace = true } @@ -7,31 +7,38 @@ publish = { workspace = true } [dependencies] aes = { workspace = true } -aes-gcm = "=0.10.3" anyhow = { workspace = true } -async-trait = "=0.1.88" -base64 = { workspace = true } -cbc = { workspace = true, features = ["alloc"] } +async-trait = "=0.1.89" +dirs = { workspace = true } hex = { workspace = true } -homedir = { workspace = true } -pbkdf2 = "=0.12.2" rand = { workspace = true } rusqlite = { version = "=0.37.0", features = ["bundled"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -sha1 = "=0.10.6" [target.'cfg(target_os = "macos")'.dependencies] +cbc = { workspace = true, features = ["alloc"] } +pbkdf2 = "=0.12.2" security-framework = { workspace = true } +sha1 = "=0.10.6" [target.'cfg(target_os = "windows")'.dependencies] +aes-gcm = { workspace = true } +base64 = { workspace = true } +windows = { workspace = true, features = [ + "Win32_Security_Cryptography", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } +verifysign = "=0.2.4" tokio = { workspace = true, features = ["full"] } -winapi = { version = "=0.3.9", features = ["dpapi", "memoryapi"] } -windows = { workspace = true, features = ["Win32_Security", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_Services", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } +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/README.md b/apps/desktop/desktop_native/chromium_importer/README.md new file mode 100644 index 00000000000..2a708ea572c --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/README.md @@ -0,0 +1,140 @@ +# Chromium Direct Importer + +A rust library that allows you to directly import credentials from Chromium-based browsers. + +## Windows ABE Architecture + +On Windows Chrome has additional protection measurements which needs to be circumvented in order to +get access to the passwords. + +### Overview + +The Windows **Application Bound Encryption (ABE)** subsystem consists of two main components that work together: + +- **client library** — a library that is part of the desktop client application +- **bitwarden_chromium_import_helper.exe** — a password decryptor running as **ADMINISTRATOR** and later as **SYSTEM** + +See the last section for a concise summary of the entire process. + +### Goal + +The goal of this subsystem is to decrypt the master encryption key used to encrypt login information on the local +Windows system. This applies to the most recent versions of Chrome, Brave, and (untested) Edge that use the ABE/v20 +encryption scheme for some local profiles. + +The general idea of this encryption scheme is as follows: + +1. Chrome generates a unique random encryption key. +2. This key is first encrypted at the **user level** with a fixed key for v1/v2 of ABE. For ABE v3 a more complicated + scheme is used that encrypts the key with a combination of a fixed key and a randomly generated key at the **system + level** via Windows CNG API. +3. It is then encrypted at the **user level** again using the Windows **Data Protection API (DPAPI)**. +4. Finally, it is sent to a special service that encrypts it with DPAPI at the **system level**. + +This triply encrypted key is stored in the `Local State` file. + +The following sections describe how the key is decrypted at each level. + +### 1. Client Library + +This is a Rust module that is part of the Chromium importer. It compiles and runs only on Windows (see `abe.rs` and +`abe_config.rs`). Its main task is to launch `bitwarden_chromium_import_helper.exe` with elevated privileges, presenting +the user with the UAC prompt. See the `abe::decrypt_with_admin` call in `platform/windows/mod.rs`. + +This function takes two arguments: + +1. Absolute path to `bitwarden_chromium_import_helper.exe` +2. Base64 string of the ABE key extracted from the browser's local state + +First, `bitwarden_chromium_import_helper.exe` is launched by calling a variant of `ShellExecute` with the `runas` verb. +This displays the UAC screen. If the user accepts, `bitwarden_chromium_import_helper.exe` starts with **ADMINISTRATOR** +privileges. + +> **The user must approve the UAC prompt or the process is aborted.** + +Because it is not possible to read the standard output of an application launched in this way, a named pipe server is +created at the user level before `bitwarden_chromium_import_helper.exe` is launched. This pipe is used to send the +decryption result from `bitwarden_chromium_import_helper.exe` back to the client. + +The data to be decrypted are passed via the command line to `bitwarden_chromium_import_helper.exe` like this: + +```bat +bitwarden_chromium_import_helper.exe --encrypted "QVBQQgEAAADQjJ3fARXREYx6AMBPwpfrAQAAA..." +``` + +### 2. Admin Executable + +Although the process starts with **ADMINISTRATOR** privileges, its ultimate goal is to elevate to **SYSTEM**. To achieve +this, it uses a technique to impersonate a system-level process. + +First, `bitwarden_chromium_import_helper.exe` ensures that the `SE_DEBUG_PRIVILEGE` privilege is enabled by calling +`RtlAdjustPrivilege`. This allows it to enumerate running system-level processes. + +Next, it finds an instance of `services.exe` or `winlogon.exe`, which are known to run at the **SYSTEM** level. Once a +system process is found, its token is duplicated by calling `DuplicateToken`. + +With the duplicated token, `ImpersonateLoggedOnUser` is called to impersonate a system-level process. + +> **At this point `bitwarden_chromium_import_helper.exe` is running as SYSTEM.** + +The received encryption key can now be decrypted using DPAPI at the **system level**. + +Next, the impersonation is stopped and the feshly decrypted key is decrypted at the **user level** with DPAPI one more +time. + +At this point, for browsers not using the custom encryption/obfuscation layer like unbranded Chromium, the twice +decrypted key is the actual encryption key that could be used to decrypt the stored passwords. + +For other browsers like Google Chrome, some additional processing is required. The decrypted key is actually a blob of structured data that could take multiple forms: + +1. exactly 32 bytes: plain key, nothing to be done more in this case +2. blob starts with 0x01: the key is encrypted with a fixed AES key found in Google Chrome binary, a random IV is stored + in the blob as well +3. blob starts with 0x02: the key is encrypted with a fixed ChaCha20 key found in Google Chrome binary, a random IV is + stored in the blob as well +4. blob starts with 0x03: the blob contains a random key, encrypted with CNG API with a random key stored in the + **system keychain** under the name `Google Chromekey1`. After that key is decryped (under **system level** impersonation again), the key is xor'ed with a fixed key from the Chrome binary and the it is used to decrypt the key from the last DPAPI decryption stage. + +The decrypted key is sent back to the client via the named pipe. `bitwarden_chromium_import_helper.exe` connects to the +pipe and writes the result. + +The response can indicate success or failure: + +- On success: a Base64-encoded string. +- On failure: an error message prefixed with `!`. + +In either case, the response is sent to the named pipe server created by the client. The client responds with `ok` +(ignored). + +Finally, `bitwarden_chromium_import_helper.exe` exits. + +### 3. Back to the Client Library + +The decrypted Base64-encoded key is returned from `bitwarden_chromium_import_helper.exe` to the named pipe server at the +user level. The key is used to decrypt the stored passwords and notes. + +### TL;DR Steps + +1. **Client side:** + + 1. Extract the encrypted key from Chrome’s settings. + 2. Create a named pipe server. + 3. Launch `bitwarden_chromium_import_helper.exe` with **ADMINISTRATOR** privileges, passing the key to be decrypted + via CLI arguments. + 4. Wait for the response from `bitwarden_chromium_import_helper.exe`. + +2. **Admin side:** + + 1. Start. + 2. Ensure `SE_DEBUG_PRIVILEGE` is enabled (not strictly necessary in tests). + 3. Impersonate a system process such as `services.exe` or `winlogon.exe`. + 4. Decrypt the key using DPAPI at the **SYSTEM** level. + 5. Decrypt it again with DPAPI at the **USER** level. + 6. (For Chrome only) Decrypt again with the hard-coded key, possibly at the **system level** again (see above). + 5. Send the result or error back via the named pipe. + 6. Exit. + +3. **Back on the client side:** + 1. Receive the master key. + 2. Shutdown the pipe server. + 3. Use the master key to read and decrypt stored passwords from Chrome, Brave, Edge, etc. diff --git a/apps/desktop/desktop_native/chromium_importer/build.rs b/apps/desktop/desktop_native/chromium_importer/build.rs new file mode 100644 index 00000000000..5791e63f036 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/build.rs @@ -0,0 +1,15 @@ +include!("config_constants.rs"); + +fn main() { + println!("cargo:rerun-if-changed=config_constants.rs"); + + if cfg!(not(debug_assertions)) { + if ENABLE_DEVELOPER_LOGGING { + panic!("ENABLE_DEVELOPER_LOGGING must be false in release builds"); + } + + if !ENABLE_SIGNATURE_VALIDATION { + panic!("ENABLE_SIGNATURE_VALIDATION must be true in release builds"); + } + } +} diff --git a/apps/desktop/desktop_native/chromium_importer/config_constants.rs b/apps/desktop/desktop_native/chromium_importer/config_constants.rs new file mode 100644 index 00000000000..26397b13714 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/config_constants.rs @@ -0,0 +1,12 @@ +// Enable this to log to a file. The way this executable is used, it's not easy to debug and the stdout gets lost. +// This is intended for development time only. +pub const ENABLE_DEVELOPER_LOGGING: bool = false; + +// The absolute path to log file when developer logging is enabled +// Change this to a suitable path for your environment +pub const LOG_FILENAME: &str = "c:\\path\\to\\log.txt"; + +/// Ensure the signature of the helper and main binary is validated in production builds +/// +/// This must be true in release builds but may be disabled in debug builds for testing. +pub const ENABLE_SIGNATURE_VALIDATION: bool = true; diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs similarity index 58% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs rename to apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs index 8179a10213d..e57b40b5778 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs @@ -1,18 +1,21 @@ -use std::path::{Path, PathBuf}; -use std::sync::LazyLock; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::LazyLock, +}; use anyhow::{anyhow, Result}; use async_trait::async_trait; +use dirs; use hex::decode; -use homedir::my_home; use rusqlite::{params, Connection}; -// Platform-specific code -#[cfg_attr(target_os = "linux", path = "linux.rs")] -#[cfg_attr(target_os = "windows", path = "windows.rs")] -#[cfg_attr(target_os = "macos", path = "macos.rs")] mod platform; +pub(crate) use platform::SUPPORTED_BROWSERS as PLATFORM_SUPPORTED_BROWSERS; +#[cfg(target_os = "windows")] +pub use platform::*; + // // Public API // @@ -22,10 +25,7 @@ pub struct ProfileInfo { pub name: String, pub folder: String, - #[allow(dead_code)] pub account_name: Option, - - #[allow(dead_code)] pub account_email: Option, } @@ -50,21 +50,27 @@ pub enum LoginImportResult { Failure(LoginImportFailure), } -// TODO: Make thus async -pub fn get_installed_browsers() -> Result> { - let mut browsers = Vec::with_capacity(SUPPORTED_BROWSER_MAP.len()); - - for (browser, config) in SUPPORTED_BROWSER_MAP.iter() { - let data_dir = get_browser_data_dir(config)?; - if data_dir.exists() { - browsers.push((*browser).to_string()); - } - } - - Ok(browsers) +pub trait InstalledBrowserRetriever { + fn get_installed_browsers() -> Result>; +} + +pub struct DefaultInstalledBrowserRetriever {} + +impl InstalledBrowserRetriever for DefaultInstalledBrowserRetriever { + fn get_installed_browsers() -> Result> { + let mut browsers = Vec::with_capacity(SUPPORTED_BROWSER_MAP.len()); + + for (browser, config) in SUPPORTED_BROWSER_MAP.iter() { + let data_dir = get_browser_data_dir(config)?; + if data_dir.exists() { + browsers.push((*browser).to_string()); + } + } + + Ok(browsers) + } } -// TODO: Make thus async pub fn get_available_profiles(browser_name: &String) -> Result> { let (_, local_state) = load_local_state_for_browser(browser_name)?; Ok(get_profile_info(&local_state)) @@ -82,14 +88,15 @@ pub async fn import_logins( let local_logins = get_logins(&data_dir, profile_id, "Login Data") .map_err(|e| anyhow!("Failed to query logins: {}", e))?; - // This is not available in all browsers, but there's no harm in trying. If the file doesn't exist we just get an empty vector. + // This is not available in all browsers, but there's no harm in trying. If the file doesn't + // exist we just get an empty vector. let account_logins = get_logins(&data_dir, profile_id, "Login Data For Account") .map_err(|e| anyhow!("Failed to query logins: {}", e))?; // TODO: Do we need a better merge strategy? Maybe ignore duplicates at least? - // TODO: Should we also ignore an error from one of the two imports? If one is successful and the other fails, - // should we still return the successful ones? At the moment it doesn't fail for a missing file, only when - // something goes really wrong. + // TODO: Should we also ignore an error from one of the two imports? If one is successful and + // the other fails, should we still return the successful ones? At the moment it + // doesn't fail for a missing file, only when something goes really wrong. let all_logins = local_logins .into_iter() .chain(account_logins.into_iter()) @@ -104,13 +111,13 @@ pub async fn import_logins( // Private // -#[derive(Debug)] -struct BrowserConfig { - name: &'static str, - data_dir: &'static str, +#[derive(Debug, Clone, Copy)] +pub(crate) struct BrowserConfig { + pub name: &'static str, + pub data_dir: &'static str, } -static SUPPORTED_BROWSER_MAP: LazyLock< +pub(crate) static SUPPORTED_BROWSER_MAP: LazyLock< std::collections::HashMap<&'static str, &'static BrowserConfig>, > = LazyLock::new(|| { platform::SUPPORTED_BROWSERS @@ -120,8 +127,7 @@ static SUPPORTED_BROWSER_MAP: LazyLock< }); fn get_browser_data_dir(config: &BrowserConfig) -> Result { - let dir = my_home() - .map_err(|_| anyhow!("Home directory not found"))? + let dir = dirs::home_dir() .ok_or_else(|| anyhow!("Home directory not found"))? .join(config.data_dir); Ok(dir) @@ -132,12 +138,12 @@ fn get_browser_data_dir(config: &BrowserConfig) -> Result { // #[async_trait] -trait CryptoService: Send { +pub(crate) trait CryptoService: Send { async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result; } #[derive(serde::Deserialize, Clone)] -struct LocalState { +pub(crate) struct LocalState { profile: AllProfiles, #[allow(dead_code)] os_crypt: Option, @@ -145,13 +151,13 @@ struct LocalState { #[derive(serde::Deserialize, Clone)] struct AllProfiles { - info_cache: std::collections::HashMap, + info_cache: HashMap, } #[derive(serde::Deserialize, Clone)] struct OneProfile { name: String, - gaia_name: Option, + gaia_id: Option, user_name: Option, } @@ -190,16 +196,21 @@ fn load_local_state(browser_dir: &Path) -> Result { } fn get_profile_info(local_state: &LocalState) -> Vec { - let mut profile_infos = Vec::new(); - for (name, info) in local_state.profile.info_cache.iter() { - profile_infos.push(ProfileInfo { - name: info.name.clone(), - folder: name.clone(), - account_name: info.gaia_name.clone(), + local_state + .profile + .info_cache + .iter() + .map(|(folder, info)| ProfileInfo { + name: if !info.name.trim().is_empty() { + info.name.clone() + } else { + folder.clone() + }, + folder: folder.clone(), + account_name: info.gaia_id.clone(), account_email: info.user_name.clone(), - }); - } - profile_infos + }) + .collect() } struct EncryptedLogin { @@ -256,17 +267,16 @@ fn hex_to_bytes(hex: &str) -> Vec { decode(hex).unwrap_or_default() } -fn does_table_exist(conn: &Connection, table_name: &str) -> Result { - let mut stmt = conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?1")?; - let exists = stmt.exists(params![table_name])?; - Ok(exists) +fn table_exist(conn: &Connection, table_name: &str) -> Result { + conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?1")? + .exists(params![table_name]) } fn query_logins(db_path: &str) -> Result, rusqlite::Error> { let conn = Connection::open(db_path)?; - let have_logins = does_table_exist(&conn, "logins")?; - let have_password_notes = does_table_exist(&conn, "password_notes")?; + let have_logins = table_exist(&conn, "logins")?; + let have_password_notes = table_exist(&conn, "password_notes")?; if !have_logins || !have_password_notes { return Ok(vec![]); } @@ -300,10 +310,7 @@ fn query_logins(db_path: &str) -> Result, rusqlite::Error> { }) })?; - let mut logins = Vec::new(); - for login in logins_iter { - logins.push(login?); - } + let logins = logins_iter.collect::, _>>()?; Ok(logins) } @@ -348,3 +355,111 @@ async fn decrypt_login( }), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_local_state(profiles: Vec<(&str, &str, Option<&str>, Option<&str>)>) -> LocalState { + let info_cache = profiles + .into_iter() + .map(|(folder, name, gaia_id, user_name)| { + ( + folder.to_string(), + OneProfile { + name: name.to_string(), + gaia_id: gaia_id.map(|s| s.to_string()), + user_name: user_name.map(|s| s.to_string()), + }, + ) + }) + .collect::>(); + + LocalState { + profile: AllProfiles { info_cache }, + os_crypt: None, + } + } + + #[test] + fn test_get_profile_info_basic() { + let local_state = make_local_state(vec![ + ( + "Profile 1", + "User 1", + Some("Account 1"), + Some("email1@example.com"), + ), + ( + "Profile 2", + "User 2", + Some("Account 2"), + Some("email2@example.com"), + ), + ]); + let infos = get_profile_info(&local_state); + assert_eq!(infos.len(), 2); + + let profile1 = infos.iter().find(|p| p.folder == "Profile 1").unwrap(); + assert_eq!(profile1.name, "User 1"); + assert_eq!(profile1.account_name.as_deref(), Some("Account 1")); + assert_eq!( + profile1.account_email.as_deref(), + Some("email1@example.com") + ); + + let profile2 = infos.iter().find(|p| p.folder == "Profile 2").unwrap(); + assert_eq!(profile2.name, "User 2"); + assert_eq!(profile2.account_name.as_deref(), Some("Account 2")); + assert_eq!( + profile2.account_email.as_deref(), + Some("email2@example.com") + ); + } + + #[test] + fn test_get_profile_info_empty_name() { + let local_state = make_local_state(vec![( + "ProfileX", + "", + Some("AccountX"), + Some("emailx@example.com"), + )]); + let infos = get_profile_info(&local_state); + assert_eq!(infos.len(), 1); + assert_eq!(infos[0].name, "ProfileX"); + assert_eq!(infos[0].folder, "ProfileX"); + } + + #[test] + fn test_get_profile_info_none_fields() { + let local_state = make_local_state(vec![("ProfileY", "NameY", None, None)]); + let infos = get_profile_info(&local_state); + assert_eq!(infos.len(), 1); + assert_eq!(infos[0].name, "NameY"); + assert_eq!(infos[0].account_name, None); + assert_eq!(infos[0].account_email, None); + } + + #[test] + fn test_get_profile_info_multiple_profiles() { + let local_state = make_local_state(vec![ + ("P1", "N1", Some("A1"), Some("E1")), + ("P2", "", None, None), + ("P3", "N3", Some("A3"), None), + ]); + let infos = get_profile_info(&local_state); + assert_eq!(infos.len(), 3); + + let p1 = infos.iter().find(|p| p.folder == "P1").unwrap(); + assert_eq!(p1.name, "N1"); + + let p2 = infos.iter().find(|p| p.folder == "P2").unwrap(); + assert_eq!(p2.name, "P2"); + + let p3 = infos.iter().find(|p| p.folder == "P3").unwrap(); + assert_eq!(p3.name, "N3"); + assert_eq!(p3.account_name.as_deref(), Some("A3")); + assert_eq!(p3.account_email, None); + } +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs similarity index 93% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs rename to apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs index 0ead034a4b2..14e38797640 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs @@ -4,16 +4,18 @@ use anyhow::{anyhow, Result}; use async_trait::async_trait; use oo7::XDG_SCHEMA_ATTRIBUTE; -use crate::chromium::{BrowserConfig, CryptoService, LocalState}; - -mod util; +use crate::{ + chromium::{BrowserConfig, CryptoService, LocalState}, + util, +}; // // Public API // -// TODO: It's possible that there might be multiple possible data directories, depending on the installation method (e.g., snap, flatpak, etc.). -pub const SUPPORTED_BROWSERS: [BrowserConfig; 4] = [ +// TODO: It's possible that there might be multiple possible data directories, depending on the +// installation method (e.g., snap, flatpak, etc.). +pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ BrowserConfig { name: "Chrome", data_dir: ".config/google-chrome", @@ -32,7 +34,7 @@ pub const SUPPORTED_BROWSERS: [BrowserConfig; 4] = [ }, ]; -pub fn get_crypto_service( +pub(crate) fn get_crypto_service( browser_name: &String, _local_state: &LocalState, ) -> Result> { diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/macos.rs similarity index 95% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs rename to apps/desktop/desktop_native/chromium_importer/src/chromium/platform/macos.rs index d9aeff68f2b..5d0b4f0c75c 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/macos.rs @@ -2,15 +2,16 @@ use anyhow::{anyhow, Result}; use async_trait::async_trait; use security_framework::passwords::get_generic_password; -use crate::chromium::{BrowserConfig, CryptoService, LocalState}; - -mod util; +use crate::{ + chromium::{BrowserConfig, CryptoService, LocalState}, + util, +}; // // Public API // -pub const SUPPORTED_BROWSERS: [BrowserConfig; 7] = [ +pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ BrowserConfig { name: "Chrome", data_dir: "Library/Application Support/Google/Chrome", @@ -41,7 +42,7 @@ pub const SUPPORTED_BROWSERS: [BrowserConfig; 7] = [ }, ]; -pub fn get_crypto_service( +pub(crate) fn get_crypto_service( browser_name: &String, _local_state: &LocalState, ) -> Result> { diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs new file mode 100644 index 00000000000..fe497de0773 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs @@ -0,0 +1,9 @@ +// Platform-specific code +#[cfg_attr(target_os = "linux", path = "linux.rs")] +#[cfg_attr(target_os = "windows", path = "windows/mod.rs")] +#[cfg_attr(target_os = "macos", path = "macos.rs")] +mod native; + +// Windows exposes public const +#[allow(unused_imports)] +pub use native::*; diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe.rs new file mode 100644 index 00000000000..a76f7b95e5c --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe.rs @@ -0,0 +1,180 @@ +use std::{ffi::OsStr, os::windows::ffi::OsStrExt}; + +use anyhow::{anyhow, Result}; +use tokio::{ + io::{self, AsyncReadExt, AsyncWriteExt}, + net::windows::named_pipe::{NamedPipeServer, ServerOptions}, + sync::mpsc::channel, + task::JoinHandle, + time::{timeout, Duration}, +}; +use tracing::debug; +use windows::{ + core::PCWSTR, + Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_HIDE}, +}; + +use super::abe_config; + +const WAIT_FOR_ADMIN_MESSAGE_TIMEOUT_SECS: u64 = 30; + +fn start_tokio_named_pipe_server( + pipe_name: &'static str, + process_message: F, +) -> Result>> +where + F: Fn(&str) -> String + Send + Sync + Clone + 'static, +{ + debug!("Starting Tokio named pipe server on: {}", pipe_name); + + // The first server needs to be constructed early so that clients can be correctly + // connected. Otherwise calling .wait will cause the client to error. + // Here we also make use of `first_pipe_instance`, which will ensure that + // there are no other servers up and running already. + let mut server = ServerOptions::new() + .first_pipe_instance(true) + .create(pipe_name)?; + + debug!("Named pipe server created and listening..."); + + // Spawn the server loop. + let server_task = tokio::spawn(async move { + loop { + // Wait for a client to connect. + match server.connect().await { + Ok(_) => { + debug!("Client connected to named pipe"); + let connected_client = server; + + // Construct the next server to be connected before sending the one + // we already have off to a task. This ensures that the server + // isn't closed (after it's done in the task) before a new one is + // available. Otherwise the client might error with + // `io::ErrorKind::NotFound`. + server = ServerOptions::new().create(pipe_name)?; + + // Handle the connected client in a separate task + let process_message_clone = process_message.clone(); + let _client_task = tokio::spawn(async move { + if let Err(e) = handle_client(connected_client, process_message_clone).await + { + debug!("Error handling client: {}", e); + } + }); + } + Err(e) => { + debug!("Failed to connect to client: {}", e); + continue; + } + } + } + }); + + Ok(server_task) +} + +async fn handle_client(mut client: NamedPipeServer, process_message: F) -> Result<()> +where + F: Fn(&str) -> String, +{ + debug!("Handling new client connection"); + + loop { + // Read a message from the client + let mut buffer = vec![0u8; 64 * 1024]; + match client.read(&mut buffer).await { + Ok(0) => { + debug!("Client disconnected (0 bytes read)"); + return Ok(()); + } + Ok(bytes_read) => { + let message = String::from_utf8_lossy(&buffer[..bytes_read]); + let preview = message.chars().take(16).collect::(); + debug!( + "Received from client: '{}...' ({} bytes)", + preview, bytes_read, + ); + + let response = process_message(&message); + match client.write_all(response.as_bytes()).await { + Ok(_) => { + debug!("Sent response to client ({} bytes)", response.len()); + } + Err(e) => { + return Err(anyhow!("Failed to send response to client: {}", e)); + } + } + } + Err(e) => { + return Err(anyhow!("Failed to read from client: {}", e)); + } + } + } +} + +pub(crate) async fn decrypt_with_admin_exe(admin_exe: &str, encrypted: &str) -> Result { + let (tx, mut rx) = channel::(1); + + debug!( + "Starting named pipe server at '{}'...", + abe_config::ADMIN_TO_USER_PIPE_NAME + ); + + let server = match start_tokio_named_pipe_server( + abe_config::ADMIN_TO_USER_PIPE_NAME, + move |message: &str| { + let _ = tx.try_send(message.to_string()); + "ok".to_string() + }, + ) { + Ok(server) => server, + Err(e) => return Err(anyhow!("Failed to start named pipe server: {}", e)), + }; + + debug!("Launching '{}' as ADMINISTRATOR...", admin_exe); + decrypt_with_admin_exe_internal(admin_exe, encrypted); + + debug!("Waiting for message from {}...", admin_exe); + let message = match timeout( + Duration::from_secs(WAIT_FOR_ADMIN_MESSAGE_TIMEOUT_SECS), + rx.recv(), + ) + .await + { + Ok(Some(msg)) => msg, + Ok(None) => return Err(anyhow!("Channel closed without message from {}", admin_exe)), + Err(_) => return Err(anyhow!("Timeout waiting for message from {}", admin_exe)), + }; + + debug!("Shutting down the pipe server..."); + server.abort(); + + Ok(message) +} + +fn decrypt_with_admin_exe_internal(admin_exe: &str, encrypted: &str) { + // Convert strings to wide strings for Windows API + let exe_wide = OsStr::new(admin_exe) + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let runas_wide = OsStr::new("runas") + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let parameters = OsStr::new(&format!(r#"--encrypted "{}""#, encrypted)) + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + + unsafe { + ShellExecuteW( + None, + PCWSTR(runas_wide.as_ptr()), + PCWSTR(exe_wide.as_ptr()), + PCWSTR(parameters.as_ptr()), + None, + SW_HIDE, + ); + } +} diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe_config.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe_config.rs new file mode 100644 index 00000000000..66b1d3b8435 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe_config.rs @@ -0,0 +1,2 @@ +pub const ADMIN_TO_USER_PIPE_NAME: &str = + r"\\.\pipe\bitwarden-to-bitwarden-chromium-importer-helper"; diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/crypto.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/crypto.rs new file mode 100644 index 00000000000..60f7b806033 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/crypto.rs @@ -0,0 +1,54 @@ +use anyhow::{anyhow, Result}; +use windows::Win32::{ + Foundation::{LocalFree, HLOCAL}, + Security::Cryptography::{CryptUnprotectData, CRYPT_INTEGER_BLOB}, +}; + +/// Rust friendly wrapper around CryptUnprotectData +/// +/// Decrypts the data passed in using the `CryptUnprotectData` api. +pub fn crypt_unprotect_data(data: &[u8], flags: u32) -> Result> { + if data.is_empty() { + return Ok(Vec::new()); + } + + let data_in = CRYPT_INTEGER_BLOB { + cbData: data.len() as u32, + pbData: data.as_ptr() as *mut u8, + }; + + let mut data_out = CRYPT_INTEGER_BLOB::default(); + + let result = unsafe { + CryptUnprotectData( + &data_in, + None, // ppszDataDescr: Option<*mut PWSTR> + None, // pOptionalEntropy: Option<*const CRYPT_INTEGER_BLOB> + None, // pvReserved: Option<*const std::ffi::c_void> + None, // pPromptStruct: Option<*const CRYPTPROTECT_PROMPTSTRUCT> + flags, // dwFlags: u32 + &mut data_out, + ) + }; + + if result.is_err() { + return Err(anyhow!("CryptUnprotectData failed")); + } + + if data_out.pbData.is_null() || data_out.cbData == 0 { + return Ok(Vec::new()); + } + + let output_slice = + unsafe { std::slice::from_raw_parts(data_out.pbData, data_out.cbData as usize) }; + + // SAFETY: Must copy data before calling LocalFree() below. + // Calling to_vec() after LocalFree() causes use-after-free bugs. + let output = output_slice.to_vec(); + + unsafe { + LocalFree(Some(HLOCAL(data_out.pbData as *mut _))); + } + + Ok(output) +} diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/mod.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/mod.rs new file mode 100644 index 00000000000..9cc89ed2161 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/mod.rs @@ -0,0 +1,274 @@ +use std::path::{Path, PathBuf}; + +use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit, Nonce}; +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; + +use crate::{ + chromium::{BrowserConfig, CryptoService, LocalState}, + util, +}; +mod abe; +mod abe_config; +mod crypto; +mod signature; + +pub use abe_config::ADMIN_TO_USER_PIPE_NAME; +pub use crypto::*; +pub use signature::*; + +// +// Public API +// + +pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ + BrowserConfig { + name: "Brave", + data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data", + }, + BrowserConfig { + name: "Chrome", + data_dir: "AppData/Local/Google/Chrome/User Data", + }, + BrowserConfig { + name: "Chromium", + data_dir: "AppData/Local/Chromium/User Data", + }, + BrowserConfig { + name: "Microsoft Edge", + data_dir: "AppData/Local/Microsoft/Edge/User Data", + }, + BrowserConfig { + name: "Opera", + data_dir: "AppData/Roaming/Opera Software/Opera Stable", + }, + BrowserConfig { + name: "Vivaldi", + data_dir: "AppData/Local/Vivaldi/User Data", + }, +]; + +pub(crate) fn get_crypto_service( + _browser_name: &str, + local_state: &LocalState, +) -> Result> { + Ok(Box::new(WindowsCryptoService::new(local_state))) +} + +// +// Private +// + +const ADMIN_EXE_FILENAME: &str = "bitwarden_chromium_import_helper.exe"; + +// +// CryptoService +// +struct WindowsCryptoService { + master_key: Option>, + encrypted_key: Option, + app_bound_encrypted_key: Option, +} + +impl WindowsCryptoService { + pub(crate) fn new(local_state: &LocalState) -> Self { + Self { + master_key: None, + encrypted_key: local_state + .os_crypt + .as_ref() + .and_then(|c| c.encrypted_key.clone()), + app_bound_encrypted_key: local_state + .os_crypt + .as_ref() + .and_then(|c| c.app_bound_encrypted_key.clone()), + } + } +} + +#[async_trait] +impl CryptoService for WindowsCryptoService { + async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result { + if encrypted.is_empty() { + return Ok(String::new()); + } + + // On Windows only v10 and v20 are supported at the moment + let (version, no_prefix) = + util::split_encrypted_string_and_validate(encrypted, &["v10", "v20"])?; + + // v10 is already stripped; Windows Chrome uses AES-GCM: [12 bytes IV][ciphertext][16 bytes + // auth tag] + const IV_SIZE: usize = 12; + const TAG_SIZE: usize = 16; + const MIN_LENGTH: usize = IV_SIZE + TAG_SIZE; + + if no_prefix.len() < MIN_LENGTH { + return Err(anyhow!( + "Corrupted entry: expected at least {} bytes, got {} bytes", + MIN_LENGTH, + no_prefix.len() + )); + } + + // Allow empty passwords + if no_prefix.len() == MIN_LENGTH { + return Ok(String::new()); + } + + if self.master_key.is_none() { + self.master_key = Some(self.get_master_key(version).await?); + } + + let key = self + .master_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + let key = Key::::from_slice(key); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(&no_prefix[..IV_SIZE]); + + let decrypted_bytes = cipher + .decrypt(nonce, no_prefix[IV_SIZE..].as_ref()) + .map_err(|e| anyhow!("Decryption failed: {}", e))?; + + let plaintext = String::from_utf8(decrypted_bytes) + .map_err(|e| anyhow!("Failed to convert decrypted data to UTF-8: {}", e))?; + + Ok(plaintext) + } +} + +impl WindowsCryptoService { + async fn get_master_key(&mut self, version: &str) -> Result> { + match version { + "v10" => self.get_master_key_v10(), + "v20" => self.get_master_key_v20().await, + _ => Err(anyhow!("Unsupported version: {}", version)), + } + } + + fn get_master_key_v10(&mut self) -> Result> { + if self.encrypted_key.is_none() { + return Err(anyhow!( + "Encrypted master key is not found in the local browser state" + )); + } + + let key = self + .encrypted_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + let key_bytes = BASE64_STANDARD + .decode(key) + .map_err(|e| anyhow!("Encrypted master key is not a valid base64 string: {}", e))?; + + if key_bytes.len() <= 5 || &key_bytes[..5] != b"DPAPI" { + return Err(anyhow!("Encrypted master key is not encrypted with DPAPI")); + } + + let key = crypt_unprotect_data(&key_bytes[5..], 0) + .map_err(|e| anyhow!("Failed to unprotect the master key: {}", e))?; + + Ok(key) + } + + async fn get_master_key_v20(&mut self) -> Result> { + if self.app_bound_encrypted_key.is_none() { + return Err(anyhow!( + "Encrypted master key is not found in the local browser state" + )); + } + + let admin_exe_path = get_admin_exe_path()?; + + if !verify_signature(&admin_exe_path)? { + return Err(anyhow!("Helper executable signature is not valid")); + } + + let admin_exe_str = admin_exe_path + .to_str() + .ok_or_else(|| anyhow!("Failed to convert {} path to string", ADMIN_EXE_FILENAME))?; + + let key_base64 = abe::decrypt_with_admin_exe( + admin_exe_str, + self.app_bound_encrypted_key + .as_ref() + .expect("app_bound_encrypted_key should not be None"), + ) + .await?; + + if let Some(error_message) = key_base64.strip_prefix('!') { + return Err(anyhow!( + "Failed to decrypt the master key: {}", + error_message + )); + } + + let key = BASE64_STANDARD.decode(&key_base64)?; + Ok(key) + } +} + +fn get_admin_exe_path() -> Result { + let current_exe_full_path = std::env::current_exe() + .map_err(|e| anyhow!("Failed to get current executable path: {}", e))?; + + let exe_name = current_exe_full_path + .file_name() + .ok_or_else(|| anyhow!("Failed to get file name from current executable path"))?; + + let admin_exe_full_path = if exe_name.eq_ignore_ascii_case("electron.exe") { + get_debug_admin_exe_path()? + } else { + get_dist_admin_exe_path(¤t_exe_full_path)? + }; + + // check if bitwarden_chromium_import_helper.exe exists + if !admin_exe_full_path.exists() { + return Err(anyhow!( + "{} not found at path: {:?}", + ADMIN_EXE_FILENAME, + admin_exe_full_path + )); + } + + Ok(admin_exe_full_path) +} + +fn get_dist_admin_exe_path(current_exe_full_path: &Path) -> Result { + let admin_exe = current_exe_full_path + .parent() + .map(|p| p.join(ADMIN_EXE_FILENAME)) + .ok_or_else(|| anyhow!("Failed to get parent directory of current executable"))?; + + Ok(admin_exe) +} + +// Try to find bitwarden_chromium_import_helper.exe in debug build folders. This might not cover all +// the cases. Tested on `npm run electron` from apps/desktop and apps/desktop/desktop_native. +fn get_debug_admin_exe_path() -> Result { + let current_dir = std::env::current_dir()?; + let folder_name = current_dir + .file_name() + .ok_or_else(|| anyhow!("Failed to get folder name from current directory"))?; + match folder_name.to_str() { + Some("desktop") => Ok(get_target_admin_exe_path( + current_dir.join("desktop_native"), + )), + Some("desktop_native") => Ok(get_target_admin_exe_path(current_dir)), + _ => Err(anyhow!( + "Cannot determine {} path from current directory: {}", + ADMIN_EXE_FILENAME, + current_dir.display() + )), + } +} + +fn get_target_admin_exe_path(desktop_native_dir: PathBuf) -> PathBuf { + desktop_native_dir + .join("target") + .join("debug") + .join(ADMIN_EXE_FILENAME) +} diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/signature.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/signature.rs new file mode 100644 index 00000000000..97cf57935b2 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/signature.rs @@ -0,0 +1,39 @@ +use std::path::Path; + +use anyhow::{anyhow, Result}; +use tracing::{debug, info}; +use verifysign::CodeSignVerifier; + +use crate::config::ENABLE_SIGNATURE_VALIDATION; + +pub const EXPECTED_SIGNATURE_SHA256_THUMBPRINT: &str = + "9f6680c4720dbf66d1cb8ed6e328f58e42523badc60d138c7a04e63af14ea40d"; + +pub fn verify_signature(path: &Path) -> Result { + if !ENABLE_SIGNATURE_VALIDATION { + info!( + "Signature validation is disabled. Skipping verification for: {}", + path.display() + ); + return Ok(true); + } + + info!("verifying signature of: {}", path.display()); + + let verifier = CodeSignVerifier::for_file(path) + .map_err(|e| anyhow!("verifysign init failed for {}: {:?}", path.display(), e))?; + + let signature = verifier + .verify() + .map_err(|e| anyhow!("verifysign verify failed for {}: {:?}", path.display(), e))?; + + // Dump signature fields for debugging/inspection + debug!("Signature fields:"); + debug!(" Subject Name: {:?}", signature.subject_name()); + debug!(" Issuer Name: {:?}", signature.issuer_name()); + debug!(" SHA1 Thumbprint: {:?}", signature.sha1_thumbprint()); + debug!(" SHA256 Thumbprint: {:?}", signature.sha256_thumbprint()); + debug!(" Serial Number: {:?}", signature.serial()); + + Ok(signature.sha256_thumbprint() == EXPECTED_SIGNATURE_SHA256_THUMBPRINT) +} diff --git a/apps/desktop/desktop_native/chromium_importer/src/lib.rs b/apps/desktop/desktop_native/chromium_importer/src/lib.rs new file mode 100644 index 00000000000..d03e4cdf496 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/lib.rs @@ -0,0 +1,9 @@ +#![doc = include_str!("../README.md")] + +pub mod config { + include!("../config_constants.rs"); +} + +pub mod chromium; +pub mod metadata; +mod util; diff --git a/apps/desktop/desktop_native/chromium_importer/src/metadata.rs b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs new file mode 100644 index 00000000000..114c9f8df84 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs @@ -0,0 +1,209 @@ +use std::collections::{HashMap, HashSet}; + +use crate::chromium::{InstalledBrowserRetriever, PLATFORM_SUPPORTED_BROWSERS}; + +/// Mechanisms that load data into the importer +pub struct NativeImporterMetadata { + /// Identifies the importer + pub id: String, + /// Describes the strategies used to obtain imported data + pub loaders: Vec<&'static str>, + /// Identifies the instructions for the importer + pub instructions: &'static str, +} + +/// Returns a map of supported importers based on the current platform. +/// +/// Only browsers listed in PLATFORM_SUPPORTED_BROWSERS will have the "chromium" loader. +/// All importers will have the "file" loader. +pub fn get_supported_importers( +) -> HashMap { + let mut map = HashMap::new(); + + // Check for installed browsers + let installed_browsers = T::get_installed_browsers().unwrap_or_default(); + + const IMPORTERS: &[(&str, &str)] = &[ + ("chromecsv", "Chrome"), + ("chromiumcsv", "Chromium"), + ("bravecsv", "Brave"), + ("operacsv", "Opera"), + ("vivaldicsv", "Vivaldi"), + ("edgecsv", "Microsoft Edge"), + ]; + + let supported: HashSet<&'static str> = + PLATFORM_SUPPORTED_BROWSERS.iter().map(|b| b.name).collect(); + + for (id, browser_name) in IMPORTERS { + let mut loaders: Vec<&'static str> = vec!["file"]; + if supported.contains(browser_name) { + loaders.push("chromium"); + } + + if installed_browsers.contains(&browser_name.to_string()) { + map.insert( + id.to_string(), + NativeImporterMetadata { + id: id.to_string(), + loaders, + instructions: "chromium", + }, + ); + } + } + + map +} + +// Tests are cfg-gated based upon OS, and must be compiled/run on each OS for full coverage +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + use crate::chromium::{InstalledBrowserRetriever, SUPPORTED_BROWSER_MAP}; + + pub struct MockInstalledBrowserRetriever {} + + impl InstalledBrowserRetriever for MockInstalledBrowserRetriever { + fn get_installed_browsers() -> Result, anyhow::Error> { + Ok(SUPPORTED_BROWSER_MAP + .keys() + .map(|browser| browser.to_string()) + .collect()) + } + } + + fn map_keys(map: &HashMap) -> HashSet { + map.keys().cloned().collect() + } + + fn get_loaders( + map: &HashMap, + id: &str, + ) -> HashSet<&'static str> { + map.get(id) + .map(|m| m.loaders.iter().copied().collect::>()) + .unwrap_or_default() + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_returns_all_known_importers() { + let map = get_supported_importers::(); + + let expected: HashSet = HashSet::from([ + "chromecsv".to_string(), + "chromiumcsv".to_string(), + "bravecsv".to_string(), + "operacsv".to_string(), + "vivaldicsv".to_string(), + "edgecsv".to_string(), + ]); + assert_eq!(map.len(), expected.len()); + assert_eq!(map_keys(&map), expected); + + for (key, meta) in map.iter() { + assert_eq!(&meta.id, key); + assert_eq!(meta.instructions, "chromium"); + assert!(meta.loaders.iter().any(|l| *l == "file")); + } + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_specific_loaders_match_const_array() { + let map = get_supported_importers::(); + let ids = [ + "chromecsv", + "chromiumcsv", + "bravecsv", + "operacsv", + "vivaldicsv", + "edgecsv", + ]; + for id in ids { + let loaders = get_loaders(&map, id); + assert!(loaders.contains("file")); + assert!(loaders.contains("chromium"), "missing chromium for {id}"); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn returns_all_known_importers() { + let map = get_supported_importers::(); + + let expected: HashSet = HashSet::from([ + "chromecsv".to_string(), + "chromiumcsv".to_string(), + "bravecsv".to_string(), + "operacsv".to_string(), + ]); + assert_eq!(map.len(), expected.len()); + assert_eq!(map_keys(&map), expected); + + for (key, meta) in map.iter() { + assert_eq!(&meta.id, key); + assert_eq!(meta.instructions, "chromium"); + assert!(meta.loaders.iter().any(|l| *l == "file")); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_specific_loaders_match_const_array() { + let map = get_supported_importers::(); + let ids = ["chromecsv", "chromiumcsv", "bravecsv", "operacsv"]; + + for id in ids { + let loaders = get_loaders(&map, id); + assert!(loaders.contains("file")); + assert!(loaders.contains("chromium"), "missing chromium for {id}"); + } + } + + #[cfg(target_os = "windows")] + #[test] + fn returns_all_known_importers() { + let map = get_supported_importers::(); + + let expected: HashSet = HashSet::from([ + "bravecsv".to_string(), + "chromecsv".to_string(), + "chromiumcsv".to_string(), + "edgecsv".to_string(), + "operacsv".to_string(), + "vivaldicsv".to_string(), + ]); + assert_eq!(map.len(), expected.len()); + assert_eq!(map_keys(&map), expected); + + for (key, meta) in map.iter() { + assert_eq!(&meta.id, key); + assert_eq!(meta.instructions, "chromium"); + assert!(meta.loaders.iter().any(|l| *l == "file")); + } + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_specific_loaders_match_const_array() { + let map = get_supported_importers::(); + let ids = [ + "bravecsv", + "chromecsv", + "chromiumcsv", + "edgecsv", + "operacsv", + "vivaldicsv", + ]; + + for id in ids { + let loaders = get_loaders(&map, id); + assert!(loaders.contains("file")); + assert!(loaders.contains("chromium"), "missing chromium for {id}"); + } + } +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs b/apps/desktop/desktop_native/chromium_importer/src/util.rs similarity index 68% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs rename to apps/desktop/desktop_native/chromium_importer/src/util.rs index e9c20ab621d..2dbc6ed005b 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/util.rs @@ -1,9 +1,6 @@ -use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; use anyhow::{anyhow, Result}; -use pbkdf2::{hmac::Hmac, pbkdf2}; -use sha1::Sha1; -pub fn split_encrypted_string(encrypted: &[u8]) -> Result<(&str, &[u8])> { +fn split_encrypted_string(encrypted: &[u8]) -> Result<(&str, &[u8])> { if encrypted.len() < 3 { return Err(anyhow!( "Corrupted entry: invalid encrypted string length, expected at least 3 bytes, got {}", @@ -15,7 +12,14 @@ pub fn split_encrypted_string(encrypted: &[u8]) -> Result<(&str, &[u8])> { Ok((std::str::from_utf8(version)?, password)) } -pub fn split_encrypted_string_and_validate<'a>( +/// A Chromium password consists of three parts: +/// - Version (3 bytes): "v10", "v11", etc. +/// - Cipher text (chunks of 16 bytes) +/// - Padding (1-15 bytes) +/// +/// This function splits the encrypted byte slice into version and cipher text. +/// Padding is included and handled by the underlying cryptographic library. +pub(crate) fn split_encrypted_string_and_validate<'a>( encrypted: &'a [u8], supported_versions: &[&str], ) -> Result<(&'a str, &'a [u8])> { @@ -27,15 +31,23 @@ pub fn split_encrypted_string_and_validate<'a>( Ok((version, password)) } -pub fn decrypt_aes_128_cbc(key: &[u8], iv: &[u8], ciphertext: &[u8]) -> Result> { - let decryptor = cbc::Decryptor::::new_from_slices(key, iv)?; - let plaintext: Vec = decryptor +/// Decrypt using AES-128 in CBC mode. +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn decrypt_aes_128_cbc(key: &[u8], iv: &[u8], ciphertext: &[u8]) -> Result> { + use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; + + cbc::Decryptor::::new_from_slices(key, iv)? .decrypt_padded_vec_mut::(ciphertext) - .map_err(|e| anyhow!("Failed to decrypt: {}", e))?; - Ok(plaintext) + .map_err(|e| anyhow!("Failed to decrypt: {}", e)) } -pub fn derive_saltysalt(password: &[u8], iterations: u32) -> Result> { +/// Derives a PBKDF2 key from the static "saltysalt" salt with the given password and iteration +/// count. +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn derive_saltysalt(password: &[u8], iterations: u32) -> Result> { + use pbkdf2::{hmac::Hmac, pbkdf2}; + use sha1::Sha1; + let mut key = vec![0u8; 16]; pbkdf2::>(password, b"saltysalt", iterations, &mut key) .map_err(|e| anyhow!("Failed to derive master key: {}", e))?; @@ -44,23 +56,6 @@ pub fn derive_saltysalt(password: &[u8], iterations: u32) -> Result> { #[cfg(test)] mod tests { - pub fn generate_vec(length: usize, offset: u8, increment: u8) -> Vec { - (0..length).map(|i| offset + i as u8 * increment).collect() - } - pub fn generate_generic_array>( - offset: u8, - increment: u8, - ) -> GenericArray { - GenericArray::generate(|i| offset + i as u8 * increment) - } - - use aes::cipher::{ - block_padding::Pkcs7, - generic_array::{sequence::GenericSequence, GenericArray}, - ArrayLength, BlockEncryptMut, KeyIvInit, - }; - - const LENGTH16: usize = 16; const LENGTH10: usize = 10; const LENGTH0: usize = 0; @@ -132,8 +127,28 @@ mod tests { run_split_encrypted_string_and_validate_test(false, "v10EncryptMe!", &[]); } + #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn test_decrypt_aes_128_cbc() { + use aes::cipher::{ + block_padding::Pkcs7, + generic_array::{sequence::GenericSequence, GenericArray}, + ArrayLength, BlockEncryptMut, KeyIvInit, + }; + + const LENGTH16: usize = 16; + + fn generate_generic_array>( + offset: u8, + increment: u8, + ) -> GenericArray { + GenericArray::generate(|i| offset + i as u8 * increment) + } + + fn generate_vec(length: usize, offset: u8, increment: u8) -> Vec { + (0..length).map(|i| offset + i as u8 * increment).collect() + } + let offset = 0; let increment = 1; diff --git a/apps/desktop/desktop_native/clippy.toml b/apps/desktop/desktop_native/clippy.toml index a29e019ac02..4441a038635 100644 --- a/apps/desktop/desktop_native/clippy.toml +++ b/apps/desktop/desktop_native/clippy.toml @@ -1,2 +1,10 @@ allow-unwrap-in-tests=true allow-expect-in-tests=true + +disallowed-macros = [ + { path = "log::trace", reason = "Use tracing for logging needs", replacement = "tracing::trace" }, + { path = "log::debug", reason = "Use tracing for logging needs", replacement = "tracing::debug" }, + { path = "log::info", reason = "Use tracing for logging needs", replacement = "tracing::info" }, + { path = "log::warn", reason = "Use tracing for logging needs", replacement = "tracing::warn" }, + { path = "log::error", reason = "Use tracing for logging needs", replacement = "tracing::error" }, +] diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index 36e1a85abc0..dc9246f55c6 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -23,23 +23,15 @@ anyhow = { workspace = true } arboard = { workspace = true, features = ["wayland-data-control"] } base64 = { workspace = true } bitwarden-russh = { workspace = true } -byteorder = { workspace = true } bytes = { workspace = true } cbc = { workspace = true, features = ["alloc"] } +chacha20poly1305 = { workspace = true } dirs = { workspace = true } -ed25519 = { workspace = true, features = ["pkcs8"] } futures = { workspace = true } -homedir = { workspace = true } interprocess = { workspace = true, features = ["tokio"] } -pin-project = { workspace = true } -pkcs8 = { workspace = true, features = ["alloc", "encryption", "pem"] } +memsec = { workspace = true, features = ["alloc_ext"] } rand = { workspace = true } -rsa = { workspace = true } -russh-cryptovec = { workspace = true } -scopeguard = { workspace = true } -secmem-proc = { workspace = true } sha2 = { workspace = true } -ssh-encoding = { workspace = true } ssh-key = { workspace = true, features = [ "encryption", "ed25519", @@ -49,13 +41,17 @@ ssh-key = { workspace = true, features = [ sysinfo = { workspace = true, features = ["windows"] } thiserror = { workspace = true } tokio = { workspace = true, features = ["io-util", "sync", "macros", "net"] } -tokio-stream = { workspace = true, features = ["net"] } tokio-util = { workspace = true, features = ["codec"] } tracing = { workspace = true } typenum = { workspace = true } zeroizing-alloc = { workspace = true } [target.'cfg(windows)'.dependencies] +pin-project = { workspace = true } +scopeguard = { workspace = true } +secmem-proc = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } widestring = { workspace = true, optional = true } windows = { workspace = true, features = [ "Foundation", @@ -64,6 +60,7 @@ windows = { workspace = true, features = [ "Storage_Streams", "Win32_Foundation", "Win32_Security_Credentials", + "Win32_Security_Cryptography", "Win32_System_WinRT", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging", @@ -71,20 +68,20 @@ windows = { workspace = true, features = [ ], optional = true } windows-future = { workspace = true } -[target.'cfg(windows)'.dev-dependencies] -keytar = { 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] -oo7 = { workspace = true } -libc = { workspace = true } 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 } diff --git a/apps/desktop/desktop_native/core/src/biometric/mod.rs b/apps/desktop/desktop_native/core/src/biometric/mod.rs index e4d51f5da9a..6ed5fbe08a7 100644 --- a/apps/desktop/desktop_native/core/src/biometric/mod.rs +++ b/apps/desktop/desktop_native/core/src/biometric/mod.rs @@ -3,16 +3,12 @@ use anyhow::{anyhow, Result}; #[allow(clippy::module_inception)] #[cfg_attr(target_os = "linux", path = "unix.rs")] -#[cfg_attr(target_os = "macos", path = "macos.rs")] -#[cfg_attr(target_os = "windows", path = "windows.rs")] +#[cfg_attr(target_os = "macos", path = "unimplemented.rs")] +#[cfg_attr(target_os = "windows", path = "unimplemented.rs")] mod biometric; -pub use biometric::Biometric; - -#[cfg(target_os = "windows")] -pub mod windows_focus; - use base64::{engine::general_purpose::STANDARD as base64_engine, Engine}; +pub use biometric::Biometric; use sha2::{Digest, Sha256}; use crate::crypto::{self, CipherString}; @@ -86,11 +82,15 @@ impl KeyMaterial { #[cfg(test)] mod tests { - use crate::biometric::{decrypt, encrypt, KeyMaterial}; - use crate::crypto::CipherString; - use base64::{engine::general_purpose::STANDARD as base64_engine, Engine}; use std::str::FromStr; + use base64::{engine::general_purpose::STANDARD as base64_engine, Engine}; + + use crate::{ + biometric::{decrypt, encrypt, KeyMaterial}, + crypto::CipherString, + }; + fn key_material() -> KeyMaterial { KeyMaterial { os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(), diff --git a/apps/desktop/desktop_native/core/src/biometric/macos.rs b/apps/desktop/desktop_native/core/src/biometric/unimplemented.rs similarity index 94% rename from apps/desktop/desktop_native/core/src/biometric/macos.rs rename to apps/desktop/desktop_native/core/src/biometric/unimplemented.rs index ec09d566e1f..3f3d034924a 100644 --- a/apps/desktop/desktop_native/core/src/biometric/macos.rs +++ b/apps/desktop/desktop_native/core/src/biometric/unimplemented.rs @@ -2,7 +2,7 @@ use anyhow::{bail, Result}; use crate::biometric::{KeyMaterial, OsDerivedKey}; -/// The MacOS implementation of the biometric trait. +/// Unimplemented stub for unsupported platforms pub struct Biometric {} impl super::BiometricTrait for Biometric { diff --git a/apps/desktop/desktop_native/core/src/biometric/unix.rs b/apps/desktop/desktop_native/core/src/biometric/unix.rs index 0f6ff8f33dc..3f4f10a1fcf 100644 --- a/apps/desktop/desktop_native/core/src/biometric/unix.rs +++ b/apps/desktop/desktop_native/core/src/biometric/unix.rs @@ -1,18 +1,18 @@ use std::str::FromStr; -use anyhow::Result; +use anyhow::{anyhow, Result}; use base64::Engine; use rand::RngCore; use sha2::{Digest, Sha256}; use tracing::error; - -use crate::biometric::{base64_engine, KeyMaterial, OsDerivedKey}; use zbus::Connection; use zbus_polkit::policykit1::*; use super::{decrypt, encrypt}; -use crate::crypto::CipherString; -use anyhow::anyhow; +use crate::{ + biometric::{base64_engine, KeyMaterial, OsDerivedKey}, + crypto::CipherString, +}; /// The Unix implementation of the biometric trait. pub struct Biometric {} diff --git a/apps/desktop/desktop_native/core/src/biometric/windows.rs b/apps/desktop/desktop_native/core/src/biometric/windows.rs deleted file mode 100644 index 8013c21bf9a..00000000000 --- a/apps/desktop/desktop_native/core/src/biometric/windows.rs +++ /dev/null @@ -1,241 +0,0 @@ -use std::{ffi::c_void, str::FromStr}; - -use anyhow::{anyhow, Result}; -use base64::{engine::general_purpose::STANDARD as base64_engine, Engine}; -use rand::RngCore; -use sha2::{Digest, Sha256}; -use windows::{ - core::{factory, HSTRING}, - Security::Credentials::UI::{ - UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability, - }, - Win32::{ - Foundation::HWND, System::WinRT::IUserConsentVerifierInterop, - UI::WindowsAndMessaging::GetForegroundWindow, - }, -}; -use windows_future::IAsyncOperation; - -use crate::{ - biometric::{KeyMaterial, OsDerivedKey}, - crypto::CipherString, -}; - -use super::{decrypt, encrypt, windows_focus::set_focus}; - -/// The Windows OS implementation of the biometric trait. -pub struct Biometric {} - -impl super::BiometricTrait for Biometric { - // FIXME: Remove unwraps! They panic and terminate the whole application. - #[allow(clippy::unwrap_used)] - async fn prompt(hwnd: Vec, message: String) -> Result { - let h = isize::from_le_bytes(hwnd.clone().try_into().unwrap()); - - let h = h as *mut c_void; - let window = HWND(h); - - // The Windows Hello prompt is displayed inside the application window. For best result we - // should set the window to the foreground and focus it. - set_focus(window); - - // Windows Hello prompt must be in foreground, focused, otherwise the face or fingerprint - // unlock will not work. We get the current foreground window, which will either be the - // Bitwarden desktop app or the browser extension. - let foreground_window = unsafe { GetForegroundWindow() }; - - let interop = factory::()?; - let operation: IAsyncOperation = unsafe { - interop.RequestVerificationForWindowAsync(foreground_window, &HSTRING::from(message))? - }; - let result = operation.get()?; - - match result { - UserConsentVerificationResult::Verified => Ok(true), - _ => Ok(false), - } - } - - async fn available() -> Result { - let ucv_available = UserConsentVerifier::CheckAvailabilityAsync()?.get()?; - - match ucv_available { - UserConsentVerifierAvailability::Available => Ok(true), - UserConsentVerifierAvailability::DeviceBusy => Ok(true), // TODO: Look into removing this and making the check more ad-hoc - _ => Ok(false), - } - } - - fn derive_key_material(challenge_str: Option<&str>) -> Result { - let challenge: [u8; 16] = match challenge_str { - Some(challenge_str) => base64_engine - .decode(challenge_str)? - .try_into() - .map_err(|e: Vec<_>| anyhow!("Expect length {}, got {}", 16, e.len()))?, - None => random_challenge(), - }; - - // Uses a key derived from the iv. This key is not intended to add any security - // but only a place-holder - let key = Sha256::digest(challenge); - let key_b64 = base64_engine.encode(key); - let iv_b64 = base64_engine.encode(challenge); - Ok(OsDerivedKey { key_b64, iv_b64 }) - } - - async fn set_biometric_secret( - service: &str, - account: &str, - secret: &str, - key_material: Option, - iv_b64: &str, - ) -> Result { - let key_material = key_material.ok_or(anyhow!( - "Key material is required for Windows Hello protected keys" - ))?; - - let encrypted_secret = encrypt(secret, &key_material, iv_b64)?; - crate::password::set_password(service, account, &encrypted_secret).await?; - Ok(encrypted_secret) - } - - async fn get_biometric_secret( - service: &str, - account: &str, - key_material: Option, - ) -> Result { - let key_material = key_material.ok_or(anyhow!( - "Key material is required for Windows Hello protected keys" - ))?; - - let encrypted_secret = crate::password::get_password(service, account).await?; - match CipherString::from_str(&encrypted_secret) { - Ok(secret) => { - // If the secret is a CipherString, it is encrypted and we need to decrypt it. - let secret = decrypt(&secret, &key_material)?; - Ok(secret) - } - Err(_) => { - // If the secret is not a CipherString, it is not encrypted and we can return it - // directly. - Ok(encrypted_secret) - } - } - } -} - -fn random_challenge() -> [u8; 16] { - let mut challenge = [0u8; 16]; - rand::rng().fill_bytes(&mut challenge); - challenge -} - -#[cfg(test)] -mod tests { - use super::*; - - use crate::biometric::BiometricTrait; - - #[test] - fn test_derive_key_material() { - let iv_input = "l9fhDUP/wDJcKwmEzcb/3w=="; - let result = ::derive_key_material(Some(iv_input)).unwrap(); - let key = base64_engine.decode(result.key_b64).unwrap(); - assert_eq!(key.len(), 32); - assert_eq!(result.iv_b64, iv_input) - } - - #[test] - fn test_derive_key_material_no_iv() { - let result = ::derive_key_material(None).unwrap(); - let key = base64_engine.decode(result.key_b64).unwrap(); - assert_eq!(key.len(), 32); - let iv = base64_engine.decode(result.iv_b64).unwrap(); - assert_eq!(iv.len(), 16); - } - - #[tokio::test] - #[cfg(feature = "manual_test")] - async fn test_prompt() { - ::prompt( - vec![0, 0, 0, 0, 0, 0, 0, 0], - String::from("Hello from Rust"), - ) - .await - .unwrap(); - } - - #[tokio::test] - #[cfg(feature = "manual_test")] - async fn test_available() { - assert!(::available().await.unwrap()) - } - - #[tokio::test] - #[cfg(feature = "manual_test")] - async fn get_biometric_secret_requires_key() { - let result = ::get_biometric_secret("", "", None).await; - assert!(result.is_err()); - assert_eq!( - result.unwrap_err().to_string(), - "Key material is required for Windows Hello protected keys" - ); - } - - #[tokio::test] - #[cfg(feature = "manual_test")] - async fn get_biometric_secret_handles_unencrypted_secret() { - let test = "test"; - let secret = "password"; - let key_material = KeyMaterial { - os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(), - client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()), - }; - crate::password::set_password(test, test, secret) - .await - .unwrap(); - let result = - ::get_biometric_secret(test, test, Some(key_material)) - .await - .unwrap(); - crate::password::delete_password("test", "test") - .await - .unwrap(); - assert_eq!(result, secret); - } - - #[tokio::test] - #[cfg(feature = "manual_test")] - async fn get_biometric_secret_handles_encrypted_secret() { - let test = "test"; - let secret = - CipherString::from_str("0.l9fhDUP/wDJcKwmEzcb/3w==|uP4LcqoCCj5FxBDP77NV6Q==").unwrap(); // output from test_encrypt - let key_material = KeyMaterial { - os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(), - client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()), - }; - crate::password::set_password(test, test, &secret.to_string()) - .await - .unwrap(); - - let result = - ::get_biometric_secret(test, test, Some(key_material)) - .await - .unwrap(); - crate::password::delete_password("test", "test") - .await - .unwrap(); - assert_eq!(result, "secret"); - } - - #[tokio::test] - async fn set_biometric_secret_requires_key() { - let result = - ::set_biometric_secret("", "", "", None, "").await; - assert!(result.is_err()); - assert_eq!( - result.unwrap_err().to_string(), - "Key material is required for Windows Hello protected keys" - ); - } -} diff --git a/apps/desktop/desktop_native/core/src/biometric/windows_focus.rs b/apps/desktop/desktop_native/core/src/biometric/windows_focus.rs deleted file mode 100644 index ce51f82862d..00000000000 --- a/apps/desktop/desktop_native/core/src/biometric/windows_focus.rs +++ /dev/null @@ -1,28 +0,0 @@ -use windows::{ - core::s, - Win32::{ - Foundation::HWND, - UI::{ - Input::KeyboardAndMouse::SetFocus, - WindowsAndMessaging::{FindWindowA, SetForegroundWindow}, - }, - }, -}; - -/// Searches for a window that looks like a security prompt and set it as focused. -/// Only works when the process has permission to foreground, either by being in foreground -/// Or by being given foreground permission https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow#remarks -pub fn focus_security_prompt() { - let class_name = s!("Credential Dialog Xaml Host"); - let hwnd = unsafe { FindWindowA(class_name, None) }; - if let Ok(hwnd) = hwnd { - set_focus(hwnd); - } -} - -pub(crate) fn set_focus(window: HWND) { - unsafe { - let _ = SetForegroundWindow(window); - let _ = SetFocus(Some(window)); - } -} diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs b/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs new file mode 100644 index 00000000000..ff2abc0686b --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs @@ -0,0 +1,144 @@ +//! This file implements Polkit based system unlock. +//! +//! # Security +//! This section describes the assumed security model and security guarantees achieved. In the +//! required security guarantee is that a locked vault - a running app - cannot be unlocked when the +//! device (user-space) is compromised in this state. +//! +//! When first unlocking the app, the app sends the user-key to this module, which holds it in +//! secure memory, protected by memfd_secret. This makes it inaccessible to other processes, even if +//! they compromise root, a kernel compromise has circumventable best-effort protections. While the +//! app is running this key is held in memory, even if locked. When unlocking, the app will prompt +//! the user via `polkit` to get a yes/no decision on whether to release the key to the app. + +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use tokio::sync::Mutex; +use tracing::{debug, warn}; +use zbus::Connection; +use zbus_polkit::policykit1::{AuthorityProxy, CheckAuthorizationFlags, Subject}; + +use crate::secure_memory::*; + +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>, +} + +impl BiometricLockSystem { + pub fn new() -> Self { + Self { + secure_memory: Arc::new(Mutex::new( + crate::secure_memory::encrypted_memory_store::EncryptedMemoryStore::new(), + )), + } + } +} + +impl Default for BiometricLockSystem { + fn default() -> Self { + Self::new() + } +} + +impl super::BiometricTrait for BiometricLockSystem { + async fn authenticate(&self, _hwnd: Vec, _message: String) -> Result { + polkit_authenticate_bitwarden_policy().await + } + + async fn authenticate_available(&self) -> Result { + polkit_is_bitwarden_policy_available().await + } + + async fn enroll_persistent(&self, _user_id: &str, _key: &[u8]) -> Result<()> { + // Not implemented + Ok(()) + } + + async fn provide_key(&self, user_id: &str, key: &[u8]) { + self.secure_memory + .lock() + .await + .put(user_id.to_string(), key); + } + + async fn unlock(&self, user_id: &str, _hwnd: Vec) -> Result> { + if !polkit_authenticate_bitwarden_policy().await? { + return Err(anyhow!("Authentication failed")); + } + + self.secure_memory + .lock() + .await + .get(user_id) + .ok_or(anyhow!("No key found")) + } + + async fn unlock_available(&self, user_id: &str) -> Result { + Ok(self.secure_memory.lock().await.has(user_id)) + } + + async fn has_persistent(&self, _user_id: &str) -> Result { + Ok(false) + } + + async fn unenroll(&self, user_id: &str) -> Result<(), anyhow::Error> { + self.secure_memory.lock().await.remove(user_id); + Ok(()) + } +} + +/// Perform a polkit authorization against the bitwarden unlock policy. Note: This relies on no +/// custom rules in the system skipping the authorization check, in which case this counts as UV / +/// authentication. +async fn polkit_authenticate_bitwarden_policy() -> Result { + debug!("[Polkit] Authenticating / performing UV"); + + let connection = Connection::system().await?; + let proxy = AuthorityProxy::new(&connection).await?; + let subject = Subject::new_for_owner(std::process::id(), None, None)?; + let details = std::collections::HashMap::new(); + let authorization_result = proxy + .check_authorization( + &subject, + "com.bitwarden.Bitwarden.unlock", + &details, + CheckAuthorizationFlags::AllowUserInteraction.into(), + "", + ) + .await; + + match authorization_result { + Ok(result) => Ok(result.is_authorized), + Err(e) => { + warn!("[Polkit] Error performing authentication: {:?}", e); + Ok(false) + } + } +} + +async fn polkit_is_bitwarden_policy_available() -> Result { + let connection = Connection::system().await?; + let proxy = AuthorityProxy::new(&connection).await?; + let actions = proxy.enumerate_actions("en").await?; + for action in actions { + if action.action_id == "com.bitwarden.Bitwarden.unlock" { + return Ok(true); + } + } + Ok(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_polkit_authenticate() { + let result = polkit_authenticate_bitwarden_policy().await; + assert!(result.is_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 new file mode 100644 index 00000000000..55aee27dd33 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs @@ -0,0 +1,34 @@ +use anyhow::Result; + +#[allow(clippy::module_inception)] +#[cfg_attr(target_os = "linux", path = "linux.rs")] +#[cfg_attr(target_os = "macos", path = "unimplemented.rs")] +#[cfg_attr(target_os = "windows", path = "windows.rs")] +mod biometric_v2; + +#[cfg(target_os = "windows")] +pub mod windows_focus; + +pub use biometric_v2::BiometricLockSystem; + +#[allow(async_fn_in_trait)] +pub trait BiometricTrait: Send + Sync { + /// Authenticate the user + async fn authenticate(&self, hwnd: Vec, message: String) -> Result; + /// Check if biometric authentication is available + async fn authenticate_available(&self) -> Result; + /// Enroll a key for persistent unlock. If the implementation does not support persistent + /// 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<()>; + /// 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>; + /// 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; +} diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/unimplemented.rs b/apps/desktop/desktop_native/core/src/biometric_v2/unimplemented.rs new file mode 100644 index 00000000000..1503cfea89c --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/unimplemented.rs @@ -0,0 +1,47 @@ +pub struct BiometricLockSystem {} + +impl BiometricLockSystem { + pub fn new() -> Self { + Self {} + } +} + +impl Default for BiometricLockSystem { + fn default() -> Self { + Self::new() + } +} + +impl super::BiometricTrait for BiometricLockSystem { + async fn authenticate(&self, _hwnd: Vec, _message: String) -> Result { + unimplemented!() + } + + async fn authenticate_available(&self) -> Result { + unimplemented!() + } + + async fn enroll_persistent(&self, _user_id: &str, _key: &[u8]) -> Result<(), anyhow::Error> { + unimplemented!() + } + + async fn provide_key(&self, _user_id: &str, _key: &[u8]) { + unimplemented!() + } + + async fn unlock(&self, _user_id: &str, _hwnd: Vec) -> Result, anyhow::Error> { + unimplemented!() + } + + async fn unlock_available(&self, _user_id: &str) -> Result { + unimplemented!() + } + + async fn has_persistent(&self, _user_id: &str) -> Result { + unimplemented!() + } + + async fn unenroll(&self, _user_id: &str) -> 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 new file mode 100644 index 00000000000..32d2eb7e6e6 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs @@ -0,0 +1,511 @@ +//! This file implements Windows-Hello based biometric unlock. +//! +//! There are two paths implemented here. +//! The former via UV + ephemerally (but protected) keys. This only works after first unlock. +//! The latter via a signing API, that deterministically signs a challenge, from which a windows +//! hello key is derived. This key is used to encrypt the protected key. +//! +//! # Security +//! The security goal is that a locked vault - a running app - cannot be unlocked when the device +//! (user-space) is compromised in this state. +//! +//! ## UV path +//! When first unlocking the app, the app sends the user-key to this module, which holds it in +//! secure memory, protected by DPAPI. This makes it inaccessible to other processes, unless they +//! compromise the system administrator, or kernel. While the app is running this key is held in +//! memory, even if locked. When unlocking, the app will prompt the user via +//! `windows_hello_authenticate` to get a yes/no decision on whether to release the key to the app. +//! Note: Further process isolation is needed here so that code cannot be injected into the running +//! process, which may circumvent DPAPI. +//! +//! ## Sign path +//! In this scenario, when enrolling, the app sends the user-key to this module, which derives the +//! windows hello key with the Windows Hello prompt. This is done by signing a per-user challenge, +//! which produces a deterministic signature which is hashed to obtain a key. This key is used to +//! encrypt and persist the vault unlock key (user key). +//! +//! Since the keychain can be accessed by all user-space processes, the challenge is known to all +//! userspace processes. Therefore, to circumvent the security measure, the attacker would need to +//! create a fake Windows-Hello prompt, and get the user to confirm it. + +use std::sync::{atomic::AtomicBool, Arc}; + +use aes::cipher::KeyInit; +use anyhow::{anyhow, Result}; +use chacha20poly1305::{aead::Aead, XChaCha20Poly1305, XNonce}; +use sha2::{Digest, Sha256}; +use tokio::sync::Mutex; +use tracing::{debug, warn}; +use windows::{ + core::{factory, h, Interface, HSTRING}, + Security::{ + Credentials::{ + KeyCredentialCreationOption, KeyCredentialManager, KeyCredentialStatus, + UI::{ + UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability, + }, + }, + Cryptography::CryptographicBuffer, + }, + Storage::Streams::IBuffer, + Win32::{ + System::WinRT::{IBufferByteAccess, IUserConsentVerifierInterop}, + UI::WindowsAndMessaging::GetForegroundWindow, + }, +}; +use windows_future::IAsyncOperation; + +use super::windows_focus::{focus_security_prompt, restore_focus}; +use crate::{ + password::{self, PASSWORD_NOT_FOUND}, + secure_memory::*, +}; + +const KEYCHAIN_SERVICE_NAME: &str = "BitwardenBiometricsV2"; +const CREDENTIAL_NAME: &HSTRING = h!("BitwardenBiometricsV2"); +const CHALLENGE_LENGTH: usize = 16; +const XCHACHA20POLY1305_NONCE_LENGTH: usize = 24; +const XCHACHA20POLY1305_KEY_LENGTH: usize = 32; + +#[derive(serde::Serialize, serde::Deserialize)] +struct WindowsHelloKeychainEntry { + nonce: [u8; XCHACHA20POLY1305_NONCE_LENGTH], + challenge: [u8; CHALLENGE_LENGTH], + wrapped_key: Vec, +} + +/// The Windows OS implementation of the biometric trait. +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>, +} + +impl BiometricLockSystem { + pub fn new() -> Self { + Self { + secure_memory: Arc::new(Mutex::new( + crate::secure_memory::dpapi::DpapiSecretKVStore::new(), + )), + } + } +} + +impl Default for BiometricLockSystem { + fn default() -> Self { + Self::new() + } +} + +impl super::BiometricTrait for BiometricLockSystem { + async fn authenticate(&self, _hwnd: Vec, message: String) -> Result { + windows_hello_authenticate(message).await + } + + async fn authenticate_available(&self) -> Result { + match UserConsentVerifier::CheckAvailabilityAsync()?.await? { + UserConsentVerifierAvailability::Available + | UserConsentVerifierAvailability::DeviceBusy => Ok(true), + _ => Ok(false), + } + } + + async fn unenroll(&self, user_id: &str) -> Result<()> { + self.secure_memory.lock().await.remove(user_id); + delete_keychain_entry(user_id).await + } + + async fn enroll_persistent(&self, user_id: &str, key: &[u8]) -> Result<()> { + // Enrollment works by first generating a random challenge unique to the user / enrollment. + // Then, with the challenge and a Windows-Hello prompt, the "windows hello key" is + // derived. The windows hello key is used to encrypt the key to store with + // XChaCha20Poly1305. The bundle of nonce, challenge and wrapped-key are stored to + // the keychain + + // Each enrollment (per user) has a unique challenge, so that the windows-hello key is + // unique + let challenge: [u8; CHALLENGE_LENGTH] = rand::random(); + + // This key is unique to the challenge + let windows_hello_key = windows_hello_authenticate_with_crypto(&challenge).await?; + let (wrapped_key, nonce) = encrypt_data(&windows_hello_key, key)?; + + set_keychain_entry( + user_id, + &WindowsHelloKeychainEntry { + nonce, + challenge, + wrapped_key, + }, + ) + .await + } + + async fn provide_key(&self, user_id: &str, key: &[u8]) { + self.secure_memory + .lock() + .await + .put(user_id.to_string(), key); + } + + async fn unlock(&self, user_id: &str, _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((), |_| { + if let Some(hwnd) = previous_active_window { + debug!("Restoring focus to previous window"); + restore_focus(hwnd.0); + } + }); + + let mut secure_memory = self.secure_memory.lock().await; + // If the key is held ephemerally, always use UV API. Only use signing API if the key is not + // held ephemerally but the keychain holds it persistently. + if secure_memory.has(user_id) { + if windows_hello_authenticate("Unlock your vault".to_string()).await? { + secure_memory + .get(user_id) + .clone() + .ok_or_else(|| anyhow!("No key found for user")) + } else { + Err(anyhow!("Authentication failed")) + } + } else { + let keychain_entry = get_keychain_entry(user_id).await?; + let windows_hello_key = + windows_hello_authenticate_with_crypto(&keychain_entry.challenge).await?; + let decrypted_key = decrypt_data( + &windows_hello_key, + &keychain_entry.wrapped_key, + &keychain_entry.nonce, + )?; + // The first unlock already sets the key for subsequent unlocks. The key may again be + // set externally after unlock finishes. + secure_memory.put(user_id.to_string(), &decrypted_key.clone()); + Ok(decrypted_key) + } + } + + async fn unlock_available(&self, user_id: &str) -> 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); + Ok(has_key && self.authenticate_available().await.unwrap_or(false)) + } + + async fn has_persistent(&self, user_id: &str) -> Result { + Ok(get_keychain_entry(user_id).await.is_ok()) + } +} + +/// Get a yes/no authorization without any cryptographic backing. +/// This API has better focusing behavior +async fn windows_hello_authenticate(message: String) -> Result { + debug!( + "[Windows Hello] Authenticating to perform UV with message: {}", + message + ); + + let userconsent_result: IAsyncOperation = unsafe { + // Windows Hello prompt must be in foreground, focused, otherwise the face or fingerprint + // unlock will not work. We get the current foreground window, which will either be the + // Bitwarden desktop app or the browser extension. + let foreground_window = GetForegroundWindow(); + factory::()? + .RequestVerificationForWindowAsync(foreground_window, &HSTRING::from(message))? + }; + + match userconsent_result.await? { + UserConsentVerificationResult::Verified => Ok(true), + _ => Ok(false), + } +} + +/// Derive the symmetric encryption key from the Windows Hello signature. +/// +/// This works by signing a static challenge string with Windows Hello protected key store. The +/// signed challenge is then hashed using SHA-256 and used as the symmetric encryption key for the +/// Windows Hello protected keys. +/// +/// Windows will only sign the challenge if the user has successfully authenticated with Windows, +/// ensuring user presence. +/// +/// Note: This API has inconsistent focusing behavior when called from another window +async fn windows_hello_authenticate_with_crypto( + challenge: &[u8; CHALLENGE_LENGTH], +) -> Result<[u8; XCHACHA20POLY1305_KEY_LENGTH]> { + debug!("[Windows Hello] Authenticating to sign challenge"); + + // Ugly hack: We need to focus the window via window focusing APIs until Microsoft releases a + // new API. This is unreliable, and if it does not work, the operation may fail + let stop_focusing = Arc::new(AtomicBool::new(false)); + let stop_focusing_clone = stop_focusing.clone(); + let _ = std::thread::spawn(move || loop { + if !stop_focusing_clone.load(std::sync::atomic::Ordering::Relaxed) { + focus_security_prompt(); + std::thread::sleep(std::time::Duration::from_millis(500)); + } else { + break; + } + }); + // Only stop focusing once this function exits. The focus MUST run both during the initial + // creation with RequestCreateAsync, and also with the subsequent use with RequestSignAsync. + let _guard = scopeguard::guard((), |_| { + stop_focusing.store(true, std::sync::atomic::Ordering::Relaxed); + }); + + // First create or replace the Bitwarden Biometrics signing key + let credential = { + let key_credential_creation_result = KeyCredentialManager::RequestCreateAsync( + CREDENTIAL_NAME, + KeyCredentialCreationOption::FailIfExists, + )? + .await?; + match key_credential_creation_result.Status()? { + KeyCredentialStatus::CredentialAlreadyExists => { + KeyCredentialManager::OpenAsync(CREDENTIAL_NAME)?.await? + } + KeyCredentialStatus::Success => key_credential_creation_result, + _ => return Err(anyhow!("Failed to create key credential")), + } + } + .Credential()?; + + let signature = { + let sign_operation = credential.RequestSignAsync( + &CryptographicBuffer::CreateFromByteArray(challenge.as_slice())?, + )?; + + // We need to drop the credential here to avoid holding it across an await point. + drop(credential); + sign_operation.await? + }; + + if signature.Status()? != KeyCredentialStatus::Success { + return Err(anyhow!("Failed to sign data")); + } + + let signature_buffer = signature.Result()?; + let signature_value = unsafe { as_mut_bytes(&signature_buffer)? }; + + // The signature is deterministic based on the challenge and keychain key. Thus, it can be + // hashed to a key. It is unclear what entropy this key provides. + let windows_hello_key = Sha256::digest(signature_value).into(); + Ok(windows_hello_key) +} + +async fn set_keychain_entry(user_id: &str, entry: &WindowsHelloKeychainEntry) -> Result<()> { + password::set_password( + KEYCHAIN_SERVICE_NAME, + user_id, + &serde_json::to_string(entry)?, + ) + .await +} + +async fn get_keychain_entry(user_id: &str) -> Result { + serde_json::from_str(&password::get_password(KEYCHAIN_SERVICE_NAME, user_id).await?) + .map_err(|e| anyhow!(e)) +} + +async fn delete_keychain_entry(user_id: &str) -> Result<()> { + password::delete_password(KEYCHAIN_SERVICE_NAME, user_id) + .await + .or_else(|e| { + if e.to_string() == PASSWORD_NOT_FOUND { + debug!( + "[Windows Hello] No keychain entry found for user {}, nothing to delete", + user_id + ); + Ok(()) + } else { + Err(e) + } + }) +} + +async fn has_keychain_entry(user_id: &str) -> Result { + password::get_password(KEYCHAIN_SERVICE_NAME, user_id) + .await + .map(|entry| !entry.is_empty()) + .or_else(|e| { + if e.to_string() == PASSWORD_NOT_FOUND { + Ok(false) + } else { + warn!( + "[Windows Hello] Error checking keychain entry for user {}: {}", + user_id, e + ); + Err(e) + } + }) +} + +/// Encrypt data with XChaCha20Poly1305 +fn encrypt_data( + key: &[u8; XCHACHA20POLY1305_KEY_LENGTH], + plaintext: &[u8], +) -> Result<(Vec, [u8; XCHACHA20POLY1305_NONCE_LENGTH])> { + let cipher = XChaCha20Poly1305::new(key.into()); + let mut nonce = [0u8; XCHACHA20POLY1305_NONCE_LENGTH]; + rand::fill(&mut nonce); + let ciphertext = cipher + .encrypt(XNonce::from_slice(&nonce), plaintext) + .map_err(|e| anyhow!(e))?; + Ok((ciphertext, nonce)) +} + +/// Decrypt data with XChaCha20Poly1305 +fn decrypt_data( + key: &[u8; XCHACHA20POLY1305_KEY_LENGTH], + ciphertext: &[u8], + nonce: &[u8; XCHACHA20POLY1305_NONCE_LENGTH], +) -> Result> { + let cipher = XChaCha20Poly1305::new(key.into()); + let plaintext = cipher + .decrypt(XNonce::from_slice(nonce), ciphertext) + .map_err(|e| anyhow!(e))?; + Ok(plaintext) +} + +unsafe fn as_mut_bytes(buffer: &IBuffer) -> Result<&mut [u8]> { + let interop = buffer.cast::()?; + + unsafe { + let data = interop.Buffer()?; + Ok(std::slice::from_raw_parts_mut( + data, + buffer.Length()? as usize, + )) + } +} + +#[cfg(test)] +#[allow(clippy::print_stdout)] +mod tests { + use crate::biometric_v2::{ + biometric_v2::{ + decrypt_data, encrypt_data, has_keychain_entry, windows_hello_authenticate, + windows_hello_authenticate_with_crypto, CHALLENGE_LENGTH, XCHACHA20POLY1305_KEY_LENGTH, + }, + BiometricLockSystem, BiometricTrait, + }; + + #[test] + fn test_encrypt_decrypt() { + let key = [0u8; 32]; + let plaintext = b"Test data"; + let (ciphertext, nonce) = encrypt_data(&key, plaintext).unwrap(); + let decrypted = decrypt_data(&key, &ciphertext, &nonce).unwrap(); + assert_eq!(plaintext.to_vec(), decrypted); + } + + #[tokio::test] + async fn test_has_keychain_entry_no_entry() { + let user_id = "test_user"; + let has_entry = has_keychain_entry(user_id).await.unwrap(); + assert!(!has_entry); + } + + // Note: These tests are ignored because they require manual intervention to run + + #[tokio::test] + #[ignore] + async fn test_windows_hello_authenticate_with_crypto_manual() { + let challenge = [0u8; CHALLENGE_LENGTH]; + let windows_hello_key = windows_hello_authenticate_with_crypto(&challenge) + .await + .unwrap(); + println!( + "Windows hello key {:?} for challenge {:?}", + windows_hello_key, challenge + ); + } + + #[tokio::test] + #[ignore] + async fn test_windows_hello_authenticate() { + let authenticated = + windows_hello_authenticate("Test Windows Hello authentication".to_string()) + .await + .unwrap(); + println!("Windows Hello authentication result: {:?}", authenticated); + } + + #[tokio::test] + #[ignore] + async fn test_double_unenroll() { + let user_id = "test_user"; + let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH]; + rand::fill(&mut key); + + let windows_hello_lock_system = BiometricLockSystem::new(); + + println!("Enrolling user"); + windows_hello_lock_system + .enroll_persistent(user_id, &key) + .await + .unwrap(); + assert!(windows_hello_lock_system + .has_persistent(user_id) + .await + .unwrap()); + + println!("Unlocking user"); + let key_after_unlock = windows_hello_lock_system + .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(); + assert!(!windows_hello_lock_system + .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(); + assert!(!windows_hello_lock_system + .has_persistent(user_id) + .await + .unwrap()); + } + + #[tokio::test] + #[ignore] + async fn test_enroll_unlock_unenroll() { + let user_id = "test_user"; + let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH]; + rand::fill(&mut key); + + let windows_hello_lock_system = BiometricLockSystem::new(); + + println!("Enrolling user"); + windows_hello_lock_system + .enroll_persistent(user_id, &key) + .await + .unwrap(); + assert!(windows_hello_lock_system + .has_persistent(user_id) + .await + .unwrap()); + + println!("Unlocking user"); + let key_after_unlock = windows_hello_lock_system + .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(); + assert!(!windows_hello_lock_system + .has_persistent(user_id) + .await + .unwrap()); + } +} diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs b/apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs new file mode 100644 index 00000000000..bf303c88e01 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs @@ -0,0 +1,104 @@ +use windows::{ + core::s, + Win32::{ + Foundation::HWND, + System::Threading::{AttachThreadInput, GetCurrentThreadId}, + UI::{ + Input::KeyboardAndMouse::{EnableWindow, SetActiveWindow, SetCapture, SetFocus}, + WindowsAndMessaging::{ + BringWindowToTop, FindWindowA, GetForegroundWindow, GetWindowThreadProcessId, + SetForegroundWindow, SwitchToThisWindow, SystemParametersInfoW, SPIF_SENDCHANGE, + SPIF_UPDATEINIFILE, SPI_GETFOREGROUNDLOCKTIMEOUT, SPI_SETFOREGROUNDLOCKTIMEOUT, + }, + }, + }, +}; + +pub(crate) struct HwndHolder(pub(crate) HWND); +unsafe impl Send for HwndHolder {} + +pub(crate) fn get_active_window() -> Option { + unsafe { Some(HwndHolder(GetForegroundWindow())) } +} + +/// Searches for a window that looks like a security prompt and set it as focused. +/// Only works when the process has permission to foreground, either by being in foreground +/// Or by being given foreground permission https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow#remarks +pub fn focus_security_prompt() { + let hwnd_result = unsafe { FindWindowA(s!("Credential Dialog Xaml Host"), None) }; + if let Ok(hwnd) = hwnd_result { + set_focus(hwnd); + } +} + +/// Sets focus to a window using a few unstable methods +fn set_focus(hwnd: HWND) { + unsafe { + // Windows REALLY does not like apps stealing focus, even if it is for fixing Windows-Hello + // bugs. The windows hello signing prompt NEEDS to be focused instantly, or it will + // error, but it does not focus itself. + + // This function implements forced focusing of windows using a few hacks. + // The conditions to successfully foreground a window are: + // All of the following conditions are true: + // - The calling process belongs to a desktop application, not a UWP app or a Windows + // Store app designed for Windows 8 or 8.1. + // - The foreground process has not disabled calls to SetForegroundWindow by a previous + // call to the LockSetForegroundWindow function. + // - The foreground lock time-out has expired (see SPI_GETFOREGROUNDLOCKTIMEOUT in + // SystemParametersInfo). No menus are active. + // Additionally, at least one of the following conditions is true: + // - The calling process is the foreground process. + // - The calling process was started by the foreground process. + // - There is currently no foreground window, and thus no foreground process. + // - The calling process received the last input event. + // - Either the foreground process or the calling process is being debugged. + + // Update the foreground lock timeout temporarily + let mut old_timeout = 0; + let _ = SystemParametersInfoW( + SPI_GETFOREGROUNDLOCKTIMEOUT, + 0, + Some(&mut old_timeout as *mut _ as *mut std::ffi::c_void), + windows::Win32::UI::WindowsAndMessaging::SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0), + ); + let _ = SystemParametersInfoW( + SPI_SETFOREGROUNDLOCKTIMEOUT, + 0, + None, + SPIF_UPDATEINIFILE | SPIF_SENDCHANGE, + ); + let _scopeguard = scopeguard::guard((), |_| { + let _ = SystemParametersInfoW( + SPI_SETFOREGROUNDLOCKTIMEOUT, + old_timeout, + None, + SPIF_UPDATEINIFILE | SPIF_SENDCHANGE, + ); + }); + + // Attach to the foreground thread once attached, we can foreground, even if in the + // background + let dw_current_thread = GetCurrentThreadId(); + let dw_fg_thread = GetWindowThreadProcessId(GetForegroundWindow(), None); + + let _ = AttachThreadInput(dw_current_thread, dw_fg_thread, true); + let _ = SetForegroundWindow(hwnd); + SetCapture(hwnd); + let _ = SetFocus(Some(hwnd)); + let _ = SetActiveWindow(hwnd); + let _ = EnableWindow(hwnd, true); + let _ = BringWindowToTop(hwnd); + SwitchToThisWindow(hwnd, true); + let _ = AttachThreadInput(dw_current_thread, dw_fg_thread, false); + } +} + +/// When restoring focus to the application window, we need a less aggressive method so the electron +/// window doesn't get frozen. +pub(crate) fn restore_focus(hwnd: HWND) { + unsafe { + let _ = SetForegroundWindow(hwnd); + let _ = SetFocus(Some(hwnd)); + } +} diff --git a/apps/desktop/desktop_native/core/src/crypto/crypto.rs b/apps/desktop/desktop_native/core/src/crypto/crypto.rs index d9e2aec3046..7991c87ca28 100644 --- a/apps/desktop/desktop_native/core/src/crypto/crypto.rs +++ b/apps/desktop/desktop_native/core/src/crypto/crypto.rs @@ -5,9 +5,8 @@ use aes::cipher::{ BlockEncryptMut, KeyIvInit, }; -use crate::error::{CryptoError, Result}; - use super::CipherString; +use crate::error::{CryptoError, Result}; pub fn decrypt_aes256(iv: &[u8; 16], data: &[u8], key: GenericArray) -> Result> { let iv = GenericArray::from_slice(iv); @@ -16,7 +15,8 @@ pub fn decrypt_aes256(iv: &[u8; 16], data: &[u8], key: GenericArray) -> .decrypt_padded_mut::(&mut data) .map_err(|_| CryptoError::KeyDecrypt)?; - // Data is decrypted in place and returns a subslice of the original Vec, to avoid cloning it, we truncate to the subslice length + // Data is decrypted in place and returns a subslice of the original Vec, to avoid cloning it, + // we truncate to the subslice length let decrypted_len = decrypted_key_slice.len(); data.truncate(decrypted_len); diff --git a/apps/desktop/desktop_native/core/src/error.rs b/apps/desktop/desktop_native/core/src/error.rs index d70d8624018..c8d3ec02332 100644 --- a/apps/desktop/desktop_native/core/src/error.rs +++ b/apps/desktop/desktop_native/core/src/error.rs @@ -35,15 +35,4 @@ pub enum KdfParamError { InvalidParams(String), } -// Ensure that the error messages implement Send and Sync -#[cfg(test)] -const _: () = { - fn assert_send() {} - fn assert_sync() {} - fn assert_all() { - assert_send::(); - assert_sync::(); - } -}; - pub type Result = std::result::Result; diff --git a/apps/desktop/desktop_native/core/src/ipc/mod.rs b/apps/desktop/desktop_native/core/src/ipc/mod.rs index 41215b3a0ee..f806e395d10 100644 --- a/apps/desktop/desktop_native/core/src/ipc/mod.rs +++ b/apps/desktop/desktop_native/core/src/ipc/mod.rs @@ -35,7 +35,7 @@ fn internal_ipc_codec(inner: T) -> Framed std::path::PathBuf { #[cfg(target_os = "windows")] { - // Use a unique IPC pipe //./pipe/xxxxxxxxxxxxxxxxx.app.bitwarden per user. + // Use a unique IPC pipe //./pipe/xxxxxxxxxxxxxxxxx.s.bw per user (s for socket). // Hashing prevents problems with reserved characters and file length limitations. use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use sha2::Digest; @@ -43,13 +43,14 @@ pub fn path(name: &str) -> std::path::PathBuf { let hash = sha2::Sha256::digest(home.as_os_str().as_encoded_bytes()); let hash_b64 = URL_SAFE_NO_PAD.encode(hash.as_slice()); - format!(r"\\.\pipe\{hash_b64}.app.{name}").into() + format!(r"\\.\pipe\{hash_b64}.s.{name}").into() } #[cfg(target_os = "macos")] { // When running in an unsandboxed environment, path is: /Users// - // While running sandboxed, it's different: /Users//Library/Containers/com.bitwarden.desktop/Data + // While running sandboxed, it's different: + // /Users//Library/Containers/com.bitwarden.desktop/Data let mut home = dirs::home_dir().unwrap(); // Check if the app is sandboxed by looking for the Containers directory @@ -59,17 +60,18 @@ pub fn path(name: &str) -> std::path::PathBuf { // If the app is sanboxed, we need to use the App Group directory if let Some(position) = containers_position { - // We want to use App Groups in /Users//Library/Group Containers/LTZ2PFU5D6.com.bitwarden.desktop, - // so we need to remove all the components after the user. We can use the previous position to do this. + // We want to use App Groups in /Users//Library/Group + // Containers/LTZ2PFU5D6.com.bitwarden.desktop, so we need to remove all the + // components after the user. We can use the previous position to do this. while home.components().count() > position - 1 { home.pop(); } - let tmp = home.join("Library/Group Containers/LTZ2PFU5D6.com.bitwarden.desktop/tmp"); + let tmp = home.join("Library/Group Containers/LTZ2PFU5D6.com.bitwarden.desktop"); // The tmp directory might not exist, so create it let _ = std::fs::create_dir_all(&tmp); - return tmp.join(format!("app.{name}")); + return tmp.join(format!("s.{name}")); } } @@ -81,6 +83,6 @@ pub fn path(name: &str) -> std::path::PathBuf { // The cache directory might not exist, so create it let _ = std::fs::create_dir_all(&path_dir); - path_dir.join(format!("app.{name}")) + path_dir.join(format!("s.{name}")) } } diff --git a/apps/desktop/desktop_native/core/src/ipc/server.rs b/apps/desktop/desktop_native/core/src/ipc/server.rs index 2762a832ac6..a65638303f1 100644 --- a/apps/desktop/desktop_native/core/src/ipc/server.rs +++ b/apps/desktop/desktop_native/core/src/ipc/server.rs @@ -3,9 +3,8 @@ use std::{ path::{Path, PathBuf}, }; -use futures::{SinkExt, StreamExt, TryFutureExt}; - use anyhow::Result; +use futures::{SinkExt, StreamExt, TryFutureExt}; use interprocess::local_socket::{tokio::prelude::*, GenericFilePath, ListenerOptions}; use tokio::{ io::{AsyncRead, AsyncWrite}, @@ -42,14 +41,17 @@ impl Server { /// /// # Parameters /// - /// - `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. - /// - `client_to_server_send`: This [`mpsc::Sender`] will receive all the [`Message`]'s that the clients send to this server. + /// - `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. + /// - `client_to_server_send`: This [`mpsc::Sender`] will receive all the [`Message`]'s + /// that the clients send to this server. pub fn start( path: &Path, client_to_server_send: mpsc::Sender, ) -> Result> { - // If the unix socket file already exists, we get an error when trying to bind to it. So we remove it first. - // Any processes that were using the old socket should remain connected to it but any new connections will use the new socket. + // If the unix socket file already exists, we get an error when trying to bind to it. So we + // remove it first. Any processes that were using the old socket should remain + // connected to it but any new connections will use the new socket. if !cfg!(windows) { let _ = std::fs::remove_file(path); } @@ -58,8 +60,9 @@ impl Server { let opts = ListenerOptions::new().name(name); let listener = opts.create_tokio()?; - // This broadcast channel is used for sending messages to all connected clients, and so the sender - // will be stored in the server while the receiver will be cloned and passed to each client handler. + // This broadcast channel is used for sending messages to all connected clients, and so the + // sender will be stored in the server while the receiver will be cloned and passed + // to each client handler. let (server_to_clients_send, server_to_clients_recv) = broadcast::channel::(MESSAGE_CHANNEL_BUFFER); diff --git a/apps/desktop/desktop_native/core/src/lib.rs b/apps/desktop/desktop_native/core/src/lib.rs index a72ec04e9c2..668badb95ed 100644 --- a/apps/desktop/desktop_native/core/src/lib.rs +++ b/apps/desktop/desktop_native/core/src/lib.rs @@ -1,13 +1,15 @@ pub mod autofill; pub mod autostart; pub mod biometric; +pub mod biometric_v2; pub mod clipboard; -pub mod crypto; +pub(crate) mod crypto; pub mod error; pub mod ipc; pub mod password; pub mod powermonitor; pub mod process_isolation; +pub(crate) mod secure_memory; pub mod ssh_agent; use zeroizing_alloc::ZeroAlloc; diff --git a/apps/desktop/desktop_native/core/src/password/macos.rs b/apps/desktop/desktop_native/core/src/password/macos.rs index 4f3a16ba4be..72d8ebeb425 100644 --- a/apps/desktop/desktop_native/core/src/password/macos.rs +++ b/apps/desktop/desktop_native/core/src/password/macos.rs @@ -1,9 +1,10 @@ -use crate::password::PASSWORD_NOT_FOUND; use anyhow::Result; use security_framework::passwords::{ delete_generic_password, get_generic_password, set_generic_password, }; +use crate::password::PASSWORD_NOT_FOUND; + #[allow(clippy::unused_async)] pub async fn get_password(service: &str, account: &str) -> Result { let password = get_generic_password(service, account).map_err(convert_error)?; diff --git a/apps/desktop/desktop_native/core/src/password/unix.rs b/apps/desktop/desktop_native/core/src/password/unix.rs index b7595dca287..57b71adefed 100644 --- a/apps/desktop/desktop_native/core/src/password/unix.rs +++ b/apps/desktop/desktop_native/core/src/password/unix.rs @@ -1,9 +1,11 @@ -use crate::password::PASSWORD_NOT_FOUND; +use std::collections::HashMap; + use anyhow::{anyhow, Result}; use oo7::dbus::{self}; -use std::collections::HashMap; use tracing::info; +use crate::password::PASSWORD_NOT_FOUND; + pub async fn get_password(service: &str, account: &str) -> Result { match get_password_new(service, account).await { Ok(res) => Ok(res), diff --git a/apps/desktop/desktop_native/core/src/password/windows.rs b/apps/desktop/desktop_native/core/src/password/windows.rs index ad09019f014..645620b444e 100644 --- a/apps/desktop/desktop_native/core/src/password/windows.rs +++ b/apps/desktop/desktop_native/core/src/password/windows.rs @@ -1,4 +1,3 @@ -use crate::password::PASSWORD_NOT_FOUND; use anyhow::{anyhow, Result}; use widestring::{U16CString, U16String}; use windows::{ @@ -12,6 +11,8 @@ use windows::{ }, }; +use crate::password::PASSWORD_NOT_FOUND; + const CRED_FLAGS_NONE: u32 = 0; #[allow(clippy::unused_async)] diff --git a/apps/desktop/desktop_native/core/src/process_isolation/linux.rs b/apps/desktop/desktop_native/core/src/process_isolation/linux.rs index 395d722ea01..263cc10b716 100644 --- a/apps/desktop/desktop_native/core/src/process_isolation/linux.rs +++ b/apps/desktop/desktop_native/core/src/process_isolation/linux.rs @@ -2,16 +2,17 @@ use anyhow::Result; #[cfg(target_env = "gnu")] use libc::c_uint; use libc::{self, c_int}; +use tracing::info; -// RLIMIT_CORE is the maximum size of a core dump file. Setting both to 0 disables core dumps, on crashes -// https://github.com/torvalds/linux/blob/1613e604df0cd359cf2a7fbd9be7a0bcfacfabd0/include/uapi/asm-generic/resource.h#L20 +// RLIMIT_CORE is the maximum size of a core dump file. Setting both to 0 disables core dumps, on +// crashes https://github.com/torvalds/linux/blob/1613e604df0cd359cf2a7fbd9be7a0bcfacfabd0/include/uapi/asm-generic/resource.h#L20 #[cfg(target_env = "musl")] const RLIMIT_CORE: c_int = 4; #[cfg(target_env = "gnu")] const RLIMIT_CORE: c_uint = 4; -// PR_SET_DUMPABLE makes it so no other running process (root or same user) can dump the memory of this process -// or attach a debugger to it. +// PR_SET_DUMPABLE makes it so no other running process (root or same user) can dump the memory of +// this process or attach a debugger to it. // https://github.com/torvalds/linux/blob/a38297e3fb012ddfa7ce0321a7e5a8daeb1872b6/include/uapi/linux/prctl.h#L14 const PR_SET_DUMPABLE: c_int = 4; @@ -20,7 +21,7 @@ pub fn disable_coredumps() -> Result<()> { rlim_cur: 0, rlim_max: 0, }; - println!("[Process Isolation] Disabling core dumps via setrlimit"); + info!("Disabling core dumps via setrlimit."); if unsafe { libc::setrlimit(RLIMIT_CORE, &rlimit) } != 0 { let e = std::io::Error::last_os_error(); @@ -48,9 +49,9 @@ pub fn is_core_dumping_disabled() -> Result { pub fn isolate_process() -> Result<()> { let pid = std::process::id(); - println!( - "[Process Isolation] Disabling ptrace and memory access for main ({}) via PR_SET_DUMPABLE", - pid + info!( + pid, + "Disabling ptrace and memory access for main via PR_SET_DUMPABLE." ); if unsafe { libc::prctl(PR_SET_DUMPABLE, 0) } != 0 { diff --git a/apps/desktop/desktop_native/core/src/process_isolation/macos.rs b/apps/desktop/desktop_native/core/src/process_isolation/macos.rs index ce42e06a832..928eac749c0 100644 --- a/apps/desktop/desktop_native/core/src/process_isolation/macos.rs +++ b/apps/desktop/desktop_native/core/src/process_isolation/macos.rs @@ -1,4 +1,5 @@ use anyhow::{bail, Result}; +use tracing::info; pub fn disable_coredumps() -> Result<()> { bail!("Not implemented on Mac") @@ -10,10 +11,7 @@ pub fn is_core_dumping_disabled() -> Result { pub fn isolate_process() -> Result<()> { let pid: u32 = std::process::id(); - println!( - "[Process Isolation] Disabling ptrace on main process ({}) via PT_DENY_ATTACH", - pid - ); + info!(pid, "Disabling ptrace on main process via PT_DENY_ATTACH."); secmem_proc::harden_process().map_err(|e| { anyhow::anyhow!( diff --git a/apps/desktop/desktop_native/core/src/process_isolation/windows.rs b/apps/desktop/desktop_native/core/src/process_isolation/windows.rs index dc1092f9131..fddea8bc53a 100644 --- a/apps/desktop/desktop_native/core/src/process_isolation/windows.rs +++ b/apps/desktop/desktop_native/core/src/process_isolation/windows.rs @@ -1,4 +1,5 @@ use anyhow::{bail, Result}; +use tracing::info; pub fn disable_coredumps() -> Result<()> { bail!("Not implemented on Windows") @@ -10,10 +11,7 @@ pub fn is_core_dumping_disabled() -> Result { pub fn isolate_process() -> Result<()> { let pid: u32 = std::process::id(); - println!( - "[Process Isolation] Isolating main process via DACL {}", - pid - ); + info!(pid, "Isolating main process via DACL."); secmem_proc::harden_process().map_err(|e| { anyhow::anyhow!( diff --git a/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs b/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs new file mode 100644 index 00000000000..8d8e10d92c4 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs @@ -0,0 +1,135 @@ +use std::collections::HashMap; + +use windows::Win32::Security::Cryptography::{ + CryptProtectMemory, CryptUnprotectMemory, CRYPTPROTECTMEMORY_BLOCK_SIZE, + CRYPTPROTECTMEMORY_SAME_PROCESS, +}; + +use crate::secure_memory::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 +/// to the current process, and cannot be decrypted by other user-mode processes. +/// +/// Note: Admin processes can still decrypt this memory: +/// https://blog.slowerzs.net/posts/cryptdecryptmemory/ +pub(crate) struct DpapiSecretKVStore { + map: HashMap>, +} + +impl DpapiSecretKVStore { + pub(crate) fn new() -> Self { + DpapiSecretKVStore { + map: HashMap::new(), + } + } +} + +impl SecureMemoryStore for DpapiSecretKVStore { + fn put(&mut self, key: String, 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 + // and write the length in front We are storing LENGTH|DATA|00..00, where LENGTH is + // the length of DATA, the total length is a multiple + // of CRYPTPROTECTMEMORY_BLOCK_SIZE, and the padding is filled with zeros. + + let data_len = value.len(); + let len_with_header = data_len + length_header_len; + let padded_length = len_with_header + CRYPTPROTECTMEMORY_BLOCK_SIZE as usize + - (len_with_header % CRYPTPROTECTMEMORY_BLOCK_SIZE as usize); + let mut padded_data = vec![0u8; padded_length]; + padded_data[..length_header_len].copy_from_slice(&data_len.to_le_bytes()); + padded_data[length_header_len..][..data_len].copy_from_slice(value); + + // Protect the memory using DPAPI + unsafe { + CryptProtectMemory( + padded_data.as_mut_ptr() as *mut core::ffi::c_void, + padded_length as u32, + CRYPTPROTECTMEMORY_SAME_PROCESS, + ) + } + .expect("crypt_protect_memory should work"); + + self.map.insert(key, padded_data); + } + + fn get(&mut self, key: &str) -> Option> { + self.map.get(key).map(|data| { + // A copy is created, that is then mutated by the DPAPI unprotect function. + let mut data = data.clone(); + unsafe { + CryptUnprotectMemory( + data.as_mut_ptr() as *mut core::ffi::c_void, + data.len() as u32, + CRYPTPROTECTMEMORY_SAME_PROCESS, + ) + } + .expect("crypt_unprotect_memory should work"); + + // Unpad the data to retrieve the original value + let length_header_size = std::mem::size_of::(); + let length_bytes = &data[..length_header_size]; + let data_length = usize::from_le_bytes( + length_bytes + .try_into() + .expect("length header should be usize"), + ); + + data[length_header_size..length_header_size + data_length].to_vec() + }) + } + + fn has(&self, key: &str) -> bool { + self.map.contains_key(key) + } + + fn remove(&mut self, key: &str) { + self.map.remove(key); + } + + fn clear(&mut self) { + self.map.clear(); + } +} + +impl Drop for DpapiSecretKVStore { + fn drop(&mut self) { + self.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dpapi_secret_kv_store_various_sizes() { + let mut store = DpapiSecretKVStore::new(); + 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); + assert!(store.has(&key), "Store should have key for size {}", size); + assert_eq!( + store.get(&key), + Some(value), + "Value mismatch for size {}", + size + ); + } + } + + #[test] + fn test_dpapi_crud() { + let mut store = DpapiSecretKVStore::new(); + 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)); + 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 new file mode 100644 index 00000000000..d116e564bc8 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/encrypted_memory_store.rs @@ -0,0 +1,105 @@ +use tracing::error; + +use crate::secure_memory::{ + secure_key::{EncryptedMemory, SecureMemoryEncryptionKey}, + SecureMemoryStore, +}; + +/// An encrypted memory store holds a platform protected symmetric encryption key, and uses it +/// to encrypt all items it stores. The ciphertexts for the items are not specially protected. This +/// allows circumventing length and amount limitations on platform specific secure memory APIs since +/// only a single short item needs to be protected. +/// +/// 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, + memory_encryption_key: SecureMemoryEncryptionKey, +} + +impl EncryptedMemoryStore { + #[allow(unused)] + pub(crate) fn new() -> Self { + EncryptedMemoryStore { + map: std::collections::HashMap::new(), + memory_encryption_key: SecureMemoryEncryptionKey::new(), + } + } +} + +impl SecureMemoryStore for EncryptedMemoryStore { + fn put(&mut self, key: String, 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 + } + } + } else { + None + } + } + + fn has(&self, key: &str) -> bool { + self.map.contains_key(key) + } + + fn remove(&mut self, key: &str) { + self.map.remove(key); + } + + fn clear(&mut self) { + self.map.clear(); + } +} + +impl Drop for EncryptedMemoryStore { + fn drop(&mut self) { + self.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_secret_kv_store_various_sizes() { + let mut store = EncryptedMemoryStore::new(); + 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); + assert!(store.has(&key), "Store should have key for size {}", size); + assert_eq!( + store.get(&key), + Some(value), + "Value mismatch for size {}", + size + ); + } + } + + #[test] + fn test_crud() { + let mut store = EncryptedMemoryStore::new(); + 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)); + store.remove(&key); + assert!(!store.has(&key)); + } +} diff --git a/apps/desktop/desktop_native/core/src/secure_memory/mod.rs b/apps/desktop/desktop_native/core/src/secure_memory/mod.rs new file mode 100644 index 00000000000..d4323ce40dd --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/mod.rs @@ -0,0 +1,27 @@ +#[cfg(target_os = "windows")] +pub(crate) mod dpapi; + +pub(crate) mod encrypted_memory_store; +mod secure_key; + +/// 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 { + /// Stores a copy of the provided value in secure memory. + fn put(&mut self, key: String, 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>; + /// Checks if a value is stored under the given key. + fn has(&self, key: &str) -> bool; + /// Removes the value associated with the given key from secure memory. + fn remove(&mut self, key: &str); + /// 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 new file mode 100644 index 00000000000..7e2917ade6d --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/crypto.rs @@ -0,0 +1,96 @@ +use std::ptr::NonNull; + +use chacha20poly1305::{aead::Aead, Key, KeyInit}; +use rand::{rng, Rng}; + +pub(super) const KEY_SIZE: usize = 32; +pub(super) const NONCE_SIZE: usize = 24; + +/// The encryption performed here is xchacha-poly1305. Any tampering with the key or the ciphertexts +/// will result in a decryption failure and panic. The key's memory contents are protected from +/// being swapped to disk via mlock. +pub(super) struct MemoryEncryptionKey(NonNull<[u8]>); + +/// An encrypted memory blob that must be decrypted using the same key that it was encrypted with. +pub struct EncryptedMemory { + nonce: [u8; NONCE_SIZE], + ciphertext: Vec, +} + +impl MemoryEncryptionKey { + pub fn new() -> Self { + let mut key = [0u8; KEY_SIZE]; + rng().fill(&mut key); + MemoryEncryptionKey::from(&key) + } + + /// Encrypts the given plaintext using the key. + #[allow(unused)] + pub(super) fn encrypt(&self, plaintext: &[u8]) -> EncryptedMemory { + let cipher = chacha20poly1305::XChaCha20Poly1305::new(Key::from_slice(self.as_ref())); + let mut nonce = [0u8; NONCE_SIZE]; + rng().fill(&mut nonce); + let ciphertext = cipher + .encrypt(chacha20poly1305::XNonce::from_slice(&nonce), plaintext) + .expect("encryption should not fail"); + EncryptedMemory { nonce, ciphertext } + } + + /// Decrypts the given encrypted memory using the key. A decryption failure will panic. This is + /// okay because neither the keys nor ciphertexts should ever fail to decrypt, and doing so + /// indicates that the process memory was tampered with. + #[allow(unused)] + pub(super) fn decrypt(&self, encrypted: &EncryptedMemory) -> Result, DecryptionError> { + let cipher = chacha20poly1305::XChaCha20Poly1305::new(Key::from_slice(self.as_ref())); + cipher + .decrypt( + chacha20poly1305::XNonce::from_slice(&encrypted.nonce), + encrypted.ciphertext.as_ref(), + ) + .map_err(|_| DecryptionError::CouldNotDecrypt) + } +} + +impl Drop for MemoryEncryptionKey { + fn drop(&mut self) { + unsafe { + memsec::free(self.0); + } + } +} + +impl From<&[u8; KEY_SIZE]> for MemoryEncryptionKey { + fn from(value: &[u8; KEY_SIZE]) -> Self { + let mut ptr: NonNull<[u8]> = + unsafe { memsec::malloc_sized(KEY_SIZE).expect("malloc_sized should work") }; + unsafe { + std::ptr::copy_nonoverlapping(value.as_ptr(), ptr.as_mut().as_mut_ptr(), KEY_SIZE); + } + MemoryEncryptionKey(ptr) + } +} + +impl AsRef<[u8]> for MemoryEncryptionKey { + fn as_ref(&self) -> &[u8] { + unsafe { self.0.as_ref() } + } +} + +#[derive(Debug)] +pub(crate) enum DecryptionError { + CouldNotDecrypt, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_memory_encryption_key() { + let key = MemoryEncryptionKey::new(); + let data = b"Hello, world!"; + let encrypted = key.encrypt(data); + let decrypted = key.decrypt(&encrypted).unwrap(); + assert_eq!(data.as_ref(), decrypted.as_slice()); + } +} diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/dpapi.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/dpapi.rs new file mode 100644 index 00000000000..52b75d94a09 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/dpapi.rs @@ -0,0 +1,96 @@ +use windows::Win32::Security::Cryptography::{ + CryptProtectMemory, CryptUnprotectMemory, CRYPTPROTECTMEMORY_BLOCK_SIZE, + CRYPTPROTECTMEMORY_SAME_PROCESS, +}; + +use super::{ + crypto::{MemoryEncryptionKey, KEY_SIZE}, + SecureKeyContainer, +}; + +/// 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 +/// to the current process, and cannot be decrypted by other user-mode processes. +/// +/// Note: Admin processes can still decrypt this memory: +/// https://blog.slowerzs.net/posts/cryptdecryptmemory/ +pub(super) struct DpapiSecureKeyContainer { + dpapi_encrypted_key: [u8; KEY_SIZE + CRYPTPROTECTMEMORY_BLOCK_SIZE as usize], +} + +// SAFETY: The encrypted data is fully owned by this struct, and not exposed outside or cloned, +// and is disposed on drop of this struct. +unsafe impl Send for DpapiSecureKeyContainer {} +// SAFETY: The container is non-mutable and thus safe to share between threads. +unsafe impl Sync for DpapiSecureKeyContainer {} + +impl SecureKeyContainer for DpapiSecureKeyContainer { + fn as_key(&self) -> MemoryEncryptionKey { + let mut decrypted_key = self.dpapi_encrypted_key; + unsafe { + CryptUnprotectMemory( + decrypted_key.as_mut_ptr() as *mut core::ffi::c_void, + decrypted_key.len() as u32, + CRYPTPROTECTMEMORY_SAME_PROCESS, + ) + } + .expect("crypt_unprotect_memory should work"); + let mut key = [0u8; KEY_SIZE]; + key.copy_from_slice(&decrypted_key[..KEY_SIZE]); + MemoryEncryptionKey::from(&key) + } + + fn from_key(key: MemoryEncryptionKey) -> Self { + let mut padded_key = [0u8; KEY_SIZE + CRYPTPROTECTMEMORY_BLOCK_SIZE as usize]; + padded_key[..KEY_SIZE].copy_from_slice(key.as_ref()); + unsafe { + CryptProtectMemory( + padded_key.as_mut_ptr() as *mut core::ffi::c_void, + padded_key.len() as u32, + CRYPTPROTECTMEMORY_SAME_PROCESS, + ) + } + .expect("crypt_protect_memory should work"); + DpapiSecureKeyContainer { + dpapi_encrypted_key: padded_key, + } + } + + fn is_supported() -> bool { + // DPAPI is supported on all Windows versions that we support. + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_multiple_keys() { + let key1 = MemoryEncryptionKey::new(); + let key2 = MemoryEncryptionKey::new(); + let container1 = DpapiSecureKeyContainer::from_key(key1); + let container2 = DpapiSecureKeyContainer::from_key(key2); + + // Capture at time 1 + let data_1_1 = container1.as_key(); + let data_2_1 = container2.as_key(); + // Capture at time 2 + let data_1_2 = container1.as_key(); + let data_2_2 = container2.as_key(); + + // Same keys should be equal + assert_eq!(data_1_1.as_ref(), data_1_2.as_ref()); + assert_eq!(data_2_1.as_ref(), data_2_2.as_ref()); + + // Different keys should be different + assert_ne!(data_1_1.as_ref(), data_2_1.as_ref()); + assert_ne!(data_1_2.as_ref(), data_2_2.as_ref()); + } + + #[test] + fn test_is_supported() { + assert!(DpapiSecureKeyContainer::is_supported()); + } +} diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/keyctl.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/keyctl.rs new file mode 100644 index 00000000000..29c62759740 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/keyctl.rs @@ -0,0 +1,99 @@ +use linux_keyutils::{KeyRing, KeyRingIdentifier}; + +use super::{crypto::KEY_SIZE, SecureKeyContainer}; +use crate::secure_memory::secure_key::crypto::MemoryEncryptionKey; + +/// The keys are bound to the process keyring. +const KEY_RING_IDENTIFIER: KeyRingIdentifier = KeyRingIdentifier::Process; +/// This is an atomic global counter used to help generate unique key IDs +static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +/// Generates a unique ID for the key in the kernel keyring. +/// SAFETY: This function is safe to call from multiple threads because it uses an atomic counter. +fn make_id() -> String { + let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + // In case multiple processes are running, include the PID in the key ID. + let pid = std::process::id(); + format!("bitwarden_desktop_{}_{}", pid, counter) +} + +/// A secure key container that uses the Linux kernel keyctl API to store the key. +/// `https://man7.org/linux/man-pages/man1/keyctl.1.html`. The kernel enforces only +/// the correct process can read them, and they do not live in process memory space +/// and cannot be dumped. +pub(super) struct KeyctlSecureKeyContainer { + /// The kernel has an identifier for the key. This is randomly generated on construction. + id: String, +} + +// SAFETY: The key id is fully owned by this struct and not exposed or cloned, and cleaned up on +// drop. Further, since we use `KeyRingIdentifier::Process` and not `KeyRingIdentifier::Thread`, the +// key is accessible across threads within the same process bound. +unsafe impl Send for KeyctlSecureKeyContainer {} +// SAFETY: The container is non-mutable and thus safe to share between threads. +unsafe impl Sync for KeyctlSecureKeyContainer {} + +impl SecureKeyContainer for KeyctlSecureKeyContainer { + fn as_key(&self) -> MemoryEncryptionKey { + let ring = KeyRing::from_special_id(KEY_RING_IDENTIFIER, false) + .expect("should get process keyring"); + let key = ring.search(&self.id).expect("should find key"); + let mut buffer = [0u8; KEY_SIZE]; + key.read(&mut buffer).expect("should read key"); + MemoryEncryptionKey::from(&buffer) + } + + fn from_key(data: MemoryEncryptionKey) -> Self { + let ring = KeyRing::from_special_id(KEY_RING_IDENTIFIER, true) + .expect("should get process keyring"); + let id = make_id(); + ring.add_key(&id, &data).expect("should add key"); + KeyctlSecureKeyContainer { id } + } + + fn is_supported() -> bool { + KeyRing::from_special_id(KEY_RING_IDENTIFIER, true).is_ok() + } +} + +impl Drop for KeyctlSecureKeyContainer { + fn drop(&mut self) { + let ring = KeyRing::from_special_id(KEY_RING_IDENTIFIER, false) + .expect("should get process keyring"); + if let Ok(key) = ring.search(&self.id) { + let _ = key.invalidate(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_multiple_keys() { + let key1 = MemoryEncryptionKey::new(); + let key2 = MemoryEncryptionKey::new(); + let container1 = KeyctlSecureKeyContainer::from_key(key1); + let container2 = KeyctlSecureKeyContainer::from_key(key2); + + // Capture at time 1 + let data_1_1 = container1.as_key(); + let data_2_1 = container2.as_key(); + // Capture at time 2 + let data_1_2 = container1.as_key(); + let data_2_2 = container2.as_key(); + + // Same keys should be equal + assert_eq!(data_1_1.as_ref(), data_1_2.as_ref()); + assert_eq!(data_2_1.as_ref(), data_2_2.as_ref()); + + // Different keys should be different + assert_ne!(data_1_1.as_ref(), data_2_1.as_ref()); + assert_ne!(data_1_2.as_ref(), data_2_2.as_ref()); + } + + #[test] + fn test_is_supported() { + assert!(KeyctlSecureKeyContainer::is_supported()); + } +} diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/memfd_secret.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/memfd_secret.rs new file mode 100644 index 00000000000..e9f96db3148 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/memfd_secret.rs @@ -0,0 +1,110 @@ +use std::{ptr::NonNull, sync::LazyLock}; + +use super::{ + crypto::{MemoryEncryptionKey, KEY_SIZE}, + SecureKeyContainer, +}; + +/// https://man.archlinux.org/man/memfd_secret.2.en +/// The memfd_secret store protects the data using the `memfd_secret` syscall. The +/// data is inaccessible to other user-mode processes, and even to root in most cases. +/// If arbitrary data can be executed in the kernel, the data can still be retrieved: +/// https://github.com/JonathonReinhart/nosecmem +pub(super) struct MemfdSecretSecureKeyContainer { + ptr: NonNull<[u8]>, +} +// SAFETY: The pointers in this struct are allocated by `memfd_secret`, and we have full ownership. +// They are never exposed outside or cloned, and are cleaned up by drop. +unsafe impl Send for MemfdSecretSecureKeyContainer {} +// SAFETY: The container is non-mutable and thus safe to share between threads. Further, +// memfd-secret is accessible across threads within the same process bound. +unsafe impl Sync for MemfdSecretSecureKeyContainer {} + +impl SecureKeyContainer for MemfdSecretSecureKeyContainer { + fn as_key(&self) -> MemoryEncryptionKey { + MemoryEncryptionKey::from( + &unsafe { self.ptr.as_ref() } + .try_into() + .expect("slice should be KEY_SIZE"), + ) + } + + fn from_key(key: MemoryEncryptionKey) -> Self { + let mut ptr: NonNull<[u8]> = unsafe { + memsec::memfd_secret_sized(KEY_SIZE).expect("memfd_secret_sized should work") + }; + unsafe { + std::ptr::copy_nonoverlapping( + key.as_ref().as_ptr(), + ptr.as_mut().as_mut_ptr(), + KEY_SIZE, + ); + } + MemfdSecretSecureKeyContainer { ptr } + } + + /// Note, `memfd_secret` is only available since Linux 6.5, so fallbacks are needed. + fn is_supported() -> bool { + // To test if memfd_secret is supported, we try to allocate a 1 byte and see if that + // succeeds. + static IS_SUPPORTED: LazyLock = LazyLock::new(|| { + let Some(ptr): Option> = (unsafe { memsec::memfd_secret_sized(1) }) + else { + return false; + }; + + // Check that the pointer is readable and writable + let result = unsafe { + let ptr = ptr.as_ptr() as *mut u8; + *ptr = 30; + *ptr += 107; + *ptr == 137 + }; + + unsafe { memsec::free_memfd_secret(ptr) }; + result + }); + *IS_SUPPORTED + } +} + +impl Drop for MemfdSecretSecureKeyContainer { + fn drop(&mut self) { + unsafe { + memsec::free_memfd_secret(self.ptr); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_multiple_keys() { + let key1 = MemoryEncryptionKey::new(); + let key2 = MemoryEncryptionKey::new(); + let container1 = MemfdSecretSecureKeyContainer::from_key(key1); + let container2 = MemfdSecretSecureKeyContainer::from_key(key2); + + // Capture at time 1 + let data_1_1 = container1.as_key(); + let data_2_1 = container2.as_key(); + // Capture at time 2 + let data_1_2 = container1.as_key(); + let data_2_2 = container2.as_key(); + + // Same keys should be equal + assert_eq!(data_1_1.as_ref(), data_1_2.as_ref()); + assert_eq!(data_2_1.as_ref(), data_2_2.as_ref()); + + // Different keys should be different + assert_ne!(data_1_1.as_ref(), data_2_1.as_ref()); + assert_ne!(data_1_2.as_ref(), data_2_2.as_ref()); + } + + #[test] + fn test_is_supported() { + assert!(MemfdSecretSecureKeyContainer::is_supported()); + } +} diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mlock.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mlock.rs new file mode 100644 index 00000000000..961988c1d40 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mlock.rs @@ -0,0 +1,84 @@ +use std::ptr::NonNull; + +use super::{ + crypto::{MemoryEncryptionKey, KEY_SIZE}, + SecureKeyContainer, +}; + +/// A SecureKeyContainer that uses mlock to prevent the memory from being swapped to disk. +/// This does not provide as strong protections as other methods, but is always supported. +pub(super) struct MlockSecureKeyContainer { + ptr: NonNull<[u8]>, +} +// SAFETY: The pointers in this struct are allocated by `malloc_sized`, and we have full ownership. +// They are never exposed outside or cloned, and are cleaned up by drop. +unsafe impl Send for MlockSecureKeyContainer {} +// SAFETY: The container is non-mutable and thus safe to share between threads. +unsafe impl Sync for MlockSecureKeyContainer {} + +impl SecureKeyContainer for MlockSecureKeyContainer { + fn as_key(&self) -> MemoryEncryptionKey { + MemoryEncryptionKey::from( + &unsafe { self.ptr.as_ref() } + .try_into() + .expect("slice should be KEY_SIZE"), + ) + } + fn from_key(key: MemoryEncryptionKey) -> Self { + let mut ptr: NonNull<[u8]> = + unsafe { memsec::malloc_sized(KEY_SIZE).expect("malloc_sized should work") }; + unsafe { + std::ptr::copy_nonoverlapping( + key.as_ref().as_ptr(), + ptr.as_mut().as_mut_ptr(), + KEY_SIZE, + ); + } + MlockSecureKeyContainer { ptr } + } + + fn is_supported() -> bool { + true + } +} + +impl Drop for MlockSecureKeyContainer { + fn drop(&mut self) { + unsafe { + memsec::free(self.ptr); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_multiple_keys() { + let key1 = MemoryEncryptionKey::new(); + let key2 = MemoryEncryptionKey::new(); + let container1 = MlockSecureKeyContainer::from_key(key1); + let container2 = MlockSecureKeyContainer::from_key(key2); + + // Capture at time 1 + let data_1_1 = container1.as_key(); + let data_2_1 = container2.as_key(); + // Capture at time 2 + let data_1_2 = container1.as_key(); + let data_2_2 = container2.as_key(); + + // Same keys should be equal + assert_eq!(data_1_1.as_ref(), data_1_2.as_ref()); + assert_eq!(data_2_1.as_ref(), data_2_2.as_ref()); + + // Different keys should be different + assert_ne!(data_1_1.as_ref(), data_2_1.as_ref()); + assert_ne!(data_1_2.as_ref(), data_2_2.as_ref()); + } + + #[test] + fn test_is_supported() { + assert!(MlockSecureKeyContainer::is_supported()); + } +} 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 new file mode 100644 index 00000000000..26e72f7d581 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mod.rs @@ -0,0 +1,247 @@ +//! This module provides hardened storage for single cryptographic keys. These are meant for +//! encrypting large amounts of memory. Some platforms restrict how many keys can be protected by +//! their APIs, which necessitates this layer of indirection. This significantly reduces the +//! complexity of each platform specific implementation, since all that's needed is implementing +//! protecting a single fixed sized key instead of protecting many arbitrarily sized secrets. This +//! significantly lowers the effort to maintain each implementation. +//! +//! The implementations include DPAPI on Windows, `keyctl` on Linux, and `memfd_secret` on Linux, +//! and a fallback implementation using mlock. + +use tracing::info; + +mod crypto; +#[cfg(target_os = "windows")] +mod dpapi; +#[cfg(target_os = "linux")] +mod keyctl; +#[cfg(target_os = "linux")] +mod memfd_secret; +mod mlock; + +pub use crypto::EncryptedMemory; + +use crate::secure_memory::secure_key::crypto::DecryptionError; + +/// 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, +/// persistent data cannot be encrypted with this key. On Linux and Windows, in most cases the +/// protection mechanisms prevent memory dumps/debuggers from reading the key. +/// +/// Note: This can be circumvented if code can be injected into the process and is only effective in +/// combination with the memory isolation provided in `process_isolation`. +/// - https://github.com/zer1t0/keydump +#[allow(unused)] +pub(crate) struct SecureMemoryEncryptionKey(CrossPlatformSecureKeyContainer); + +impl SecureMemoryEncryptionKey { + pub fn new() -> Self { + SecureMemoryEncryptionKey(CrossPlatformSecureKeyContainer::from_key( + crypto::MemoryEncryptionKey::new(), + )) + } + + /// Encrypts the provided plaintext using the contained key, returning an EncryptedMemory blob. + #[allow(unused)] + pub fn encrypt(&self, plaintext: &[u8]) -> crypto::EncryptedMemory { + self.0.as_key().encrypt(plaintext) + } + + /// Decrypts the provided EncryptedMemory blob using the contained key, returning the plaintext. + /// If the decryption fails, that means the memory was tampered with, and the function panics. + #[allow(unused)] + pub fn decrypt(&self, encrypted: &crypto::EncryptedMemory) -> Result, DecryptionError> { + self.0.as_key().decrypt(encrypted) + } +} + +/// A platform specific implementation of a key container that protects a single encryption key +/// from memory attacks. +#[allow(unused)] +trait SecureKeyContainer: Sync + Send { + /// Returns the key as a byte slice. This slice does not have additional memory protections + /// applied. + fn as_key(&self) -> crypto::MemoryEncryptionKey; + /// Creates a new SecureKeyContainer from the provided key. + fn from_key(key: crypto::MemoryEncryptionKey) -> Self; + /// Returns true if this platform supports this secure key container implementation. + fn is_supported() -> bool; +} + +#[allow(unused)] +enum CrossPlatformSecureKeyContainer { + #[cfg(target_os = "windows")] + Dpapi(dpapi::DpapiSecureKeyContainer), + #[cfg(target_os = "linux")] + Keyctl(keyctl::KeyctlSecureKeyContainer), + #[cfg(target_os = "linux")] + MemfdSecret(memfd_secret::MemfdSecretSecureKeyContainer), + Mlock(mlock::MlockSecureKeyContainer), +} + +impl SecureKeyContainer for CrossPlatformSecureKeyContainer { + fn as_key(&self) -> crypto::MemoryEncryptionKey { + match self { + #[cfg(target_os = "windows")] + CrossPlatformSecureKeyContainer::Dpapi(c) => c.as_key(), + #[cfg(target_os = "linux")] + CrossPlatformSecureKeyContainer::Keyctl(c) => c.as_key(), + #[cfg(target_os = "linux")] + CrossPlatformSecureKeyContainer::MemfdSecret(c) => c.as_key(), + CrossPlatformSecureKeyContainer::Mlock(c) => c.as_key(), + } + } + + fn from_key(key: crypto::MemoryEncryptionKey) -> Self { + if let Some(container) = get_env_forced_container() { + return container; + } + + #[cfg(target_os = "windows")] + { + if dpapi::DpapiSecureKeyContainer::is_supported() { + info!("Using DPAPI for secure key storage"); + return CrossPlatformSecureKeyContainer::Dpapi( + dpapi::DpapiSecureKeyContainer::from_key(key), + ); + } + } + #[cfg(target_os = "linux")] + { + // Memfd_secret is slightly better in some cases of the kernel being compromised. + // Note that keyctl may sometimes not be available in e.g. snap. Memfd_secret is + // not available on kernels older than 6.5 while keyctl is supported since 2.6. + // + // Note: This may prevent the system from hibernating but not sleeping. Hibernate + // would write the memory to disk, exposing the keys. If this is an issue, + // the environment variable `SECURE_KEY_CONTAINER_BACKEND` can be used + // to force the use of keyctl or mlock. + if memfd_secret::MemfdSecretSecureKeyContainer::is_supported() { + info!("Using memfd_secret for secure key storage"); + return CrossPlatformSecureKeyContainer::MemfdSecret( + memfd_secret::MemfdSecretSecureKeyContainer::from_key(key), + ); + } + if keyctl::KeyctlSecureKeyContainer::is_supported() { + info!("Using keyctl for secure key storage"); + return CrossPlatformSecureKeyContainer::Keyctl( + keyctl::KeyctlSecureKeyContainer::from_key(key), + ); + } + } + + // Falling back to mlock means that the key is accessible via memory dumping. + info!("Falling back to mlock for secure key storage"); + CrossPlatformSecureKeyContainer::Mlock(mlock::MlockSecureKeyContainer::from_key(key)) + } + + fn is_supported() -> bool { + // Mlock is always supported as a fallback. + true + } +} + +fn get_env_forced_container() -> Option { + let env_var = std::env::var("SECURE_KEY_CONTAINER_BACKEND"); + match env_var.as_deref() { + #[cfg(target_os = "windows")] + Ok("dpapi") => { + info!("Forcing DPAPI secure key container via environment variable"); + Some(CrossPlatformSecureKeyContainer::Dpapi( + dpapi::DpapiSecureKeyContainer::from_key(crypto::MemoryEncryptionKey::new()), + )) + } + #[cfg(target_os = "linux")] + Ok("memfd_secret") => { + info!("Forcing memfd_secret secure key container via environment variable"); + Some(CrossPlatformSecureKeyContainer::MemfdSecret( + memfd_secret::MemfdSecretSecureKeyContainer::from_key( + crypto::MemoryEncryptionKey::new(), + ), + )) + } + #[cfg(target_os = "linux")] + Ok("keyctl") => { + info!("Forcing keyctl secure key container via environment variable"); + Some(CrossPlatformSecureKeyContainer::Keyctl( + keyctl::KeyctlSecureKeyContainer::from_key(crypto::MemoryEncryptionKey::new()), + )) + } + Ok("mlock") => { + info!("Forcing mlock secure key container via environment variable"); + Some(CrossPlatformSecureKeyContainer::Mlock( + mlock::MlockSecureKeyContainer::from_key(crypto::MemoryEncryptionKey::new()), + )) + } + _ => { + info!( + "{} is not a valid secure key container backend, using automatic selection", + env_var.unwrap_or_default() + ); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_multiple_keys() { + // Create 20 different keys + let original_keys: Vec = (0..20) + .map(|_| crypto::MemoryEncryptionKey::new()) + .collect(); + + // Store them in secure containers + let containers: Vec = original_keys + .iter() + .map(|key| { + let key_bytes: &[u8; crypto::KEY_SIZE] = key.as_ref().try_into().unwrap(); + CrossPlatformSecureKeyContainer::from_key(crypto::MemoryEncryptionKey::from( + key_bytes, + )) + }) + .collect(); + + // Read all keys back and validate they match the originals + for (i, (original_key, container)) in + original_keys.iter().zip(containers.iter()).enumerate() + { + let retrieved_key = container.as_key(); + assert_eq!( + original_key.as_ref(), + retrieved_key.as_ref(), + "Key {} should match after storage and retrieval", + i + ); + } + + // Verify all keys are different from each other + for i in 0..original_keys.len() { + for j in (i + 1)..original_keys.len() { + assert_ne!( + original_keys[i].as_ref(), + original_keys[j].as_ref(), + "Keys {} and {} should be different", + i, + j + ); + } + } + + // Read keys back a second time to ensure consistency + for (i, (original_key, container)) in + original_keys.iter().zip(containers.iter()).enumerate() + { + let retrieved_key_again = container.as_key(); + assert_eq!( + original_key.as_ref(), + retrieved_key_again.as_ref(), + "Key {} should still match on second retrieval", + i + ); + } + } +} 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 d038ba2277f..8ba64618ffa 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs @@ -1,16 +1,19 @@ -use std::sync::{ - atomic::{AtomicBool, AtomicU32}, - Arc, +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicBool, AtomicU32}, + Arc, RwLock, + }, }; use base64::{engine::general_purpose::STANDARD, Engine as _}; -use tokio::sync::Mutex; -use tokio_util::sync::CancellationToken; - use bitwarden_russh::{ session_bind::SessionBindResult, ssh_agent::{self, SshKey}, }; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; +use tracing::{error, info}; #[cfg_attr(target_os = "windows", path = "windows.rs")] #[cfg_attr(target_os = "macos", path = "unix.rs")] @@ -24,13 +27,14 @@ pub mod peerinfo; mod request_parser; #[derive(Clone)] -pub struct BitwardenDesktopAgent { - keystore: ssh_agent::KeyStore, +pub struct BitwardenDesktopAgent { + keystore: ssh_agent::KeyStore, cancellation_token: CancellationToken, show_ui_request_tx: tokio::sync::mpsc::Sender, get_ui_response_rx: Arc>>, request_id: Arc, - /// before first unlock, or after account switching, listing keys should require an unlock to get a list of public keys + /// before first unlock, or after account switching, listing keys should require an unlock to + /// get a list of public keys needs_unlock: Arc, is_running: Arc, } @@ -76,9 +80,7 @@ impl SshKey for BitwardenSshKey { } } -impl ssh_agent::Agent - for BitwardenDesktopAgent -{ +impl ssh_agent::Agent for BitwardenDesktopAgent { async fn confirm( &self, ssh_key: BitwardenSshKey, @@ -86,7 +88,7 @@ impl ssh_agent::Agent info: &peerinfo::models::PeerInfo, ) -> bool { if !self.is_running() { - println!("[BitwardenDesktopAgent] Agent is not running, but tried to call confirm"); + error!("Agent is not running, but tried to call confirm"); return false; } @@ -94,7 +96,7 @@ impl ssh_agent::Agent let request_data = match request_parser::parse_request(data) { Ok(data) => data, Err(e) => { - println!("[SSH Agent] Error while parsing request: {e}"); + error!(error = %e, "Error while parsing request"); return false; } }; @@ -105,12 +107,12 @@ impl ssh_agent::Agent _ => None, }; - println!( - "[SSH Agent] Confirming request from application: {}, is_forwarding: {}, namespace: {}, host_key: {}", + info!( + is_forwarding = %info.is_forwarding(), + namespace = ?namespace.as_ref(), + host_key = %STANDARD.encode(info.host_key()), + "Confirming request from application: {}", info.process_name(), - info.is_forwarding(), - namespace.clone().unwrap_or_default(), - STANDARD.encode(info.host_key()) ); let mut rx_channel = self.get_ui_response_rx.lock().await.resubscribe(); @@ -172,16 +174,32 @@ impl ssh_agent::Agent connection_info.set_host_key(session_bind_info.host_key.clone()); } SessionBindResult::SignatureFailure => { - println!("[BitwardenDesktopAgent] Session bind failure: Signature failure"); + error!("Session bind failure: Signature failure"); } } } } -impl BitwardenDesktopAgent { +impl BitwardenDesktopAgent { + /// Create a new `BitwardenDesktopAgent` from the provided auth channel handles. + pub fn new( + auth_request_tx: tokio::sync::mpsc::Sender, + auth_response_rx: Arc>>, + ) -> Self { + Self { + keystore: ssh_agent::KeyStore(Arc::new(RwLock::new(HashMap::new()))), + cancellation_token: CancellationToken::new(), + show_ui_request_tx: auth_request_tx, + get_ui_response_rx: auth_response_rx, + request_id: Arc::new(AtomicU32::new(0)), + needs_unlock: Arc::new(AtomicBool::new(true)), + is_running: Arc::new(AtomicBool::new(false)), + } + } + pub fn stop(&self) { if !self.is_running() { - println!("[BitwardenDesktopAgent] Tried to stop agent while it is not running"); + error!("Tried to stop agent while it is not running"); return; } @@ -227,7 +245,7 @@ impl BitwardenDesktopAgent { ); } Err(e) => { - eprintln!("[SSH Agent Native Module] Error while parsing key: {e}"); + error!(error=%e, "Error while parsing key"); } } } @@ -265,7 +283,7 @@ impl BitwardenDesktopAgent { fn get_request_id(&self) -> u32 { if !self.is_running() { - println!("[BitwardenDesktopAgent] Agent is not running, but tried to get request id"); + error!("Agent is not running, but tried to get request id"); return 0; } diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs b/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs index fccd7ca5ed6..38b2193faf5 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs @@ -1,7 +1,6 @@ -use futures::Stream; -use std::os::windows::prelude::AsRawHandle as _; use std::{ io, + os::windows::prelude::AsRawHandle as _, pin::Pin, sync::{ atomic::{AtomicBool, Ordering}, @@ -9,11 +8,14 @@ use std::{ }, task::{Context, Poll}, }; + +use futures::Stream; use tokio::{ net::windows::named_pipe::{NamedPipeServer, ServerOptions}, select, }; use tokio_util::sync::CancellationToken; +use tracing::{error, info}; use windows::Win32::{Foundation::HANDLE, System::Pipes::GetNamedPipeClientProcessId}; use crate::ssh_agent::peerinfo::{self, models::PeerInfo}; @@ -31,42 +33,38 @@ impl NamedPipeServerStream { pub fn new(cancellation_token: CancellationToken, is_running: Arc) -> Self { let (tx, rx) = tokio::sync::mpsc::channel(16); tokio::spawn(async move { - println!( - "[SSH Agent Native Module] Creating named pipe server on {}", - PIPE_NAME - ); + info!("Creating named pipe server on {}", PIPE_NAME); let mut listener = match ServerOptions::new().create(PIPE_NAME) { Ok(pipe) => pipe, - Err(err) => { - println!("[SSH Agent Native Module] Encountered an error creating the first pipe. The system's openssh service must likely be disabled"); - println!("[SSH Agent Natvie Module] error: {}", err); + Err(e) => { + error!(error = %e, "Encountered an error creating the first pipe. The system's openssh service must likely be disabled"); cancellation_token.cancel(); is_running.store(false, Ordering::Relaxed); return; } }; loop { - println!("[SSH Agent Native Module] Waiting for connection"); + info!("Waiting for connection"); select! { _ = cancellation_token.cancelled() => { - println!("[SSH Agent Native Module] Cancellation token triggered, stopping named pipe server"); + info!("[SSH Agent Native Module] Cancellation token triggered, stopping named pipe server"); break; } _ = listener.connect() => { - println!("[SSH Agent Native Module] Incoming connection"); + info!("[SSH Agent Native Module] Incoming connection"); let handle = HANDLE(listener.as_raw_handle()); let mut pid = 0; unsafe { if let Err(e) = GetNamedPipeClientProcessId(handle, &mut pid) { - println!("Error getting named pipe client process id {}", e); + error!(error = %e, pid, "Faile to get named pipe client process id"); continue } }; let peer_info = peerinfo::gather::get_peer_info(pid); let peer_info = match peer_info { - Err(err) => { - println!("Failed getting process info for pid {} {}", pid, err); + Err(e) => { + error!(error = %e, pid = %pid, "Failed getting process info"); continue }, Ok(info) => info, @@ -76,8 +74,8 @@ impl NamedPipeServerStream { listener = match ServerOptions::new().create(PIPE_NAME) { Ok(pipe) => pipe, - Err(err) => { - println!("[SSH Agent Native Module] Encountered an error creating a new pipe {}", err); + Err(e) => { + error!(error = %e, "Encountered an error creating a new pipe"); cancellation_token.cancel(); is_running.store(false, Ordering::Relaxed); return; diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/peercred_unix_listener_stream.rs b/apps/desktop/desktop_native/core/src/ssh_agent/peercred_unix_listener_stream.rs index 77eec5e35c7..5b6b1d8f36b 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/peercred_unix_listener_stream.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/peercred_unix_listener_stream.rs @@ -1,11 +1,13 @@ +use std::{ + io, + pin::Pin, + task::{Context, Poll}, +}; + use futures::Stream; -use std::io; -use std::pin::Pin; -use std::task::{Context, Poll}; use tokio::net::{UnixListener, UnixStream}; -use super::peerinfo; -use super::peerinfo::models::PeerInfo; +use super::{peerinfo, peerinfo::models::PeerInfo}; #[derive(Debug)] pub struct PeercredUnixListenerStream { diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/models.rs b/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/models.rs index fad535cb80e..74b909f5ce7 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/models.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/models.rs @@ -1,9 +1,10 @@ use std::sync::{atomic::AtomicBool, Arc, Mutex}; /** -* Peerinfo represents the information of a peer process connecting over a socket. -* This can be later extended to include more information (icon, app name) for the corresponding application. -*/ + * Peerinfo represents the information of a peer process connecting over a socket. + * This can be later extended to include more information (icon, app name) for the corresponding + * application. + */ #[derive(Debug, Clone)] pub struct PeerInfo { uid: u32, diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs index 53142d4c476..8623df13776 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs @@ -1,95 +1,52 @@ -use std::{ - collections::HashMap, - fs, - os::unix::fs::PermissionsExt, - sync::{ - atomic::{AtomicBool, AtomicU32}, - Arc, RwLock, - }, -}; +use std::{fs, os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc}; +use anyhow::anyhow; use bitwarden_russh::ssh_agent; use homedir::my_home; use tokio::{net::UnixListener, sync::Mutex}; -use tokio_util::sync::CancellationToken; +use tracing::{error, info}; +use super::{BitwardenDesktopAgent, SshAgentUIRequest}; use crate::ssh_agent::peercred_unix_listener_stream::PeercredUnixListenerStream; -use super::{BitwardenDesktopAgent, BitwardenSshKey, SshAgentUIRequest}; +/// User can override the default socket path with this env var +const ENV_BITWARDEN_SSH_AUTH_SOCK: &str = "BITWARDEN_SSH_AUTH_SOCK"; -impl BitwardenDesktopAgent { +const FLATPAK_DATA_DIR: &str = ".var/app/com.bitwarden.desktop/data"; + +const SOCKFILE_NAME: &str = ".bitwarden-ssh-agent.sock"; + +impl BitwardenDesktopAgent { + /// Starts the Bitwarden Desktop SSH Agent server. + /// # Errors + /// Will return `Err` if unable to create and set permissions for socket file path or + /// if unable to bind to the socket path. pub fn start_server( auth_request_tx: tokio::sync::mpsc::Sender, auth_response_rx: Arc>>, ) -> Result { - let agent = BitwardenDesktopAgent { - keystore: ssh_agent::KeyStore(Arc::new(RwLock::new(HashMap::new()))), - cancellation_token: CancellationToken::new(), - show_ui_request_tx: auth_request_tx, - get_ui_response_rx: auth_response_rx, - request_id: Arc::new(AtomicU32::new(0)), - needs_unlock: Arc::new(AtomicBool::new(true)), - is_running: Arc::new(AtomicBool::new(false)), - }; - let cloned_agent_state = agent.clone(); - tokio::spawn(async move { - let ssh_path = match std::env::var("BITWARDEN_SSH_AUTH_SOCK") { - Ok(path) => path, - Err(_) => { - println!("[SSH Agent Native Module] BITWARDEN_SSH_AUTH_SOCK not set, using default path"); + let agent_state = BitwardenDesktopAgent::new(auth_request_tx, auth_response_rx); - let ssh_agent_directory = match my_home() { - Ok(Some(home)) => home, - _ => { - println!( - "[SSH Agent Native Module] Could not determine home directory" - ); - return; - } - }; + let socket_path = get_socket_path()?; - let is_flatpak = std::env::var("container") == Ok("flatpak".to_string()); - if !is_flatpak { - ssh_agent_directory - .join(".bitwarden-ssh-agent.sock") - .to_str() - .expect("Path should be valid") - .to_owned() - } else { - ssh_agent_directory - .join(".var/app/com.bitwarden.desktop/data/.bitwarden-ssh-agent.sock") - .to_str() - .expect("Path should be valid") - .to_owned() - } - } - }; + // if the socket is already present and wasn't cleanly removed during a previous + // runtime, remove it before beginning anew. + remove_path(&socket_path)?; - println!("[SSH Agent Native Module] Starting SSH Agent server on {ssh_path:?}"); - let sockname = std::path::Path::new(&ssh_path); - if let Err(e) = std::fs::remove_file(sockname) { - println!("[SSH Agent Native Module] Could not remove existing socket file: {e}"); - if e.kind() != std::io::ErrorKind::NotFound { - return; - } - } + info!(?socket_path, "Starting SSH Agent server"); - match UnixListener::bind(sockname) { - Ok(listener) => { - // Only the current user should be able to access the socket - if let Err(e) = fs::set_permissions(sockname, fs::Permissions::from_mode(0o600)) - { - println!("[SSH Agent Native Module] Could not set socket permissions: {e}"); - return; - } + match UnixListener::bind(socket_path.clone()) { + Ok(listener) => { + // Only the current user should be able to access the socket + set_user_permissions(&socket_path)?; - let stream = PeercredUnixListenerStream::new(listener); + let stream = PeercredUnixListenerStream::new(listener); - let cloned_keystore = cloned_agent_state.keystore.clone(); - let cloned_cancellation_token = cloned_agent_state.cancellation_token.clone(); - cloned_agent_state - .is_running - .store(true, std::sync::atomic::Ordering::Relaxed); + let cloned_agent_state = agent_state.clone(); + let cloned_keystore = cloned_agent_state.keystore.clone(); + let cloned_cancellation_token = cloned_agent_state.cancellation_token.clone(); + + tokio::spawn(async move { let _ = ssh_agent::serve( stream, cloned_agent_state.clone(), @@ -97,17 +54,132 @@ impl BitwardenDesktopAgent { cloned_cancellation_token, ) .await; + cloned_agent_state .is_running .store(false, std::sync::atomic::Ordering::Relaxed); - println!("[SSH Agent Native Module] SSH Agent server exited"); - } - Err(e) => { - eprintln!("[SSH Agent Native Module] Error while starting agent server: {e}"); - } - } - }); - Ok(agent) + info!("SSH Agent server exited"); + }); + + agent_state + .is_running + .store(true, std::sync::atomic::Ordering::Relaxed); + + info!(?socket_path, "SSH Agent is running."); + } + Err(error) => { + error!(%error, ?socket_path, "Unable to start start agent server"); + return Err(error.into()); + } + } + + Ok(agent_state) + } +} + +// one of the following: +// - only the env var socket path if it is defined +// - the $HOME path and our well known extension +fn get_socket_path() -> Result { + if let Ok(path) = std::env::var(ENV_BITWARDEN_SSH_AUTH_SOCK) { + Ok(PathBuf::from(path)) + } else { + info!("BITWARDEN_SSH_AUTH_SOCK not set, using default path"); + get_default_socket_path() + } +} + +fn is_flatpak() -> bool { + std::env::var("container") == Ok("flatpak".to_string()) +} + +// use the $HOME directory +fn get_default_socket_path() -> Result { + let Ok(Some(mut ssh_agent_directory)) = my_home() else { + error!("Could not determine home directory"); + return Err(anyhow!("Could not determine home directory.")); + }; + + if is_flatpak() { + ssh_agent_directory = ssh_agent_directory.join(FLATPAK_DATA_DIR); + } + + ssh_agent_directory = ssh_agent_directory.join(SOCKFILE_NAME); + + Ok(ssh_agent_directory) +} + +fn set_user_permissions(path: &PathBuf) -> Result<(), anyhow::Error> { + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .map_err(|e| anyhow!("Could not set socket permissions for {path:?}: {e}")) +} + +// try to remove the given path if it exists +fn remove_path(path: &PathBuf) -> Result<(), anyhow::Error> { + if let Ok(true) = std::fs::exists(path) { + std::fs::remove_file(path).map_err(|e| anyhow!("Error removing socket {path:?}: {e}"))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use rand::{distr::Alphanumeric, Rng}; + + use super::*; + + #[test] + fn test_default_socket_path_success() { + let path = get_default_socket_path().unwrap(); + let expected = PathBuf::from_iter([ + std::env::var("HOME").unwrap(), + ".bitwarden-ssh-agent.sock".to_string(), + ]); + assert_eq!(path, expected); + } + + fn rand_file_in_temp() -> PathBuf { + let mut path = std::env::temp_dir(); + let s: String = rand::rng() + .sample_iter(&Alphanumeric) + .take(16) + .map(char::from) + .collect(); + path.push(s); + path + } + + #[test] + fn test_remove_path_exists_success() { + let path = rand_file_in_temp(); + fs::write(&path, "").unwrap(); + remove_path(&path).unwrap(); + + assert!(!fs::exists(&path).unwrap()); + } + + // the remove_path should not fail if the path does not exist + #[test] + fn test_remove_path_not_found_success() { + let path = rand_file_in_temp(); + remove_path(&path).unwrap(); + + assert!(!fs::exists(&path).unwrap()); + } + + #[test] + fn test_sock_path_file_permissions() { + let path = rand_file_in_temp(); + fs::write(&path, "").unwrap(); + + set_user_permissions(&path).unwrap(); + + let metadata = fs::metadata(&path).unwrap(); + let permissions = metadata.permissions().mode(); + + assert_eq!(permissions, 0o100_600); + + remove_path(&path).unwrap(); } } diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs b/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs index 75c47165960..2012dab2d77 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs @@ -1,32 +1,19 @@ use bitwarden_russh::ssh_agent; pub mod named_pipe_listener_stream; -use std::{ - collections::HashMap, - sync::{ - atomic::{AtomicBool, AtomicU32}, - Arc, RwLock, - }, -}; +use std::sync::Arc; + use tokio::sync::Mutex; -use tokio_util::sync::CancellationToken; -use super::{BitwardenDesktopAgent, BitwardenSshKey, SshAgentUIRequest}; +use super::{BitwardenDesktopAgent, SshAgentUIRequest}; -impl BitwardenDesktopAgent { +impl BitwardenDesktopAgent { pub fn start_server( auth_request_tx: tokio::sync::mpsc::Sender, auth_response_rx: Arc>>, ) -> Result { - let agent_state = BitwardenDesktopAgent { - keystore: ssh_agent::KeyStore(Arc::new(RwLock::new(HashMap::new()))), - show_ui_request_tx: auth_request_tx, - get_ui_response_rx: auth_response_rx, - cancellation_token: CancellationToken::new(), - request_id: Arc::new(AtomicU32::new(0)), - needs_unlock: Arc::new(AtomicBool::new(true)), - is_running: Arc::new(AtomicBool::new(true)), - }; + let agent_state = BitwardenDesktopAgent::new(auth_request_tx, auth_response_rx); + let stream = named_pipe_listener_stream::NamedPipeServerStream::new( agent_state.cancellation_token.clone(), agent_state.is_running.clone(), diff --git a/apps/desktop/desktop_native/deny.toml b/apps/desktop/desktop_native/deny.toml new file mode 100644 index 00000000000..7d7a126f694 --- /dev/null +++ b/apps/desktop/desktop_native/deny.toml @@ -0,0 +1,40 @@ +# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html +[advisories] +ignore = [ + # Vulnerability in `rsa` crate: https://rustsec.org/advisories/RUSTSEC-2023-0071.html + { id = "RUSTSEC-2023-0071", reason = "There is no fix available yet." }, + { id = "RUSTSEC-2024-0436", reason = "paste crate is unmaintained."} +] + +# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html +[licenses] +# See https://spdx.org/licenses/ for list of possible licenses +allow = [ + "0BSD", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "ISC", + "MIT", + "MPL-2.0", + "Unicode-3.0", + "Zlib", +] + + +[licenses.private] +# If true, ignores workspace crates that aren't published, or are only +# published to private registries. +# To see how to mark a crate as unpublished (to the official registry), +# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. +ignore = true + +# This section is considered when running `cargo deny check bans`. +# More documentation about the 'bans' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html +[bans] +deny = [ +# TODO: enable after https://github.com/bitwarden/clients/pull/16761 is merged +# { name = "log", wrappers = [], reason = "Use `tracing` and `tracing-subscriber` for observability needs." }, +] diff --git a/apps/desktop/desktop_native/macos_provider/Cargo.toml b/apps/desktop/desktop_native/macos_provider/Cargo.toml index 9f042209b06..50f1834851d 100644 --- a/apps/desktop/desktop_native/macos_provider/Cargo.toml +++ b/apps/desktop/desktop_native/macos_provider/Cargo.toml @@ -14,18 +14,17 @@ crate-type = ["staticlib", "cdylib"] bench = false [dependencies] -desktop_core = { path = "../core" } -futures = { workspace = true } -log = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -tokio = { workspace = true, features = ["sync"] } -tokio-util = { workspace = true } -tracing = { workspace = true } uniffi = { workspace = true, features = ["cli"] } [target.'cfg(target_os = "macos")'.dependencies] -oslog = { workspace = true } +desktop_core = { path = "../core" } +futures = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["sync"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +tracing-oslog = "0.3.0" [build-dependencies] uniffi = { workspace = true, features = ["build"] } diff --git a/apps/desktop/desktop_native/macos_provider/src/lib.rs b/apps/desktop/desktop_native/macos_provider/src/lib.rs index ded133bcb54..a5a134b0bfe 100644 --- a/apps/desktop/desktop_native/macos_provider/src/lib.rs +++ b/apps/desktop/desktop_native/macos_provider/src/lib.rs @@ -1,14 +1,20 @@ #![cfg(target_os = "macos")] +#![allow(clippy::disallowed_macros)] // uniffi macros trip up clippy's evaluation use std::{ collections::HashMap, - sync::{atomic::AtomicU32, Arc, Mutex}, + 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!(); @@ -21,6 +27,8 @@ use assertion::{ }; use registration::{PasskeyRegistrationRequest, PreparePasskeyRegistrationCallback}; +static INIT: Once = Once::new(); + #[derive(uniffi::Enum, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum UserVerification { @@ -65,9 +73,20 @@ impl MacOSProviderClient { #[allow(clippy::unwrap_used)] #[uniffi::constructor] pub fn connect() -> Self { - let _ = oslog::OsLogger::new("com.bitwarden.desktop.autofill-extension") - .level_filter(log::LevelFilter::Trace) - .init(); + 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); @@ -78,7 +97,7 @@ impl MacOSProviderClient { response_callbacks_queue: Arc::new(Mutex::new(HashMap::new())), }; - let path = desktop_core::ipc::path("autofill"); + let path = desktop_core::ipc::path("af"); let queue = client.response_callbacks_queue.clone(); diff --git a/apps/desktop/desktop_native/napi/Cargo.toml b/apps/desktop/desktop_native/napi/Cargo.toml index 5e2e42b463f..b5847a602d5 100644 --- a/apps/desktop/desktop_native/napi/Cargo.toml +++ b/apps/desktop/desktop_native/napi/Cargo.toml @@ -16,17 +16,13 @@ manual_test = [] [dependencies] anyhow = { workspace = true } autotype = { path = "../autotype" } -base64 = { workspace = true } -bitwarden_chromium_importer = { path = "../bitwarden_chromium_importer" } +chromium_importer = { path = "../chromium_importer" } desktop_core = { path = "../core" } -hex = { workspace = true } napi = { workspace = true, features = ["async"] } napi-derive = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true } -tokio-stream = { workspace = true } -tokio-util = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 030bf4c964d..01bfa65d571 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -11,7 +11,10 @@ export declare namespace passwords { * Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist. */ export function getPassword(service: string, account: string): Promise - /** Save the password to the keychain. Adds an entry if none exists otherwise updates the existing entry. */ + /** + * Save the password to the keychain. Adds an entry if none exists otherwise updates the + * existing entry. + */ export function setPassword(service: string, account: string, password: string): Promise /** * Delete the stored password from the keychain. @@ -35,7 +38,8 @@ export declare namespace biometrics { * 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. + * If the iv is provided, it will be used as the challenge. Otherwise a random challenge will + * be generated. * * `format!("|")` */ @@ -49,6 +53,18 @@ export declare namespace biometrics { ivB64: string } } +export declare namespace biometrics_v2 { + export function initBiometricSystem(): BiometricLockSystem + export function authenticate(biometricLockSystem: BiometricLockSystem, hwnd: Buffer, message: string): Promise + export function authenticateAvailable(biometricLockSystem: BiometricLockSystem): Promise + export function enrollPersistent(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise + export function provideKey(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise + export function unlock(biometricLockSystem: BiometricLockSystem, userId: string, hwnd: Buffer): Promise + export function unlockAvailable(biometricLockSystem: BiometricLockSystem, userId: string): Promise + export function hasPersistent(biometricLockSystem: BiometricLockSystem, userId: string): Promise + export function unenroll(biometricLockSystem: BiometricLockSystem, userId: string): Promise + export class BiometricLockSystem { } +} export declare namespace clipboards { export function read(): Promise export function write(text: string, password: boolean): Promise @@ -107,8 +123,9 @@ export declare namespace ipc { /** * 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. + * @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. */ static listen(name: string, callback: (error: null | Error, message: IpcMessage) => void): Promise /** Return the path to the IPC server. */ @@ -118,8 +135,9 @@ export declare namespace ipc { /** * 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. + * @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. */ send(message: string): number } @@ -182,8 +200,9 @@ export declare namespace autofill { /** * 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. + * @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. */ static listen(name: string, registrationCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void, assertionCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void, assertionWithoutUserInterfaceCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void): Promise /** Return the path to the IPC server. */ @@ -228,7 +247,13 @@ export declare namespace chromium_importer { login?: Login failure?: LoginImportFailure } - export function getInstalledBrowsers(): Array + export interface NativeImporterMetadata { + id: string + loaders: Array + instructions: string + } + /** Returns OS aware metadata describing supported Chromium based importers as a JSON string. */ + export function getMetadata(): Record export function getAvailableProfiles(browser: string): Array export function importLogins(browser: string, profileId: string): Promise> } diff --git a/apps/desktop/desktop_native/napi/index.js b/apps/desktop/desktop_native/napi/index.js index acfd0dffb89..64819be4405 100644 --- a/apps/desktop/desktop_native/napi/index.js +++ b/apps/desktop/desktop_native/napi/index.js @@ -78,12 +78,6 @@ switch (platform) { throw new Error(`Unsupported architecture on macOS: ${arch}`); } break; - case "freebsd": - nativeBinding = loadFirstAvailable( - ["desktop_napi.freebsd-x64.node"], - "@bitwarden/desktop-napi-freebsd-x64", - ); - break; case "linux": switch (arch) { case "x64": diff --git a/apps/desktop/desktop_native/napi/package.json b/apps/desktop/desktop_native/napi/package.json index d557ccfd259..ca17377c9f2 100644 --- a/apps/desktop/desktop_native/napi/package.json +++ b/apps/desktop/desktop_native/napi/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "description": "", "scripts": { - "build": "napi build --platform --js false", + "build": "node scripts/build.js", "test": "cargo test" }, "author": "", diff --git a/apps/desktop/desktop_native/napi/scripts/build.js b/apps/desktop/desktop_native/napi/scripts/build.js new file mode 100644 index 00000000000..a6680f5d311 --- /dev/null +++ b/apps/desktop/desktop_native/napi/scripts/build.js @@ -0,0 +1,14 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { execSync } = require('child_process'); + +const args = process.argv.slice(2); +const isRelease = args.includes('--release'); + +if (isRelease) { + console.log('Building release mode.'); +} else { + console.log('Building debug mode.'); + process.env.RUST_LOG = 'debug'; +} + +execSync(`napi build --platform --js false`, { stdio: 'inherit', env: process.env }); diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 327c7c1c8e5..c34e7574f68 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -19,7 +19,8 @@ pub mod passwords { .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. + /// 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, @@ -107,7 +108,8 @@ pub mod biometrics { /// 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. + /// 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! @@ -149,6 +151,123 @@ pub mod biometrics { } } +#[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! @@ -169,7 +288,6 @@ pub mod clipboards { pub mod sshagent { use std::sync::Arc; - use desktop_core::ssh_agent::BitwardenSshKey; use napi::{ bindgen_prelude::Promise, threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction}, @@ -179,7 +297,7 @@ pub mod sshagent { #[napi] pub struct SshAgentState { - state: desktop_core::ssh_agent::BitwardenDesktopAgent, + state: desktop_core::ssh_agent::BitwardenDesktopAgent, } #[napi(object)] @@ -440,8 +558,9 @@ pub mod ipc { impl IpcServer { /// 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. + /// @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( @@ -482,8 +601,9 @@ pub mod ipc { /// 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. + /// @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 @@ -627,8 +747,9 @@ pub mod autofill { impl IpcServer { /// 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. + /// @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( @@ -830,18 +951,18 @@ pub mod logging { //! //! # 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} + //! [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; - use std::sync::OnceLock; + use std::{fmt::Write, sync::OnceLock}; use napi::threadsafe_function::{ ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode, }; use tracing::Level; - use tracing_subscriber::fmt::format::{DefaultVisitor, Writer}; use tracing_subscriber::{ - filter::{EnvFilter, LevelFilter}, + filter::EnvFilter, + fmt::format::{DefaultVisitor, Writer}, layer::SubscriberExt, util::SubscriberInitExt, Layer, @@ -928,13 +1049,25 @@ pub mod logging { pub fn init_napi_log(js_log_fn: ThreadsafeFunction<(LogLevel, String), CalleeHandled>) { 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() - // set the default log level to INFO. - .with_default_directive(LevelFilter::INFO.into()) + .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) @@ -944,8 +1077,15 @@ pub mod logging { #[napi] pub mod chromium_importer { - use bitwarden_chromium_importer::chromium::LoginImportResult as _LoginImportResult; - use bitwarden_chromium_importer::chromium::ProfileInfo as _ProfileInfo; + use std::collections::HashMap; + + use chromium_importer::{ + chromium::{ + DefaultInstalledBrowserRetriever, LoginImportResult as _LoginImportResult, + ProfileInfo as _ProfileInfo, + }, + metadata::NativeImporterMetadata as _NativeImporterMetadata, + }; #[napi(object)] pub struct ProfileInfo { @@ -974,6 +1114,13 @@ pub mod chromium_importer { pub failure: Option, } + #[napi(object)] + pub struct NativeImporterMetadata { + pub id: String, + pub loaders: Vec<&'static str>, + pub instructions: &'static str, + } + impl From<_LoginImportResult> for LoginImportResult { fn from(l: _LoginImportResult) -> Self { match l { @@ -1007,15 +1154,28 @@ pub mod chromium_importer { } } + impl From<_NativeImporterMetadata> for NativeImporterMetadata { + fn from(m: _NativeImporterMetadata) -> Self { + NativeImporterMetadata { + id: m.id, + loaders: m.loaders, + instructions: m.instructions, + } + } + } + #[napi] - pub fn get_installed_browsers() -> napi::Result> { - bitwarden_chromium_importer::chromium::get_installed_browsers() - .map_err(|e| napi::Error::from_reason(e.to_string())) + /// 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> { - bitwarden_chromium_importer::chromium::get_available_profiles(&browser) + 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())) } @@ -1025,7 +1185,7 @@ pub mod chromium_importer { browser: String, profile_id: String, ) -> napi::Result> { - bitwarden_chromium_importer::chromium::import_logins(&browser, &profile_id) + 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/objc/Cargo.toml b/apps/desktop/desktop_native/objc/Cargo.toml index fc8910bddd3..5ef791fb586 100644 --- a/apps/desktop/desktop_native/objc/Cargo.toml +++ b/apps/desktop/desktop_native/objc/Cargo.toml @@ -8,17 +8,13 @@ publish = { workspace = true } [features] default = [] -[dependencies] +[target.'cfg(target_os = "macos")'.dependencies] anyhow = { workspace = true } -thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } -[target.'cfg(target_os = "macos")'.dependencies] -core-foundation = "=0.10.1" - -[build-dependencies] -cc = "=1.2.4" +[target.'cfg(target_os = "macos")'.build-dependencies] +cc = "=1.2.46" glob = "=0.3.2" [lints] diff --git a/apps/desktop/desktop_native/process_isolation/Cargo.toml b/apps/desktop/desktop_native/process_isolation/Cargo.toml new file mode 100644 index 00000000000..d8c6c7a618c --- /dev/null +++ b/apps/desktop/desktop_native/process_isolation/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "process_isolation" +edition = { workspace = true } +license = { workspace = true } +version = { workspace = true } +publish = { workspace = true } + +[lib] +crate-type = ["cdylib"] + +[target.'cfg(target_os = "linux")'.dependencies] +ctor = { workspace = true } +desktop_core = { path = "../core" } +libc = { workspace = true } +tracing = { workspace = true } diff --git a/apps/desktop/desktop_native/process_isolation/src/lib.rs b/apps/desktop/desktop_native/process_isolation/src/lib.rs new file mode 100644 index 00000000000..55c5d7fafae --- /dev/null +++ b/apps/desktop/desktop_native/process_isolation/src/lib.rs @@ -0,0 +1,48 @@ +#![cfg(target_os = "linux")] + +//! This library compiles to a pre-loadable shared object. When preloaded, it +//! immediately isolates the process using the methods available on the platform. +//! On Linux, this is PR_SET_DUMPABLE to prevent debuggers from attaching, the env +//! from being read and the memory from being stolen. + +use std::{ffi::c_char, sync::LazyLock}; + +use desktop_core::process_isolation; +use tracing::info; + +static ORIGINAL_UNSETENV: LazyLock i32> = + LazyLock::new(|| unsafe { + std::mem::transmute(libc::dlsym(libc::RTLD_NEXT, c"unsetenv".as_ptr())) + }); + +/// Hooks unsetenv to fix a bug in zypak-wrapper. +/// Zypak unsets the env in Flatpak as a side-effect, which means that only the top level +/// processes would be hooked. With this work-around all processes in the tree are hooked +#[unsafe(no_mangle)] +unsafe extern "C" fn unsetenv(name: *const c_char) -> i32 { + unsafe { + let Ok(name_str) = std::ffi::CStr::from_ptr(name).to_str() else { + return ORIGINAL_UNSETENV(name); + }; + + if name_str == "LD_PRELOAD" { + // This env variable is provided by the flatpak configuration + let ld_preload = std::env::var("PROCESS_ISOLATION_LD_PRELOAD").unwrap_or_default(); + std::env::set_var("LD_PRELOAD", ld_preload); + return 0; + } + + ORIGINAL_UNSETENV(name) + } +} + +// Hooks the shared object being loaded into the process +#[ctor::ctor] +fn preload_init() { + let pid = unsafe { libc::getpid() }; + info!(pid, "Enabling memory security for process."); + unsafe { + process_isolation::isolate_process(); + process_isolation::disable_coredumps(); + } +} diff --git a/apps/desktop/desktop_native/process_isolation/test_isolation.sh b/apps/desktop/desktop_native/process_isolation/test_isolation.sh new file mode 100644 index 00000000000..91f3b7933df --- /dev/null +++ b/apps/desktop/desktop_native/process_isolation/test_isolation.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# This script tests the memory isolation status of bitwarden-desktop processes. The script will print "isolated" +# if the memory is not accessible by other processes. + +CURRENT_USER=$(whoami) + +# Find processes with "bitwarden" in the command +pids=$(pgrep -f bitwarden) + +if [[ -z "$pids" ]]; then + echo "No bitwarden processes found." + exit 0 +fi + +for pid in $pids; do + # Get process info: command, PPID, RSS memory + read cmd ppid rss <<<$(ps -o comm=,ppid=,rss= -p "$pid") + + # Explicitly skip if the command line does not contain "bitwarden" + if ! grep -q "bitwarden" <<<"$cmd"; then + continue + fi + + # Check ownership of /proc/$pid/environ + owner=$(stat -c "%U" /proc/$pid/environ 2>/dev/null) + + if [[ "$owner" == "root" ]]; then + status="isolated" + elif [[ "$owner" == "$CURRENT_USER" ]]; then + status="insecure" + else + status="unknown-owner:$owner" + fi + + # Convert memory to MB + mem_mb=$((rss / 1024)) + + echo "PID: $pid | CMD: $cmd | Mem: ${mem_mb}MB | Owner: $owner | Status: $status" +done diff --git a/apps/desktop/desktop_native/proxy/Cargo.toml b/apps/desktop/desktop_native/proxy/Cargo.toml index cb1f39d9b42..25682fe2aa3 100644 --- a/apps/desktop/desktop_native/proxy/Cargo.toml +++ b/apps/desktop/desktop_native/proxy/Cargo.toml @@ -6,13 +6,12 @@ version = { workspace = true } publish = { workspace = true } [dependencies] -anyhow = { workspace = true } desktop_core = { path = "../core" } futures = { workspace = true } -log = { workspace = true } -simplelog = { workspace = true } tokio = { workspace = true, features = ["io-std", "io-util", "macros", "rt"] } tokio-util = { workspace = true, features = ["codec"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] embed_plist = { workspace = true } diff --git a/apps/desktop/desktop_native/proxy/src/main.rs b/apps/desktop/desktop_native/proxy/src/main.rs index 792b1bf272d..a2a0b834bca 100644 --- a/apps/desktop/desktop_native/proxy/src/main.rs +++ b/apps/desktop/desktop_native/proxy/src/main.rs @@ -2,39 +2,47 @@ use std::path::Path; use desktop_core::ipc::{MESSAGE_CHANNEL_BUFFER, NATIVE_MESSAGING_BUFFER_SIZE}; use futures::{FutureExt, SinkExt, StreamExt}; -use log::*; use tokio_util::codec::LengthDelimitedCodec; - -#[cfg(target_os = "windows")] -mod windows; +use tracing::{debug, error, info, level_filters::LevelFilter}; +use tracing_subscriber::{ + fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer as _, +}; #[cfg(target_os = "macos")] embed_plist::embed_info_plist!("../../../resources/info.desktop_proxy.plist"); +const ENV_VAR_PROXY_LOG_LEVEL: &str = "PROXY_LOG_LEVEL"; + fn init_logging(log_path: &Path, console_level: LevelFilter, file_level: LevelFilter) { - use simplelog::{ColorChoice, CombinedLogger, Config, SharedLogger, TermLogger, TerminalMode}; + let console_filter = EnvFilter::builder() + .with_default_directive(console_level.into()) + .with_env_var(ENV_VAR_PROXY_LOG_LEVEL) + .from_env_lossy(); - let config = Config::default(); - - let mut loggers: Vec> = Vec::new(); - loggers.push(TermLogger::new( - console_level, - config.clone(), - TerminalMode::Stderr, - ColorChoice::Auto, - )); + let console_layer = fmt::layer() + .with_writer(std::io::stderr) + .with_filter(console_filter); match std::fs::File::create(log_path) { Ok(file) => { - loggers.push(simplelog::WriteLogger::new(file_level, config, file)); - } - Err(e) => { - eprintln!("Can't create file: {e}"); - } - } + let file_filter = EnvFilter::builder() + .with_default_directive(file_level.into()) + .from_env_lossy(); - if let Err(e) = CombinedLogger::init(loggers) { - eprintln!("Failed to initialize logger: {e}"); + let file_layer = fmt::layer() + .with_writer(file) + .with_ansi(false) + .with_filter(file_filter); + + tracing_subscriber::registry() + .with(console_layer) + .with(file_layer) + .init(); + } + Err(error) => { + tracing_subscriber::registry().with(console_layer).init(); + error!(%error, ?log_path, "Could not create log file."); + } } } @@ -49,15 +57,11 @@ fn init_logging(log_path: &Path, console_level: LevelFilter, file_level: LevelFi /// a stable communication channel between the proxy and the running desktop application. /// /// Browser extension <-[native messaging]-> proxy <-[ipc]-> desktop -/// // FIXME: Remove unwraps! They panic and terminate the whole application. #[allow(clippy::unwrap_used)] #[tokio::main(flavor = "current_thread")] async fn main() { - #[cfg(target_os = "windows")] - let should_foreground = windows::allow_foreground(); - - let sock_path = desktop_core::ipc::path("bitwarden"); + let sock_path = desktop_core::ipc::path("bw"); let log_path = { let mut path = sock_path.clone(); @@ -65,20 +69,17 @@ async fn main() { path }; - let level = std::env::var("PROXY_LOG_LEVEL") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(LevelFilter::Info); - - init_logging(&log_path, level, LevelFilter::Info); + init_logging(&log_path, LevelFilter::INFO, LevelFilter::INFO); info!("Starting Bitwarden IPC Proxy."); // Different browsers send different arguments when the app starts: // // Firefox: - // - The complete path to the app manifest. (in the form `/Users//Library/.../Mozilla/NativeMessagingHosts/com.8bit.bitwarden.json`) - // - (in Firefox 55+) the ID (as given in the manifest.json) of the add-on that started it (in the form `{[UUID]}`). + // - The complete path to the app manifest. (in the form + // `/Users//Library/.../Mozilla/NativeMessagingHosts/com.8bit.bitwarden.json`) + // - (in Firefox 55+) the ID (as given in the manifest.json) of the add-on that started it (in + // the form `{[UUID]}`). // // Chrome on Windows: // - Origin of the extension that started it (in the form `chrome-extension://[ID]`). @@ -88,9 +89,10 @@ async fn main() { // - Origin of the extension that started it (in the form `chrome-extension://[ID]`). let args: Vec<_> = std::env::args().skip(1).collect(); - info!("Process args: {:?}", args); + info!(?args, "Process args"); - // Setup two channels, one for sending messages to the desktop application (`out`) and one for receiving messages from the desktop application (`in`) + // Setup two channels, one for sending messages to the desktop application (`out`) and one for + // receiving messages from the desktop application (`in`) let (in_send, in_recv) = tokio::sync::mpsc::channel(MESSAGE_CHANNEL_BUFFER); let (out_send, mut out_recv) = tokio::sync::mpsc::channel(MESSAGE_CHANNEL_BUFFER); @@ -123,12 +125,12 @@ async fn main() { info!("IPC client finished successfully."); std::process::exit(0); } - Ok(Err(e)) => { - error!("IPC client connection error: {}", e); + Ok(Err(error)) => { + error!(error, "IPC client connection error."); std::process::exit(1); } - Err(e) => { - error!("IPC client spawn error: {}", e); + Err(error) => { + error!(%error, "IPC client spawn error."); std::process::exit(1); } } @@ -138,7 +140,7 @@ async fn main() { msg = out_recv.recv() => { match msg { Some(msg) => { - debug!("OUT: {}", msg); + debug!(msg, "OUT"); stdout.send(msg.into()).await.unwrap(); } None => { @@ -150,17 +152,14 @@ async fn main() { // Listen to stdin and send messages to ipc processor. msg = stdin.next() => { - #[cfg(target_os = "windows")] - should_foreground.store(true, std::sync::atomic::Ordering::Relaxed); - match msg { Some(Ok(msg)) => { - let m = String::from_utf8(msg.to_vec()).unwrap(); - debug!("IN: {}", m); - in_send.send(m).await.unwrap(); + let msg = String::from_utf8(msg.to_vec()).unwrap(); + debug!(msg, "IN"); + in_send.send(msg).await.unwrap(); } - Some(Err(e)) => { - error!("Error parsing input: {}", e); + Some(Err(error)) => { + error!(%error, "Error parsing input."); std::process::exit(1); } None => { diff --git a/apps/desktop/desktop_native/proxy/src/windows.rs b/apps/desktop/desktop_native/proxy/src/windows.rs deleted file mode 100644 index cb0656fc7f8..00000000000 --- a/apps/desktop/desktop_native/proxy/src/windows.rs +++ /dev/null @@ -1,23 +0,0 @@ -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, -}; - -pub fn allow_foreground() -> Arc { - let should_foreground = Arc::new(AtomicBool::new(false)); - let should_foreground_clone = should_foreground.clone(); - let _ = std::thread::spawn(move || loop { - if !should_foreground_clone.load(Ordering::Relaxed) { - std::thread::sleep(std::time::Duration::from_millis(100)); - continue; - } - should_foreground_clone.store(false, Ordering::Relaxed); - - for _ in 0..60 { - desktop_core::biometric::windows_focus::focus_security_prompt(); - std::thread::sleep(std::time::Duration::from_millis(1000)); - } - }); - - should_foreground -} diff --git a/apps/desktop/desktop_native/rustfmt.toml b/apps/desktop/desktop_native/rustfmt.toml new file mode 100644 index 00000000000..bb3baeccd76 --- /dev/null +++ b/apps/desktop/desktop_native/rustfmt.toml @@ -0,0 +1,7 @@ +# Wrap comments and increase the width of comments to 100 +comment_width = 100 +wrap_comments = true + +# Sort and group imports +group_imports = "StdExternalCrate" +imports_granularity = "Crate" diff --git a/apps/desktop/desktop_native/windows_plugin_authenticator/src/lib.rs b/apps/desktop/desktop_native/windows_plugin_authenticator/src/lib.rs index 2e4f453d8f0..893fdf765fc 100644 --- a/apps/desktop/desktop_native/windows_plugin_authenticator/src/lib.rs +++ b/apps/desktop/desktop_native/windows_plugin_authenticator/src/lib.rs @@ -2,11 +2,12 @@ #![allow(non_snake_case)] #![allow(non_camel_case_types)] -use std::ffi::c_uchar; -use std::ptr; -use windows::Win32::Foundation::*; -use windows::Win32::System::Com::*; -use windows::Win32::System::LibraryLoader::*; +use std::{ffi::c_uchar, ptr}; + +use windows::Win32::{ + Foundation::*, + System::{Com::*, LibraryLoader::*}, +}; use windows_core::*; mod pluginauthenticator; diff --git a/apps/desktop/electron-builder.beta.json b/apps/desktop/electron-builder.beta.json new file mode 100644 index 00000000000..630a956560d --- /dev/null +++ b/apps/desktop/electron-builder.beta.json @@ -0,0 +1,136 @@ +{ + "extraMetadata": { + "name": "bitwarden-beta" + }, + "productName": "Bitwarden Beta", + "appId": "com.bitwarden.desktop.beta", + "buildDependenciesFromSource": true, + "copyright": "Copyright © 2015-2025 Bitwarden Inc.", + "directories": { + "buildResources": "resources", + "output": "dist", + "app": "build" + }, + "afterSign": "scripts/after-sign.js", + "afterPack": "scripts/after-pack.js", + "asarUnpack": ["**/*.node"], + "files": [ + "**/*", + "!**/node_modules/@bitwarden/desktop-napi/**/*", + "**/node_modules/@bitwarden/desktop-napi/index.js", + "**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node" + ], + "electronVersion": "36.8.1", + "generateUpdatesFilesForAllChannels": true, + "publish": { + "provider": "generic", + "url": "https://artifacts.bitwarden.com/desktop" + }, + "win": { + "electronUpdaterCompatibility": ">=0.0.1", + "target": ["portable", "nsis-web", "appx"], + "signtoolOptions": { + "sign": "./sign.js" + }, + "extraFiles": [ + { + "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe", + "to": "desktop_proxy.exe" + }, + { + "from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe", + "to": "bitwarden_chromium_import_helper.exe" + } + ] + }, + "nsisWeb": { + "oneClick": false, + "perMachine": false, + "allowToChangeInstallationDirectory": false, + "artifactName": "Bitwarden-Beta-Installer-${version}.${ext}", + "uninstallDisplayName": "${productName}", + "deleteAppDataOnUninstall": true, + "include": "installer.nsh" + }, + "portable": { + "artifactName": "Bitwarden-Beta-Portable-${version}.${ext}" + }, + "appx": { + "artifactName": "Bitwarden-Beta-${version}-${arch}.${ext}", + "backgroundColor": "#175DDC", + "applicationId": "BitwardenBeta", + "identityName": "8bitSolutionsLLC.BitwardenBeta", + "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", + "publisherDisplayName": "Bitwarden Inc", + "languages": [ + "en-US", + "af", + "ar", + "az-latn", + "be", + "bg", + "bn", + "bs", + "ca", + "cs", + "cy", + "da", + "de", + "el", + "en-gb", + "en-in", + "es", + "et", + "eu", + "fa", + "fi", + "fil", + "fr", + "gl", + "he", + "hi", + "hr", + "hu", + "id", + "it", + "ja", + "ka", + "km", + "kn", + "ko", + "lt", + "lv", + "ml", + "mr", + "nb", + "ne", + "nl", + "nn", + "or", + "pl", + "pt-br", + "pt-pt", + "ro", + "ru", + "si", + "sk", + "sl", + "sr-cyrl", + "sv", + "ta", + "te", + "th", + "tr", + "uk", + "vi", + "zh-cn", + "zh-tw" + ] + }, + "protocols": [ + { + "name": "Bitwarden", + "schemes": ["bitwarden"] + } + ] +} diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index 4d0dac1242a..6e89799e9c4 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -20,7 +20,7 @@ "**/node_modules/@bitwarden/desktop-napi/index.js", "**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node" ], - "electronVersion": "36.8.1", + "electronVersion": "37.7.0", "generateUpdatesFilesForAllChannels": true, "publish": { "provider": "generic", @@ -96,6 +96,10 @@ { "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe", "to": "desktop_proxy.exe" + }, + { + "from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe", + "to": "bitwarden_chromium_import_helper.exe" } ] }, @@ -106,9 +110,13 @@ { "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}", "to": "desktop_proxy" + }, + { + "from": "desktop_native/dist/libprocess_isolation.so", + "to": "libprocess_isolation.so" } ], - "target": ["deb", "freebsd", "rpm", "AppImage", "snap"], + "target": ["deb", "rpm", "AppImage", "snap"], "desktop": { "entry": { "Name": "Bitwarden", @@ -244,9 +252,6 @@ "artifactName": "${productName}-${version}-${arch}.${ext}", "fpm": ["--rpm-rpmbuild-define", "_build_id_links none"] }, - "freebsd": { - "artifactName": "${productName}-${version}-${arch}.${ext}" - }, "snap": { "summary": "Bitwarden is a secure and free password manager for all of your devices.", "description": "Password Manager\n**Installation**\nBitwarden requires access to the `password-manager-service`. Please enable it through permissions or by running `sudo snap connect bitwarden:password-manager-service` after installation. See https://btwrdn.com/install-snap for details.", diff --git a/apps/desktop/fastlane/fastfile b/apps/desktop/fastlane/fastfile index 08c35dfa7b3..134d18563de 100644 --- a/apps/desktop/fastlane/fastfile +++ b/apps/desktop/fastlane/fastfile @@ -21,11 +21,13 @@ platform :mac do .split('.') .map(&:strip) .reject(&:empty?) - .map { |item| "• #{item}" } + .map { |item| "• #{item.gsub(/\A(?:•|\u2022)\s*/, '')}" } .join("\n") - UI.message("Original changelog: #{changelog[0,100]}#{changelog.length > 100 ? '...' : ''}") - UI.message("Formatted changelog: #{formatted_changelog[0,100]}#{formatted_changelog.length > 100 ? '...' : ''}") + UI.message("Original changelog: ") + UI.message("#{changelog}") + UI.message("Formatted changelog: ") + UI.message("#{formatted_changelog}") # Create release notes directories and files for all locales APP_CONFIG[:locales].each do |locale| diff --git a/apps/desktop/installer.nsh b/apps/desktop/installer.nsh index f8939423c8d..3fe8a69b089 100644 --- a/apps/desktop/installer.nsh +++ b/apps/desktop/installer.nsh @@ -7,3 +7,14 @@ ${endif} ${endif} !macroend + +# When the user is uninstalling the app, remove the auto-start registry entries +!macro customUnInstall + ${ifNot} ${isUpdated} + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "electron.app.${PRODUCT_NAME}" + DeleteRegValue HKLM "Software\Microsoft\Windows\CurrentVersion\Run" "electron.app.${PRODUCT_NAME}" + + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" "electron.app.${PRODUCT_NAME}" + DeleteRegValue HKLM "Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" "electron.app.${PRODUCT_NAME}" + ${endIf} +!macroend diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json index 718bf7efb39..9ad1ffb3ec0 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": "11.1.0", + "uuid": "13.0.0", "yargs": "18.0.0" }, "devDependencies": { - "@types/node": "22.15.3", + "@types/node": "22.19.1", "typescript": "5.4.2" } }, @@ -117,10 +117,11 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", - "integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==", + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -336,6 +337,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -351,16 +353,16 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json index 35a110c3958..21a6ba3626a 100644 --- a/apps/desktop/native-messaging-test-runner/package.json +++ b/apps/desktop/native-messaging-test-runner/package.json @@ -20,11 +20,11 @@ "@bitwarden/logging": "dist/libs/logging/src", "module-alias": "2.2.3", "ts-node": "10.9.2", - "uuid": "11.1.0", + "uuid": "13.0.0", "yargs": "18.0.0" }, "devDependencies": { - "@types/node": "22.15.3", + "@types/node": "22.19.1", "typescript": "5.4.2" }, "_moduleAliases": { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fc3e7d0cad3..bb8118cb7eb 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.9.1", + "version": "2025.12.0", "keywords": [ "bitwarden", "password", @@ -21,25 +21,26 @@ "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.preload.js", - "build:preload:dev": "cross-env NODE_ENV=development webpack --config webpack.preload.js", - "build:preload:watch": "cross-env NODE_ENV=development webpack --config webpack.preload.js --watch", + "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:main": "cross-env NODE_ENV=production webpack --config webpack.main.js", - "build:main:dev": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.main.js", - "build:main:watch": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.main.js --watch", - "build:renderer": "cross-env NODE_ENV=production webpack --config webpack.renderer.js", - "build:renderer:dev": "cross-env NODE_ENV=development webpack --config webpack.renderer.js", - "build:renderer:watch": "cross-env NODE_ENV=development webpack --config webpack.renderer.js --watch", + "build:main": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main", + "build:main:dev": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main", + "build:main:watch": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main --watch", + "build:renderer": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name renderer", + "build:renderer:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer", + "build:renderer:watch": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer --watch", "electron": "node ./scripts/start.js", "electron:ignore": "node ./scripts/start.js --ignore-certificate-errors", + "flatpak:dev": "npm run clean:dist && electron-builder --dir -p never && flatpak-builder --force-clean --install --user ../../.flatpak/ ./resources/com.bitwarden.desktop.devel.yaml && flatpak run com.bitwarden.desktop", "clean:dist": "rimraf ./dist", "pack:dir": "npm run clean:dist && electron-builder --dir -p never", - "pack:lin:flatpak": "npm run clean:dist && electron-builder --dir -p never && flatpak-builder --repo=build/.repo build/.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ./build/.repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop", - "pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/", - "pack:lin:arm64": "npm run clean:dist && electron-builder --dir -p never && tar -czvf ./dist/bitwarden_desktop_arm64.tar.gz -C ./dist/linux-arm64-unpacked/ .", + "pack:lin:flatpak": "flatpak-builder --repo=../../.flatpak-repo ../../.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ../../.flatpak-repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop", + "pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/ && tar -czvf ./dist/bitwarden_desktop_x64.tar.gz -C ./dist/linux-unpacked/ .", + "pack:lin:arm64": "npm run clean:dist && electron-builder --linux --arm64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/ && tar -czvf ./dist/bitwarden_desktop_arm64.tar.gz -C ./dist/linux-arm64-unpacked/ .", "pack:mac": "npm run clean:dist && electron-builder --mac --universal -p never", "pack:mac:with-extension": "npm run clean:dist && npm run build:macos-extension:mac && electron-builder --mac --universal -p never", "pack:mac:arm64": "npm run clean:dist && electron-builder --mac --arm64 -p never", @@ -48,6 +49,7 @@ "pack:mac:masdev": "npm run clean:dist && electron-builder --mac mas-dev --universal -p never", "pack:mac:masdev:with-extension": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never", "pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", + "pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", "pack:win:ci": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never", "dist:dir": "npm run build && npm run pack:dir", "dist:lin": "npm run build && npm run pack:lin", diff --git a/apps/desktop/project.json b/apps/desktop/project.json new file mode 100644 index 00000000000..98f33864046 --- /dev/null +++ b/apps/desktop/project.json @@ -0,0 +1,115 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "desktop", + "projectType": "application", + "sourceRoot": "apps/desktop/src", + "tags": ["scope:desktop", "type:app"], + "targets": { + "build-native": { + "executor": "nx:run-commands", + "options": { + "command": "cd desktop_native && node build.js", + "cwd": "apps/desktop" + } + }, + "build-main": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/dist/apps/desktop"], + "options": { + "command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main", + "cwd": "apps/desktop" + }, + "configurations": { + "development": { + "command": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main" + }, + "production": { + "command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main" + } + } + }, + "build-preload": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/dist/apps/desktop"], + "options": { + "command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name preload", + "cwd": "apps/desktop" + }, + "configurations": { + "development": { + "command": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name preload" + }, + "production": { + "command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name preload" + } + } + }, + "build-renderer": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/dist/apps/desktop"], + "options": { + "command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name renderer", + "cwd": "apps/desktop" + }, + "configurations": { + "development": { + "command": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer" + }, + "production": { + "command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name renderer" + } + } + }, + "build": { + "executor": "nx:run-commands", + "dependsOn": ["build-native"], + "outputs": ["{workspaceRoot}/dist/apps/desktop"], + "options": { + "parallel": true, + "commands": [ + "nx run desktop:build-main", + "nx run desktop:build-preload", + "nx run desktop:build-renderer" + ] + }, + "configurations": { + "development": { + "commands": [ + "nx run desktop:build-main --configuration=development", + "nx run desktop:build-preload --configuration=development", + "nx run desktop:build-renderer --configuration=development" + ] + }, + "production": { + "commands": [ + "nx run desktop:build-main --configuration=production", + "nx run desktop:build-preload --configuration=production", + "nx run desktop:build-renderer --configuration=production" + ] + } + } + }, + "serve": { + "executor": "nx:run-commands", + "dependsOn": ["build-native"], + "options": { + "command": "node scripts/nx-serve.js", + "cwd": "apps/desktop" + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/apps/desktop"], + "options": { + "jestConfig": "apps/desktop/jest.config.js" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/desktop/**/*.ts", "apps/desktop/**/*.html"] + } + } + } +} diff --git a/apps/desktop/resources/com.bitwarden.desktop.devel.yaml b/apps/desktop/resources/com.bitwarden.desktop.devel.yaml index 858fb6e1af2..e72df98e22b 100644 --- a/apps/desktop/resources/com.bitwarden.desktop.devel.yaml +++ b/apps/desktop/resources/com.bitwarden.desktop.devel.yaml @@ -46,4 +46,6 @@ modules: commands: - ulimit -c 0 - export TMPDIR="$XDG_RUNTIME_DIR/app/$FLATPAK_ID" + - export ZYPAK_LD_PRELOAD="/app/bin/libprocess_isolation.so" + - export PROCESS_ISOLATION_LD_PRELOAD="/app/bin/libprocess_isolation.so" - exec zypak-wrapper /app/bin/bitwarden-app "$@" diff --git a/apps/desktop/resources/linux-wrapper.sh b/apps/desktop/resources/linux-wrapper.sh index dd53eb9811c..3c5d16c3a3d 100644 --- a/apps/desktop/resources/linux-wrapper.sh +++ b/apps/desktop/resources/linux-wrapper.sh @@ -7,9 +7,8 @@ ulimit -c 0 RAW_PATH=$(readlink -f "$0") APP_PATH=$(dirname $RAW_PATH) -# force use of base image libdus in snap -if [ -e "/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" ] -then +# force use of base image libdbus in snap +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 diff --git a/apps/desktop/scripts/nx-serve.js b/apps/desktop/scripts/nx-serve.js new file mode 100644 index 00000000000..b92a045f8e8 --- /dev/null +++ b/apps/desktop/scripts/nx-serve.js @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +const path = require("path"); + +const concurrently = require("concurrently"); +const rimraf = require("rimraf"); +const args = process.argv.splice(2); +const outputPath = path.resolve(__dirname, "../../../dist/apps/desktop"); + +rimraf.sync(outputPath); +require("fs").mkdirSync(outputPath, { recursive: true }); + +concurrently( + [ + { + name: "Main", + command: `cross-env NODE_ENV=development OUTPUT_PATH=${outputPath} webpack --config webpack.config.js --config-name main --watch`, + prefixColor: "yellow", + }, + { + name: "Prel", + command: `cross-env NODE_ENV=development OUTPUT_PATH=${outputPath} webpack --config webpack.config.js --config-name preload --watch`, + prefixColor: "magenta", + }, + { + name: "Rend", + command: `cross-env NODE_ENV=development OUTPUT_PATH=${outputPath} webpack --config webpack.config.js --config-name renderer --watch`, + prefixColor: "cyan", + }, + { + name: "Elec", + command: `npx wait-on ${outputPath}/main.js ${outputPath}/index.html && npx electron --no-sandbox --inspect=5858 ${args.join( + " ", + )} ${outputPath} --watch`, + prefixColor: "green", + }, + ], + { + prefix: "name", + outputStream: process.stdout, + killOthers: ["success", "failure"], + }, +); diff --git a/apps/desktop/sign.js b/apps/desktop/sign.js index f0110ea195b..6a42666c46f 100644 --- a/apps/desktop/sign.js +++ b/apps/desktop/sign.js @@ -13,7 +13,7 @@ exports.default = async function (configuration) { `-fd ${configuration.hash} ` + `-du ${configuration.site} ` + `-tr http://timestamp.digicert.com ` + - `${configuration.path}`, + `"${configuration.path}"`, { stdio: "inherit", }, diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index a0380a8b5ce..8abd84ee39c 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -31,36 +31,50 @@ - -

    {{ "vaultTimeoutHeader" | i18n }}

    -
    + @if (consolidatedSessionTimeoutComponent$ | async) { + +

    {{ "sessionTimeoutHeader" | i18n }}

    +
    - - + + } @else { + +

    {{ "vaultTimeoutHeader" | i18n }}

    +
    - - {{ "vaultTimeoutAction1" | i18n }} - - + + + + {{ + "vaultTimeoutAction1" | i18n + }} + + + + + + - - + {{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
    + +
    - - {{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
    + + {{ "vaultTimeoutPolicyAffectingOptions" | i18n }} - - - - {{ "vaultTimeoutPolicyAffectingOptions" | i18n }} - + }
    @@ -81,6 +95,30 @@ "additionalTouchIdSettings" | i18n }}
    +
    +
    + +
    +
    {{ "important" | i18n }} - {{ "enableAutotypeDescriptionTransitionKey" | i18n }} - {{ "editShortcut" | i18n }} -
    diff --git a/apps/desktop/src/app/accounts/settings.component.spec.ts b/apps/desktop/src/app/accounts/settings.component.spec.ts index a791fd7b9a4..a424f230778 100644 --- a/apps/desktop/src/app/accounts/settings.component.spec.ts +++ b/apps/desktop/src/app/accounts/settings.component.spec.ts @@ -30,6 +30,7 @@ import { ValidationService } from "@bitwarden/common/platform/abstractions/valid import { ThemeType } from "@bitwarden/common/platform/enums"; import { MessageSender } from "@bitwarden/common/platform/messaging"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; @@ -73,6 +74,9 @@ describe("SettingsComponent", () => { const desktopAutotypeService = mock(); const billingAccountProfileStateService = mock(); const configService = mock(); + const userVerificationService = mock(); + + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)); beforeEach(async () => { jest.clearAllMocks(); @@ -92,6 +96,7 @@ describe("SettingsComponent", () => { }; i18nService.supportedTranslationLocales = []; + i18nService.t.mockImplementation((key: string) => key); await TestBed.configureTestingModule({ imports: [], @@ -124,7 +129,7 @@ describe("SettingsComponent", () => { { provide: PolicyService, useValue: policyService }, { provide: StateService, useValue: mock() }, { provide: ThemeStateService, useValue: themeStateService }, - { provide: UserVerificationService, useValue: mock() }, + { provide: UserVerificationService, useValue: userVerificationService }, { provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService }, { provide: ValidationService, useValue: validationService }, { provide: MessagingService, useValue: messagingService }, @@ -153,6 +158,7 @@ describe("SettingsComponent", () => { component = fixture.componentInstance; fixture.detectChanges(); + desktopBiometricsService.hasPersistentKey.mockResolvedValue(false); vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue( of(VaultTimeoutStringType.OnLocked), ); @@ -185,7 +191,7 @@ describe("SettingsComponent", () => { desktopAutotypeService.autotypeEnabledUserSetting$ = of(false); desktopAutotypeService.autotypeKeyboardShortcut$ = of(["Control", "Shift", "B"]); billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); - configService.getFeatureFlag$.mockReturnValue(of(true)); + configService.getFeatureFlag$.mockReturnValue(of(false)); }); afterEach(() => { @@ -302,37 +308,74 @@ describe("SettingsComponent", () => { component = fixture.componentInstance; }); - it("require password or pin on app start not visible when RemoveUnlockWithPin policy is disabled and pin set and windows desktop", async () => { - const policy = new Policy(); - policy.type = PolicyType.RemoveUnlockWithPin; - policy.enabled = false; - policyService.policiesByType$.mockReturnValue(of([policy])); - pinServiceAbstraction.isPinSet.mockResolvedValue(true); + test.each([true, false])( + `correct message display for require MP/PIN on app restart when pin is set, windows desktop, and policy is %s`, + async (policyEnabled) => { + const policy = new Policy(); + policy.type = PolicyType.RemoveUnlockWithPin; + policy.enabled = policyEnabled; + policyService.policiesByType$.mockReturnValue(of([policy])); + platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop); + pinServiceAbstraction.isPinSet.mockResolvedValue(true); - await component.ngOnInit(); - fixture.detectChanges(); + await component.ngOnInit(); + fixture.detectChanges(); - const requirePasswordOnStartLabelElement = fixture.debugElement.query( - By.css("label[for='requirePasswordOnStart']"), - ); - expect(requirePasswordOnStartLabelElement).toBeNull(); + const textNodes = checkRequireMasterPasswordOnAppRestartElement(fixture); + + if (policyEnabled) { + expect(textNodes).toContain("requireMasterPasswordOnAppRestart"); + } else { + expect(textNodes).toContain("requireMasterPasswordOrPinOnAppRestart"); + } + }, + ); + + describe("users without a master password", () => { + beforeEach(() => { + userVerificationService.hasMasterPassword.mockResolvedValue(false); + }); + + it("displays require MP/PIN on app restart checkbox when pin is set", async () => { + pinServiceAbstraction.isPinSet.mockResolvedValue(true); + + await component.ngOnInit(); + fixture.detectChanges(); + + checkRequireMasterPasswordOnAppRestartElement(fixture); + }); + + it("does not display require MP/PIN on app restart checkbox when pin is not set", async () => { + pinServiceAbstraction.isPinSet.mockResolvedValue(false); + + await component.ngOnInit(); + fixture.detectChanges(); + + const requireMasterPasswordOnAppRestartLabelElement = fixture.debugElement.query( + By.css("label[for='requireMasterPasswordOnAppRestart']"), + ); + expect(requireMasterPasswordOnAppRestartLabelElement).toBeNull(); + }); }); - it("require password on app start not visible when RemoveUnlockWithPin policy is enabled and pin set and windows desktop", async () => { - const policy = new Policy(); - policy.type = PolicyType.RemoveUnlockWithPin; - policy.enabled = true; - policyService.policiesByType$.mockReturnValue(of([policy])); - pinServiceAbstraction.isPinSet.mockResolvedValue(true); - - await component.ngOnInit(); - fixture.detectChanges(); - - const requirePasswordOnStartLabelElement = fixture.debugElement.query( - By.css("label[for='requirePasswordOnStart']"), + function checkRequireMasterPasswordOnAppRestartElement( + fixture: ComponentFixture, + ) { + const requireMasterPasswordOnAppRestartLabelElement = fixture.debugElement.query( + By.css("label[for='requireMasterPasswordOnAppRestart']"), ); - expect(requirePasswordOnStartLabelElement).toBeNull(); - }); + expect(requireMasterPasswordOnAppRestartLabelElement).not.toBeNull(); + expect(requireMasterPasswordOnAppRestartLabelElement.children).toHaveLength(1); + expect(requireMasterPasswordOnAppRestartLabelElement.children[0].name).toBe("input"); + expect(requireMasterPasswordOnAppRestartLabelElement.children[0].attributes).toMatchObject({ + id: "requireMasterPasswordOnAppRestart", + type: "checkbox", + }); + const textNodes = requireMasterPasswordOnAppRestartLabelElement.childNodes + .filter((node) => node.nativeNode.nodeType === Node.TEXT_NODE) + .map((node) => node.nativeNode.wholeText?.trim()); + return textNodes; + } }); }); @@ -362,7 +405,7 @@ describe("SettingsComponent", () => { await component.updatePinHandler(true); expect(component.form.controls.pin.value).toBe(false); - expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled(); + expect(pinServiceAbstraction.unsetPin).not.toHaveBeenCalled(); expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); }); @@ -378,7 +421,7 @@ describe("SettingsComponent", () => { await component.updatePinHandler(true); expect(component.form.controls.pin.value).toBe(dialogResult); - expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled(); + expect(pinServiceAbstraction.unsetPin).not.toHaveBeenCalled(); expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); }, ); @@ -390,9 +433,145 @@ describe("SettingsComponent", () => { await component.updatePinHandler(false); expect(component.form.controls.pin.value).toBe(false); - expect(vaultTimeoutSettingsService.clear).toHaveBeenCalled(); + expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled(); expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); }); + + describe("when windows biometric v2 feature flag is enabled", () => { + beforeEach(() => { + keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey)); + }); + + test.each([false, true])( + "enrolls persistent biometric if needed, enrolled is %s", + async (enrolled) => { + desktopBiometricsService.hasPersistentKey.mockResolvedValue(enrolled); + + await component.ngOnInit(); + component.isWindows = true; + component.form.value.requireMasterPasswordOnAppRestart = true; + component.userHasMasterPassword = false; + component.supportsBiometric = true; + component.form.value.biometric = true; + + await component.updatePinHandler(false); + + expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false); + expect(component.form.controls.pin.value).toBe(false); + expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled(); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + + if (enrolled) { + expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled(); + } else { + expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith( + mockUserId, + mockUserKey, + ); + } + }, + ); + + test.each([ + { + userHasMasterPassword: true, + supportsBiometric: false, + biometric: false, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: true, + supportsBiometric: false, + biometric: false, + requireMasterPasswordOnAppRestart: true, + }, + { + userHasMasterPassword: true, + supportsBiometric: false, + biometric: true, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: true, + supportsBiometric: false, + biometric: true, + requireMasterPasswordOnAppRestart: true, + }, + { + userHasMasterPassword: true, + supportsBiometric: true, + biometric: false, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: true, + supportsBiometric: true, + biometric: false, + requireMasterPasswordOnAppRestart: true, + }, + { + userHasMasterPassword: false, + supportsBiometric: false, + biometric: false, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: false, + supportsBiometric: false, + biometric: false, + requireMasterPasswordOnAppRestart: true, + }, + { + userHasMasterPassword: false, + supportsBiometric: false, + biometric: true, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: false, + supportsBiometric: false, + biometric: true, + requireMasterPasswordOnAppRestart: true, + }, + { + userHasMasterPassword: false, + supportsBiometric: true, + biometric: false, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: false, + supportsBiometric: true, + biometric: false, + requireMasterPasswordOnAppRestart: true, + }, + ])( + "does not enroll persistent biometric when conditions are not met: userHasMasterPassword=$userHasMasterPassword, supportsBiometric=$supportsBiometric, biometric=$biometric, requireMasterPasswordOnAppRestart=$requireMasterPasswordOnAppRestart", + async ({ + userHasMasterPassword, + supportsBiometric, + biometric, + requireMasterPasswordOnAppRestart, + }) => { + desktopBiometricsService.hasPersistentKey.mockResolvedValue(false); + + await component.ngOnInit(); + component.isWindows = true; + component.form.value.requireMasterPasswordOnAppRestart = + requireMasterPasswordOnAppRestart; + component.userHasMasterPassword = userHasMasterPassword; + component.supportsBiometric = supportsBiometric; + component.form.value.biometric = biometric; + + await component.updatePinHandler(false); + + expect(component.form.controls.pin.value).toBe(false); + expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled(); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled(); + }, + ); + }); }); }); @@ -474,22 +653,91 @@ describe("SettingsComponent", () => { expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); }); - it("handles windows case", async () => { - desktopBiometricsService.getBiometricsStatus.mockResolvedValue(BiometricsStatus.Available); - desktopBiometricsService.getBiometricsStatusForUser.mockResolvedValue( - BiometricsStatus.Available, - ); + describe("windows test cases", () => { + beforeEach(() => { + platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop); + keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey)); + component.isWindows = true; + component.isLinux = false; - component.isWindows = true; - component.isLinux = false; - await component.updateBiometricHandler(true); + desktopBiometricsService.getBiometricsStatus.mockResolvedValue( + BiometricsStatus.Available, + ); + desktopBiometricsService.getBiometricsStatusForUser.mockResolvedValue( + BiometricsStatus.Available, + ); + }); - expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); - expect(component.form.controls.autoPromptBiometrics.value).toBe(false); - expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false); - expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId); - expect(component.form.controls.biometric.value).toBe(true); - expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + it("handles windows case", async () => { + await component.updateBiometricHandler(true); + + expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); + expect(component.form.controls.autoPromptBiometrics.value).toBe(false); + expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false); + expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId); + expect(component.form.controls.biometric.value).toBe(true); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + }); + + describe("when windows v2 biometrics is enabled", () => { + beforeEach(() => { + keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey)); + }); + + it("when the user doesn't have a master password or a PIN set, allows biometric unlock on app restart", async () => { + component.userHasMasterPassword = false; + component.userHasPinSet = false; + desktopBiometricsService.hasPersistentKey.mockResolvedValue(false); + + await component.updateBiometricHandler(true); + + expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId); + expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith( + mockUserId, + mockUserKey, + ); + expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false); + + expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); + expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); + expect(component.form.controls.autoPromptBiometrics.value).toBe(false); + expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false); + expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId); + expect(component.form.controls.biometric.value).toBe(true); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + }); + + test.each([ + [true, true], + [true, false], + [false, true], + ])( + "when the userHasMasterPassword is %s and userHasPinSet is %s, require master password/PIN on app restart is the default setting", + async (userHasMasterPassword, userHasPinSet) => { + component.userHasMasterPassword = userHasMasterPassword; + component.userHasPinSet = userHasPinSet; + + await component.updateBiometricHandler(true); + + expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled(); + expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(true); + expect(desktopBiometricsService.deleteBiometricUnlockKeyForUser).toHaveBeenCalledWith( + mockUserId, + ); + expect( + desktopBiometricsService.setBiometricProtectedUnlockKeyForUser, + ).toHaveBeenCalledWith(mockUserId, mockUserKey); + + expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); + expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); + expect(component.form.controls.autoPromptBiometrics.value).toBe(false); + expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false); + expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId); + expect(component.form.controls.biometric.value).toBe(true); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + }, + ); + }); }); it("handles linux case", async () => { @@ -553,6 +801,57 @@ describe("SettingsComponent", () => { }); }); + describe("updateRequireMasterPasswordOnAppRestartHandler", () => { + beforeEach(() => { + jest.clearAllMocks(); + + keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey)); + }); + + test.each([true, false])(`handles thrown errors when updated to %s`, async (update) => { + const error = new Error("Test error"); + jest.spyOn(component, "updateRequireMasterPasswordOnAppRestart").mockRejectedValue(error); + + await component.ngOnInit(); + await component.updateRequireMasterPasswordOnAppRestartHandler(update, mockUserId); + + expect(logService.error).toHaveBeenCalled(); + expect(validationService.showError).toHaveBeenCalledWith(error); + }); + + describe("when updating to true", () => { + it("calls the biometrics service to clear and reset biometric key", async () => { + await component.ngOnInit(); + await component.updateRequireMasterPasswordOnAppRestartHandler(true, mockUserId); + + expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId); + expect(desktopBiometricsService.deleteBiometricUnlockKeyForUser).toHaveBeenCalledWith( + mockUserId, + ); + expect(desktopBiometricsService.setBiometricProtectedUnlockKeyForUser).toHaveBeenCalledWith( + mockUserId, + mockUserKey, + ); + }); + }); + + describe("when updating to false", () => { + it("doesn't enroll persistent biometric if already enrolled", async () => { + biometricStateService.hasPersistentKey.mockResolvedValue(false); + + await component.ngOnInit(); + await component.updateRequireMasterPasswordOnAppRestartHandler(false, mockUserId); + + expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId); + expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith( + mockUserId, + mockUserKey, + ); + expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false); + }); + }); + }); + describe("saveVaultTimeout", () => { const DEFAULT_VAULT_TIMEOUT: VaultTimeout = 123; const DEFAULT_VAULT_TIMEOUT_ACTION = VaultTimeoutAction.Lock; @@ -629,7 +928,6 @@ describe("SettingsComponent", () => { }); it("should not save vault timeout when vault timeout is invalid", async () => { - i18nService.t.mockReturnValue("Number too large test error"); component["form"].controls.vaultTimeout.setErrors({}, { emitEvent: false }); await component.saveVaultTimeout(DEFAULT_VAULT_TIMEOUT, 999_999_999); @@ -639,11 +937,6 @@ describe("SettingsComponent", () => { DEFAULT_VAULT_TIMEOUT_ACTION, ); expect(component["form"].getRawValue().vaultTimeout).toEqual(DEFAULT_VAULT_TIMEOUT); - expect(platformUtilsService.showToast).toHaveBeenCalledWith( - "error", - null, - "Number too large test error", - ); }); }); diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 030027913bc..68863312ffe 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -9,7 +9,6 @@ import { concatMap, map, pairwise, startWith, switchMap, takeUntil, timeout } fr import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { VaultTimeoutInputComponent } from "@bitwarden/auth/angular"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; @@ -55,6 +54,10 @@ import { TypographyModule, } from "@bitwarden/components"; import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management"; +import { + SessionTimeoutInputComponent, + SessionTimeoutSettingsComponent, +} from "@bitwarden/key-management-ui"; import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault"; import { SetPinComponent } from "../../auth/components/set-pin.component"; @@ -67,6 +70,8 @@ import { DesktopSettingsService } from "../../platform/services/desktop-settings import { DesktopPremiumUpgradePromptService } from "../../services/desktop-premium-upgrade-prompt.service"; import { NativeMessagingManifestService } from "../services/native-messaging-manifest.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: "app-settings", templateUrl: "settings.component.html", @@ -92,7 +97,8 @@ import { NativeMessagingManifestService } from "../services/native-messaging-man SectionHeaderComponent, SelectModule, TypographyModule, - VaultTimeoutInputComponent, + SessionTimeoutInputComponent, + SessionTimeoutSettingsComponent, PermitCipherDetailsPopoverComponent, PremiumBadgeComponent, ], @@ -143,12 +149,15 @@ export class SettingsComponent implements OnInit, OnDestroy { pinEnabled$: Observable = of(true); + consolidatedSessionTimeoutComponent$: Observable; + form = this.formBuilder.group({ // Security vaultTimeout: [null as VaultTimeout | null], vaultTimeoutAction: [VaultTimeoutAction.Lock], pin: [null as boolean | null], biometric: false, + requireMasterPasswordOnAppRestart: true, autoPromptBiometrics: false, // Account Preferences clearClipboard: [null], @@ -180,7 +189,7 @@ export class SettingsComponent implements OnInit, OnDestroy { locale: [null as string | null], }); - private refreshTimeoutSettings$ = new BehaviorSubject(undefined); + protected refreshTimeoutSettings$ = new BehaviorSubject(undefined); private destroy$ = new Subject(); constructor( @@ -278,10 +287,15 @@ export class SettingsComponent implements OnInit, OnDestroy { value: SshAgentPromptType.RememberUntilLock, }, ]; + + this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + ); } async ngOnInit() { this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions(); + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); // Autotype is for Windows initially @@ -372,6 +386,9 @@ export class SettingsComponent implements OnInit, OnDestroy { ), pin: this.userHasPinSet, biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(), + requireMasterPasswordOnAppRestart: !(await this.biometricsService.hasPersistentKey( + activeAccount.id, + )), autoPromptBiometrics: await firstValueFrom(this.biometricStateService.promptAutomatically$), clearClipboard: await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$), minimizeOnCopyToClipboard: await firstValueFrom(this.desktopSettingsService.minimizeOnCopy$), @@ -479,6 +496,15 @@ export class SettingsComponent implements OnInit, OnDestroy { ) .subscribe(); + this.form.controls.requireMasterPasswordOnAppRestart.valueChanges + .pipe( + concatMap(async (value) => { + await this.updateRequireMasterPasswordOnAppRestartHandler(value, activeAccount.id); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + this.form.controls.enableBrowserIntegration.valueChanges .pipe(takeUntil(this.destroy$)) .subscribe((enabled) => { @@ -510,16 +536,11 @@ export class SettingsComponent implements OnInit, OnDestroy { } // Avoid saving 0 since it's useless as a timeout value. - if (this.form.value.vaultTimeout === 0) { + if (newValue === 0) { return; } if (!this.form.controls.vaultTimeout.valid) { - this.platformUtilsService.showToast( - "error", - null, - this.i18nService.t("vaultTimeoutTooLarge"), - ); return; } @@ -593,7 +614,19 @@ export class SettingsComponent implements OnInit, OnDestroy { this.form.controls.pin.setValue(this.userHasPinSet, { emitEvent: false }); } else { const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - await this.vaultTimeoutSettingsService.clear(userId); + + // On Windows if a user turned off PIN without having a MP and has biometrics + require MP/PIN on restart enabled. + if ( + this.isWindows && + this.supportsBiometric && + this.form.value.requireMasterPasswordOnAppRestart && + this.form.value.biometric && + !this.userHasMasterPassword + ) { + // Allow biometric unlock on app restart so the user doesn't get into a bad state. + await this.enrollPersistentBiometricIfNeeded(userId); + } + await this.pinService.unsetPin(userId); } } @@ -644,6 +677,14 @@ export class SettingsComponent implements OnInit, OnDestroy { // Recommended settings for Windows Hello this.form.controls.autoPromptBiometrics.setValue(false); await this.biometricStateService.setPromptAutomatically(false); + + // If the user doesn't have a MP or PIN then they have to use biometrics on app restart. + if (!this.userHasMasterPassword && !this.userHasPinSet) { + // Allow biometric unlock on app restart so the user doesn't get into a bad state. + await this.enrollPersistentBiometricIfNeeded(activeUserId); + } else { + this.form.controls.requireMasterPasswordOnAppRestart.setValue(true); + } } else if (this.isLinux) { // Similar to Windows this.form.controls.autoPromptBiometrics.setValue(false); @@ -661,6 +702,37 @@ export class SettingsComponent implements OnInit, OnDestroy { } } + async updateRequireMasterPasswordOnAppRestartHandler(enabled: boolean, userId: UserId) { + try { + await this.updateRequireMasterPasswordOnAppRestart(enabled, userId); + } catch (error) { + this.logService.error("Error updating require master password on app restart: ", error); + this.validationService.showError(error); + } + } + + async updateRequireMasterPasswordOnAppRestart(enabled: boolean, userId: UserId) { + if (enabled) { + // Require master password or PIN on app restart + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + await this.biometricsService.deleteBiometricUnlockKeyForUser(userId); + await this.biometricsService.setBiometricProtectedUnlockKeyForUser(userId, userKey); + } else { + // Allow biometric unlock on app restart + await this.enrollPersistentBiometricIfNeeded(userId); + } + } + + private async enrollPersistentBiometricIfNeeded(userId: UserId): Promise { + if (!(await this.biometricsService.hasPersistentKey(userId))) { + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + await this.biometricsService.enrollPersistent(userId, userKey); + this.form.controls.requireMasterPasswordOnAppRestart.setValue(false, { + emitEvent: false, + }); + } + } + async updateAutoPromptBiometrics() { if (this.form.value.autoPromptBiometrics) { await this.biometricStateService.setPromptAutomatically(true); @@ -761,22 +833,6 @@ export class SettingsComponent implements OnInit, OnDestroy { ipc.platform.allowBrowserintegrationOverride || ipc.platform.isDev; if (!skipSupportedPlatformCheck) { - if ( - ipc.platform.deviceType === DeviceType.MacOsDesktop && - !this.platformUtilsService.isMacAppStore() - ) { - await this.dialogService.openSimpleDialog({ - title: { key: "browserIntegrationUnsupportedTitle" }, - content: { key: "browserIntegrationMasOnlyDesc" }, - acceptButtonText: { key: "ok" }, - cancelButtonText: null, - type: "warning", - }); - - this.form.controls.enableBrowserIntegration.setValue(false); - return; - } - if (ipc.platform.isWindowsStore) { await this.dialogService.openSimpleDialog({ title: { key: "browserIntegrationUnsupportedTitle" }, diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index b07c1c08718..b6e86ba19ff 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component"; +import { AuthRoute } from "@bitwarden/angular/auth/constants"; import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/environment-selector/environment-selector.component"; import { authGuard, @@ -21,6 +22,7 @@ import { UserLockIcon, VaultIcon, LockIcon, + DomainIcon, } from "@bitwarden/assets/svg"; import { LoginComponent, @@ -64,7 +66,7 @@ const routes: Routes = [ canActivate: [redirectGuard({ loggedIn: "/vault", loggedOut: "/login", locked: "/lock" })], }, { - path: "authentication-timeout", + path: AuthRoute.AuthenticationTimeout, component: AnonLayoutWrapperComponent, children: [ { @@ -80,7 +82,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "device-verification", + path: AuthRoute.NewDeviceVerification, component: AnonLayoutWrapperComponent, canActivate: [unauthGuardFn(), activeAuthGuard()], children: [{ path: "", component: NewDeviceVerificationComponent }], @@ -122,7 +124,7 @@ const routes: Routes = [ component: AnonLayoutWrapperComponent, children: [ { - path: "signup", + path: AuthRoute.SignUp, canActivate: [unauthGuardFn()], data: { pageIcon: RegistrationUserAddIcon, @@ -140,13 +142,13 @@ const routes: Routes = [ component: RegistrationStartSecondaryComponent, outlet: "secondary", data: { - loginRoute: "/login", + loginRoute: `/${AuthRoute.Login}`, } satisfies RegistrationStartSecondaryComponentData, }, ], }, { - path: "finish-signup", + path: AuthRoute.FinishSignUp, canActivate: [unauthGuardFn()], data: { pageIcon: LockIcon, @@ -159,7 +161,7 @@ const routes: Routes = [ ], }, { - path: "login", + path: AuthRoute.Login, canActivate: [maxAccountsGuardFn()], data: { pageTitle: { @@ -178,7 +180,7 @@ const routes: Routes = [ ], }, { - path: "login-initiated", + path: AuthRoute.LoginInitiated, canActivate: [tdeDecryptionRequiredGuard()], data: { pageIcon: DevicesIcon, @@ -186,7 +188,7 @@ const routes: Routes = [ children: [{ path: "", component: LoginDecryptionOptionsComponent }], }, { - path: "sso", + path: AuthRoute.Sso, data: { pageIcon: VaultIcon, pageTitle: { @@ -206,7 +208,7 @@ const routes: Routes = [ ], }, { - path: "login-with-device", + path: AuthRoute.LoginWithDevice, data: { pageIcon: DevicesIcon, pageTitle: { @@ -226,7 +228,7 @@ const routes: Routes = [ ], }, { - path: "admin-approval-requested", + path: AuthRoute.AdminApprovalRequested, data: { pageIcon: DevicesIcon, pageTitle: { @@ -239,7 +241,7 @@ const routes: Routes = [ children: [{ path: "", component: LoginViaAuthRequestComponent }], }, { - path: "hint", + path: AuthRoute.PasswordHint, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -277,7 +279,7 @@ const routes: Routes = [ ], }, { - path: "2fa", + path: AuthRoute.TwoFactor, canActivate: [unauthGuardFn(), TwoFactorAuthGuard], children: [ { @@ -289,20 +291,26 @@ const routes: Routes = [ pageTitle: { key: "verifyYourIdentity", }, + // `TwoFactorAuthComponent` manually sets its icon based on the 2fa type + pageIcon: null, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "set-initial-password", + path: AuthRoute.SetInitialPassword, canActivate: [authGuard], component: SetInitialPasswordComponent, data: { maxWidth: "lg", + pageIcon: LockIcon, } satisfies AnonLayoutWrapperData, }, { - path: "change-password", + path: AuthRoute.ChangePassword, component: ChangePasswordComponent, canActivate: [authGuard], + data: { + pageIcon: LockIcon, + } satisfies AnonLayoutWrapperData, }, { path: "confirm-key-connector-domain", @@ -312,6 +320,7 @@ const routes: Routes = [ pageTitle: { key: "confirmKeyConnectorDomain", }, + pageIcon: DomainIcon, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, ], diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 1c2d3aa464d..6243ba1e538 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -32,6 +32,7 @@ import { ModalService } from "@bitwarden/angular/services/modal.service"; import { FingerprintDialogComponent } from "@bitwarden/auth/angular"; import { DESKTOP_SSO_CALLBACK, + LockService, LogoutReason, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; @@ -47,6 +48,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeout, VaultTimeoutAction, @@ -90,6 +92,8 @@ const BroadcasterSubscriptionId = "AppComponent"; const IdleTimeout = 60000 * 10; // 10 minutes const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours +// 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-root", styles: [], @@ -114,14 +118,26 @@ const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours standalone: false, }) export class AppComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("settings", { read: ViewContainerRef, static: true }) settingsRef: ViewContainerRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("premium", { read: ViewContainerRef, static: true }) premiumRef: ViewContainerRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("passwordHistory", { read: ViewContainerRef, static: true }) passwordHistoryRef: ViewContainerRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("exportVault", { read: ViewContainerRef, static: true }) exportVaultModalRef: ViewContainerRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("appGenerator", { read: ViewContainerRef, static: true }) generatorModalRef: ViewContainerRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("loginApproval", { read: ViewContainerRef, static: true }) loginApprovalModalRef: ViewContainerRef; @@ -177,8 +193,10 @@ export class AppComponent implements OnInit, OnDestroy { private readonly destroyRef: DestroyRef, private readonly documentLangSetter: DocumentLangSetter, private restrictedItemTypesService: RestrictedItemTypesService, + private pinService: PinServiceAbstraction, private readonly tokenService: TokenService, private desktopAutotypeDefaultSettingPolicy: DesktopAutotypeDefaultSettingPolicy, + private readonly lockService: LockService, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); @@ -229,7 +247,7 @@ export class AppComponent implements OnInit, OnDestroy { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.updateAppMenu(); await this.systemService.clearPendingClipboard(); - await this.processReloadService.startProcessReload(this.authService); + await this.processReloadService.startProcessReload(); break; case "authBlocked": // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. @@ -242,21 +260,10 @@ export class AppComponent implements OnInit, OnDestroy { this.loading = false; break; case "lockVault": - await this.vaultTimeoutService.lock(message.userId); + await this.lockService.lock(message.userId); break; case "lockAllVaults": { - const currentUser = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a.id)), - ); - const accounts = await firstValueFrom(this.accountService.accounts$); - await this.vaultTimeoutService.lock(currentUser); - for (const account of Object.keys(accounts)) { - if (account === currentUser) { - continue; - } - - await this.vaultTimeoutService.lock(account); - } + await this.lockService.lockAll(); break; } case "locked": @@ -270,12 +277,12 @@ export class AppComponent implements OnInit, OnDestroy { } await this.updateAppMenu(); await this.systemService.clearPendingClipboard(); - await this.processReloadService.startProcessReload(this.authService); + await this.processReloadService.startProcessReload(); break; case "startProcessReload": // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.processReloadService.startProcessReload(this.authService); + this.processReloadService.startProcessReload(); break; case "cancelProcessReload": this.processReloadService.cancelProcessReload(); @@ -691,10 +698,9 @@ export class AppComponent implements OnInit, OnDestroy { await this.eventUploadService.uploadEvents(userBeingLoggedOut); await this.keyService.clearKeys(userBeingLoggedOut); await this.cipherService.clear(userBeingLoggedOut); - // ! DO NOT REMOVE folderService.clear ! For more information see PM-25660 await this.folderService.clear(userBeingLoggedOut); - await this.vaultTimeoutSettingsService.clear(userBeingLoggedOut); await this.biometricStateService.logout(userBeingLoggedOut); + await this.pinService.logout(userBeingLoggedOut); await this.stateEventRunnerService.handleEvent("logout", userBeingLoggedOut); @@ -721,8 +727,6 @@ export class AppComponent implements OnInit, OnDestroy { } } - await this.updateAppMenu(); - // This must come last otherwise the logout will prematurely trigger // a process reload before all the state service user data can be cleaned up this.authService.logOut(async () => {}, userBeingLoggedOut); @@ -799,11 +803,9 @@ export class AppComponent implements OnInit, OnDestroy { } const options = await this.getVaultTimeoutOptions(userId); if (options[0] === timeout) { - // 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 options[1] === "logOut" - ? this.logOut("vaultTimeout", userId as UserId) - : await this.vaultTimeoutService.lock(userId); + ? await this.logOut("vaultTimeout", userId as UserId) + : await this.lockService.lock(userId as UserId); } } } diff --git a/apps/desktop/src/app/components/avatar.component.ts b/apps/desktop/src/app/components/avatar.component.ts index 1fba864686c..e94aaf83183 100644 --- a/apps/desktop/src/app/components/avatar.component.ts +++ b/apps/desktop/src/app/components/avatar.component.ts @@ -5,20 +5,38 @@ import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; import { Utils } from "@bitwarden/common/platform/misc/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: "app-avatar", template: ``, standalone: false, }) export class AvatarComponent implements OnChanges, OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() size = 45; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() charCount = 2; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() fontSize = 20; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() dynamic = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() circle = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() color?: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() id?: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() text?: string; private svgCharCount = 2; @@ -114,7 +132,7 @@ export class AvatarComponent implements OnChanges, OnInit { textTag.setAttribute("fill", Utils.pickTextColorBasedOnBgColor(color, 135, true)); textTag.setAttribute( "font-family", - 'Roboto,"Helvetica Neue",Helvetica,Arial,' + + 'Inter,"Helvetica Neue",Helvetica,Arial,' + 'sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"', ); textTag.textContent = character; diff --git a/apps/desktop/src/app/components/browser-sync-verification-dialog.component.ts b/apps/desktop/src/app/components/browser-sync-verification-dialog.component.ts index 713dc07e803..d65df60a8ce 100644 --- a/apps/desktop/src/app/components/browser-sync-verification-dialog.component.ts +++ b/apps/desktop/src/app/components/browser-sync-verification-dialog.component.ts @@ -1,12 +1,20 @@ import { Component, Inject } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { DIALOG_DATA, ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; +import { + DIALOG_DATA, + ButtonModule, + DialogModule, + DialogService, + CenterPositionStrategy, +} from "@bitwarden/components"; export type BrowserSyncVerificationDialogParams = { fingerprint: string[]; }; +// 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: "browser-sync-verification-dialog.component.html", imports: [JslibModule, ButtonModule, DialogModule], @@ -17,6 +25,7 @@ export class BrowserSyncVerificationDialogComponent { static open(dialogService: DialogService, data: BrowserSyncVerificationDialogParams) { return dialogService.open(BrowserSyncVerificationDialogComponent, { data, + positionStrategy: new CenterPositionStrategy(), }); } } diff --git a/apps/desktop/src/app/components/fido2placeholder.component.ts b/apps/desktop/src/app/components/fido2placeholder.component.ts index b95dcc6d890..f1f52dae439 100644 --- a/apps/desktop/src/app/components/fido2placeholder.component.ts +++ b/apps/desktop/src/app/components/fido2placeholder.component.ts @@ -9,6 +9,8 @@ import { } from "../../autofill/services/desktop-fido2-user-interface.service"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.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({ standalone: true, imports: [CommonModule], diff --git a/apps/desktop/src/app/components/user-verification.component.ts b/apps/desktop/src/app/components/user-verification.component.ts index 31d38b10183..e19916c3d6b 100644 --- a/apps/desktop/src/app/components/user-verification.component.ts +++ b/apps/desktop/src/app/components/user-verification.component.ts @@ -11,6 +11,8 @@ import { FormFieldModule } from "@bitwarden/components"; * @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent or UserVerificationFormInputComponent instead. * Each client specific component should eventually be converted over to use one of these new components. */ +// 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-user-verification", imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, FormsModule], diff --git a/apps/desktop/src/app/components/verify-native-messaging-dialog.component.ts b/apps/desktop/src/app/components/verify-native-messaging-dialog.component.ts index 72284d007b6..6f9695f856a 100644 --- a/apps/desktop/src/app/components/verify-native-messaging-dialog.component.ts +++ b/apps/desktop/src/app/components/verify-native-messaging-dialog.component.ts @@ -1,12 +1,20 @@ import { Component, Inject } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { DIALOG_DATA, ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; +import { + DIALOG_DATA, + ButtonModule, + DialogModule, + DialogService, + CenterPositionStrategy, +} from "@bitwarden/components"; export type VerifyNativeMessagingDialogData = { applicationName: string; }; +// 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: "verify-native-messaging-dialog.component.html", imports: [JslibModule, ButtonModule, DialogModule], @@ -17,6 +25,7 @@ export class VerifyNativeMessagingDialogComponent { static open(dialogService: DialogService, data: VerifyNativeMessagingDialogData) { return dialogService.open(VerifyNativeMessagingDialogComponent, { data, + positionStrategy: new CenterPositionStrategy(), }); } } diff --git a/apps/desktop/src/app/layout/account-switcher.component.ts b/apps/desktop/src/app/layout/account-switcher.component.ts index a54674c3a1e..6a7e274ade4 100644 --- a/apps/desktop/src/app/layout/account-switcher.component.ts +++ b/apps/desktop/src/app/layout/account-switcher.component.ts @@ -31,6 +31,8 @@ type InactiveAccount = ActiveAccount & { authenticationStatus: AuthenticationStatus; }; +// 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-account-switcher", templateUrl: "account-switcher.component.html", diff --git a/apps/desktop/src/app/layout/header.component.ts b/apps/desktop/src/app/layout/header.component.ts index 9aef093423f..9630e3b1914 100644 --- a/apps/desktop/src/app/layout/header.component.ts +++ b/apps/desktop/src/app/layout/header.component.ts @@ -1,5 +1,7 @@ import { Component } from "@angular/core"; +// 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-header", templateUrl: "header.component.html", diff --git a/apps/desktop/src/app/layout/nav.component.ts b/apps/desktop/src/app/layout/nav.component.ts index bcc2b57fb17..72064a4de51 100644 --- a/apps/desktop/src/app/layout/nav.component.ts +++ b/apps/desktop/src/app/layout/nav.component.ts @@ -4,6 +4,8 @@ import { RouterLink, RouterLinkActive } from "@angular/router"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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: "app-nav", templateUrl: "nav.component.html", diff --git a/apps/desktop/src/app/layout/search/search.component.ts b/apps/desktop/src/app/layout/search/search.component.ts index 70196d74dda..c0b088a13d9 100644 --- a/apps/desktop/src/app/layout/search/search.component.ts +++ b/apps/desktop/src/app/layout/search/search.component.ts @@ -8,6 +8,8 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { SearchBarService, SearchBarState } from "./search-bar.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: "app-search", templateUrl: "search.component.html", diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index 6b511ff366d..73c4d38d3b2 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -6,9 +6,10 @@ import { AbstractThemingService } from "@bitwarden/angular/platform/services/the import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service"; +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"; @@ -27,6 +28,7 @@ import { DesktopAutotypeService } from "../../autofill/services/desktop-autotype import { SshAgentService } from "../../autofill/services/ssh-agent.service"; import { I18nRendererService } from "../../platform/services/i18n.renderer.service"; import { VersionService } from "../../platform/services/version.service"; +import { BiometricMessageHandlerService } from "../../services/biometric-message-handler.service"; import { NativeMessagingService } from "../../services/native-messaging.service"; @Injectable() @@ -37,7 +39,7 @@ export class InitService { private vaultTimeoutService: DefaultVaultTimeoutService, private i18nService: I18nServiceAbstraction, private eventUploadService: EventUploadServiceAbstraction, - private twoFactorService: TwoFactorServiceAbstraction, + private twoFactorService: TwoFactorService, private notificationsService: ServerNotificationsService, private platformUtilsService: PlatformUtilsServiceAbstraction, private stateService: StateServiceAbstraction, @@ -52,6 +54,8 @@ export class InitService { private autofillService: DesktopAutofillService, private autotypeService: DesktopAutotypeService, private sdkLoadService: SdkLoadService, + private biometricMessageHandlerService: BiometricMessageHandlerService, + private configService: ConfigService, @Inject(DOCUMENT) private document: Document, private readonly migrationRunner: MigrationRunner, ) {} @@ -62,6 +66,7 @@ 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 = []; @@ -92,6 +97,7 @@ export class InitService { const containerService = new ContainerService(this.keyService, this.encryptService); containerService.attachToGlobal(this.win); + await this.biometricMessageHandlerService.init(); await this.autofillService.init(); await this.autotypeService.init(); }; diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 9f2bb1acc90..03d6eb5c908 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -77,7 +77,10 @@ import { LogService as LogServiceAbstraction, } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + PlatformUtilsService, + PlatformUtilsService as PlatformUtilsServiceAbstraction, +} from "@bitwarden/common/platform/abstractions/platform-utils.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"; @@ -106,7 +109,10 @@ import { BiometricStateService, BiometricsService, } from "@bitwarden/key-management"; -import { LockComponentService } from "@bitwarden/key-management-ui"; +import { + LockComponentService, + SessionTimeoutSettingsComponentService, +} from "@bitwarden/key-management-ui"; import { SerializedMemoryStorageService } from "@bitwarden/storage-core"; import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; @@ -122,6 +128,7 @@ import { DesktopBiometricsService } from "../../key-management/biometrics/deskto import { RendererBiometricsService } from "../../key-management/biometrics/renderer-biometrics.service"; import { ElectronKeyService } from "../../key-management/electron-key.service"; import { DesktopLockComponentService } from "../../key-management/lock/services/desktop-lock-component.service"; +import { DesktopSessionTimeoutSettingsComponentService } from "../../key-management/session-timeout/services/desktop-session-timeout-settings-component.service"; import { flagEnabled } from "../../platform/flags"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; import { ElectronLogRendererService } from "../../platform/services/electron-log.renderer.service"; @@ -262,6 +269,7 @@ const safeProviders: SafeProvider[] = [ BiometricStateService, AccountServiceAbstraction, LogService, + AuthServiceAbstraction, ], }), safeProvider({ @@ -306,7 +314,6 @@ const safeProviders: SafeProvider[] = [ provide: KeyServiceAbstraction, useClass: ElectronKeyService, deps: [ - PinServiceAbstraction, InternalMasterPasswordServiceAbstraction, KeyGenerationService, CryptoFunctionServiceAbstraction, @@ -337,6 +344,7 @@ const safeProviders: SafeProvider[] = [ ConfigService, Fido2AuthenticatorServiceAbstraction, AccountService, + PlatformUtilsService, ], }), safeProvider({ @@ -476,6 +484,11 @@ const safeProviders: SafeProvider[] = [ useClass: DesktopAutotypeDefaultSettingPolicy, deps: [AccountServiceAbstraction, AuthServiceAbstraction, InternalPolicyService, ConfigService], }), + safeProvider({ + provide: SessionTimeoutSettingsComponentService, + useClass: DesktopSessionTimeoutSettingsComponentService, + deps: [I18nServiceAbstraction], + }), ]; @NgModule({ 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 53a1c7dbd4c..717af25a1dc 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 @@ -119,7 +119,9 @@ describe("DesktopSetInitialPasswordService", () => { userDecryptionOptions = new UserDecryptionOptions({ hasMasterPassword: true }); userDecryptionOptionsSubject = new BehaviorSubject(userDecryptionOptions); - userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject; + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + userDecryptionOptionsSubject, + ); setPasswordRequest = new SetPasswordRequest( credentials.newServerMasterKeyHash, diff --git a/apps/desktop/src/app/tools/export/export-desktop.component.ts b/apps/desktop/src/app/tools/export/export-desktop.component.ts index 03afb154200..0adc8e758c9 100644 --- a/apps/desktop/src/app/tools/export/export-desktop.component.ts +++ b/apps/desktop/src/app/tools/export/export-desktop.component.ts @@ -5,6 +5,8 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { DialogRef, AsyncActionsModule, ButtonModule, DialogModule } from "@bitwarden/components"; import { ExportComponent } from "@bitwarden/vault-export-ui"; +// 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: "export-desktop.component.html", imports: [ diff --git a/apps/desktop/src/app/tools/generator/credential-generator.component.ts b/apps/desktop/src/app/tools/generator/credential-generator.component.ts index 4124b2439da..42313c48f7f 100644 --- a/apps/desktop/src/app/tools/generator/credential-generator.component.ts +++ b/apps/desktop/src/app/tools/generator/credential-generator.component.ts @@ -13,6 +13,8 @@ import { GeneratorModule, } from "@bitwarden/generator-components"; +// 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: "credential-generator", templateUrl: "credential-generator.component.html", diff --git a/apps/desktop/src/app/tools/import/chromium-importer.service.ts b/apps/desktop/src/app/tools/import/chromium-importer.service.ts index 56f31c359db..0faff81974a 100644 --- a/apps/desktop/src/app/tools/import/chromium-importer.service.ts +++ b/apps/desktop/src/app/tools/import/chromium-importer.service.ts @@ -4,8 +4,8 @@ import { chromium_importer } from "@bitwarden/desktop-napi"; export class ChromiumImporterService { constructor() { - ipcMain.handle("chromium_importer.getInstalledBrowsers", async (event) => { - return await chromium_importer.getInstalledBrowsers(); + ipcMain.handle("chromium_importer.getMetadata", async (event) => { + return await chromium_importer.getMetadata(); }); ipcMain.handle("chromium_importer.getAvailableProfiles", async (event, browser: string) => { diff --git a/apps/desktop/src/app/tools/import/desktop-import-metadata.service.ts b/apps/desktop/src/app/tools/import/desktop-import-metadata.service.ts new file mode 100644 index 00000000000..0c29cd9f44a --- /dev/null +++ b/apps/desktop/src/app/tools/import/desktop-import-metadata.service.ts @@ -0,0 +1,68 @@ +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; +import type { chromium_importer } from "@bitwarden/desktop-napi"; +import { + ImportType, + DefaultImportMetadataService, + ImportMetadataServiceAbstraction, + DataLoader, + ImporterMetadata, + InstructionLink, + Instructions, + Loader, +} from "@bitwarden/importer-core"; + +export class DesktopImportMetadataService + extends DefaultImportMetadataService + implements ImportMetadataServiceAbstraction +{ + constructor(system: SystemServiceProvider) { + super(system); + } + + async init(): Promise { + const metadata = await ipc.tools.chromiumImporter.getMetadata(); + await this.parseNativeMetaData(metadata); + await super.init(); + } + + private async parseNativeMetaData( + raw: Record, + ): Promise { + const entries = Object.entries(raw).map(([id, meta]) => { + const loaders = meta.loaders.map(this.mapLoader); + const instructions = this.mapInstructions(meta.instructions); + const mapped: ImporterMetadata = { + type: id as ImportType, + loaders, + ...(instructions ? { instructions } : {}), + }; + return [id, mapped] as const; + }); + + // Do not overwrite existing importers, just add new ones or update existing ones + this.importers = { + ...this.importers, + ...Object.fromEntries(entries), + }; + } + + private mapLoader(name: string): DataLoader { + switch (name) { + case "file": + return Loader.file; + case "chromium": + return Loader.chromium; + default: + throw new Error(`Unknown loader from native module: ${name}`); + } + } + + private mapInstructions(name: string): InstructionLink | undefined { + switch (name) { + case "chromium": + return Instructions.chromium; + default: + return undefined; + } + } +} diff --git a/apps/desktop/src/app/tools/import/import-desktop.component.ts b/apps/desktop/src/app/tools/import/import-desktop.component.ts index f096471f770..6b1d26562fc 100644 --- a/apps/desktop/src/app/tools/import/import-desktop.component.ts +++ b/apps/desktop/src/app/tools/import/import-desktop.component.ts @@ -3,8 +3,19 @@ import { Component } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { DialogRef, AsyncActionsModule, ButtonModule, DialogModule } from "@bitwarden/components"; -import { ImportComponent } from "@bitwarden/importer-ui"; +import type { chromium_importer } from "@bitwarden/desktop-napi"; +import { ImportMetadataServiceAbstraction } from "@bitwarden/importer-core"; +import { + ImportComponent, + ImporterProviders, + SYSTEM_SERVICE_PROVIDER, +} from "@bitwarden/importer-ui"; +import { safeProvider } from "@bitwarden/ui-common"; +import { DesktopImportMetadataService } from "./desktop-import-metadata.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({ templateUrl: "import-desktop.component.html", imports: [ @@ -15,6 +26,14 @@ import { ImportComponent } from "@bitwarden/importer-ui"; ButtonModule, ImportComponent, ], + providers: [ + ...ImporterProviders, + safeProvider({ + provide: ImportMetadataServiceAbstraction, + useClass: DesktopImportMetadataService, + deps: [SYSTEM_SERVICE_PROVIDER], + }), + ], }) export class ImportDesktopComponent { protected disabled = false; @@ -29,11 +48,14 @@ export class ImportDesktopComponent { this.dialogRef.close(); } - protected onLoadProfilesFromBrowser(browser: string): Promise { + protected onLoadProfilesFromBrowser(browser: string): Promise { return ipc.tools.chromiumImporter.getAvailableProfiles(browser); } - protected onImportFromBrowser(browser: string, profile: string): Promise { + protected onImportFromBrowser( + browser: string, + profile: string, + ): Promise { return ipc.tools.chromiumImporter.importLogins(browser, profile); } } diff --git a/apps/desktop/src/app/tools/preload.ts b/apps/desktop/src/app/tools/preload.ts index 574c27ac9fd..ff0a4ffbbd8 100644 --- a/apps/desktop/src/app/tools/preload.ts +++ b/apps/desktop/src/app/tools/preload.ts @@ -1,11 +1,16 @@ import { ipcRenderer } from "electron"; +import type { chromium_importer } from "@bitwarden/desktop-napi"; + const chromiumImporter = { - getInstalledBrowsers: (): Promise => - ipcRenderer.invoke("chromium_importer.getInstalledBrowsers"), - getAvailableProfiles: (browser: string): Promise => + getMetadata: (): Promise> => + ipcRenderer.invoke("chromium_importer.getMetadata"), + getAvailableProfiles: (browser: string): Promise => ipcRenderer.invoke("chromium_importer.getAvailableProfiles", browser), - importLogins: (browser: string, profileId: string): Promise => + importLogins: ( + browser: string, + profileId: string, + ): Promise => ipcRenderer.invoke("chromium_importer.importLogins", browser, profileId), }; diff --git a/apps/desktop/src/app/tools/send/add-edit.component.ts b/apps/desktop/src/app/tools/send/add-edit.component.ts index 025bab66539..076b0f6c9d5 100644 --- a/apps/desktop/src/app/tools/send/add-edit.component.ts +++ b/apps/desktop/src/app/tools/send/add-edit.component.ts @@ -3,11 +3,13 @@ import { CommonModule, DatePipe } from "@angular/common"; import { Component } from "@angular/core"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -17,12 +19,23 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/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 { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { CalloutModule, DialogService, ToastService } from "@bitwarden/components"; +import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.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: "app-send-add-edit", templateUrl: "add-edit.component.html", imports: [CommonModule, JslibModule, ReactiveFormsModule, CalloutModule], + providers: [ + { + provide: PremiumUpgradePromptService, + useClass: DesktopPremiumUpgradePromptService, + }, + ], }) export class AddEditComponent extends BaseAddEditComponent { constructor( @@ -41,6 +54,7 @@ export class AddEditComponent extends BaseAddEditComponent { billingAccountProfileStateService: BillingAccountProfileStateService, accountService: AccountService, toastService: ToastService, + premiumUpgradePromptService: PremiumUpgradePromptService, ) { super( i18nService, @@ -58,12 +72,14 @@ export class AddEditComponent extends BaseAddEditComponent { billingAccountProfileStateService, accountService, toastService, + premiumUpgradePromptService, ); } async refresh() { const send = await this.loadSend(); - this.send = await send.decrypt(); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + this.send = await send.decrypt(userId); this.updateFormValues(); this.hasPassword = this.send.password != null && this.send.password.trim() !== ""; } diff --git a/apps/desktop/src/app/tools/send/send.component.ts b/apps/desktop/src/app/tools/send/send.component.ts index 0146a5e62ea..b58c3961bde 100644 --- a/apps/desktop/src/app/tools/send/send.component.ts +++ b/apps/desktop/src/app/tools/send/send.component.ts @@ -25,22 +25,29 @@ import { SearchBarService } from "../../layout/search/search-bar.service"; import { AddEditComponent } from "./add-edit.component"; -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -enum Action { - None = "", - Add = "add", - Edit = "edit", -} +const Action = Object.freeze({ + /** No action is currently active. */ + None: "", + /** The user is adding a new Send. */ + Add: "add", + /** The user is editing an existing Send. */ + Edit: "edit", +} as const); + +type Action = (typeof Action)[keyof typeof Action]; const BroadcasterSubscriptionId = "SendComponent"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-send", templateUrl: "send.component.html", imports: [CommonModule, JslibModule, FormsModule, NavComponent, AddEditComponent], }) export class SendComponent extends BaseSendComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(AddEditComponent) addEditComponent: AddEditComponent; sendId: string; diff --git a/apps/desktop/src/auth/components/set-pin.component.html b/apps/desktop/src/auth/components/set-pin.component.html index 6fb5829b79a..aaebf7c1cdb 100644 --- a/apps/desktop/src/auth/components/set-pin.component.html +++ b/apps/desktop/src/auth/components/set-pin.component.html @@ -1,6 +1,6 @@ -
    +
    {{ "unlockWithPin" | i18n }}
    diff --git a/apps/desktop/src/auth/components/set-pin.component.ts b/apps/desktop/src/auth/components/set-pin.component.ts index 93e1ea0d25c..5bb8e761b32 100644 --- a/apps/desktop/src/auth/components/set-pin.component.ts +++ b/apps/desktop/src/auth/components/set-pin.component.ts @@ -12,6 +12,8 @@ import { FormFieldModule, IconButtonModule, } from "@bitwarden/components"; +// 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: "set-pin.component.html", imports: [ diff --git a/apps/desktop/src/auth/delete-account.component.ts b/apps/desktop/src/auth/delete-account.component.ts index b6c6650375d..5cd73896e07 100644 --- a/apps/desktop/src/auth/delete-account.component.ts +++ b/apps/desktop/src/auth/delete-account.component.ts @@ -20,6 +20,8 @@ import { import { UserVerificationComponent } from "../app/components/user-verification.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-delete-account", templateUrl: "delete-account.component.html", diff --git a/apps/desktop/src/autofill/components/autotype-shortcut.component.html b/apps/desktop/src/autofill/components/autotype-shortcut.component.html index 774c299e0b6..6f73d4006ac 100644 --- a/apps/desktop/src/autofill/components/autotype-shortcut.component.html +++ b/apps/desktop/src/autofill/components/autotype-shortcut.component.html @@ -1,6 +1,6 @@ -
    +
    {{ "typeShortcut" | i18n }}
    diff --git a/apps/desktop/src/autofill/components/autotype-shortcut.component.ts b/apps/desktop/src/autofill/components/autotype-shortcut.component.ts index 5cf1d90cb79..3c82d8297a1 100644 --- a/apps/desktop/src/autofill/components/autotype-shortcut.component.ts +++ b/apps/desktop/src/autofill/components/autotype-shortcut.component.ts @@ -22,6 +22,8 @@ import { IconButtonModule, } from "@bitwarden/components"; +// 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: "autotype-shortcut.component.html", imports: [ diff --git a/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts b/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts index 09f03d2ef8e..4dcf05a4220 100644 --- a/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts +++ b/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts @@ -5,6 +5,8 @@ import { LogService } from "@bitwarden/logging"; import { WindowMain } from "../../main/window.main"; import { stringIsNotUndefinedNullAndEmpty } from "../../utils"; +import { AutotypeMatchError } from "../models/autotype-errors"; +import { AutotypeVaultData } from "../models/autotype-vault-data"; import { AutotypeKeyboardShortcut } from "../models/main-autotype-keyboard-shortcut"; export class MainDesktopAutotypeService { @@ -47,20 +49,22 @@ export class MainDesktopAutotypeService { } }); - ipcMain.on("autofill.completeAutotypeRequest", (event, data) => { - const { response } = data; - + ipcMain.on("autofill.completeAutotypeRequest", (_event, vaultData: AutotypeVaultData) => { if ( - stringIsNotUndefinedNullAndEmpty(response.username) && - stringIsNotUndefinedNullAndEmpty(response.password) + stringIsNotUndefinedNullAndEmpty(vaultData.username) && + stringIsNotUndefinedNullAndEmpty(vaultData.password) ) { - this.doAutotype( - response.username, - response.password, - this.autotypeKeyboardShortcut.getArrayFormat(), - ); + this.doAutotype(vaultData, this.autotypeKeyboardShortcut.getArrayFormat()); } }); + + ipcMain.on("autofill.completeAutotypeError", (_event, matchError: AutotypeMatchError) => { + this.logService.debug( + "autofill.completeAutotypeError", + "No match for window: " + matchError.windowTitle, + ); + this.logService.error("autofill.completeAutotypeError", matchError.errorMessage); + }); } disableAutotype() { @@ -89,8 +93,9 @@ export class MainDesktopAutotypeService { : this.logService.info("Enabling autotype failed."); } - private doAutotype(username: string, password: string, keyboardShortcut: string[]) { - const inputPattern = username + "\t" + password; + private doAutotype(vaultData: AutotypeVaultData, keyboardShortcut: string[]) { + const TAB = "\t"; + const inputPattern = vaultData.username + TAB + vaultData.password; const inputArray = new Array(inputPattern.length); for (let i = 0; i < inputPattern.length; i++) { diff --git a/apps/desktop/src/autofill/models/autotype-errors.ts b/apps/desktop/src/autofill/models/autotype-errors.ts new file mode 100644 index 00000000000..9e59b102302 --- /dev/null +++ b/apps/desktop/src/autofill/models/autotype-errors.ts @@ -0,0 +1,8 @@ +/** + * This error is surfaced when there is no matching + * vault item found. + */ +export interface AutotypeMatchError { + windowTitle: string; + errorMessage: string; +} diff --git a/apps/desktop/src/autofill/models/autotype-vault-data.ts b/apps/desktop/src/autofill/models/autotype-vault-data.ts new file mode 100644 index 00000000000..ee3db98c334 --- /dev/null +++ b/apps/desktop/src/autofill/models/autotype-vault-data.ts @@ -0,0 +1,8 @@ +/** + * Vault data used in autotype operations. + * `username` and `password` are guaranteed to be not null/undefined. + */ +export interface AutotypeVaultData { + username: string; + password: string; +} diff --git a/apps/desktop/src/autofill/preload.ts b/apps/desktop/src/autofill/preload.ts index fcb2f646743..e839ac223b7 100644 --- a/apps/desktop/src/autofill/preload.ts +++ b/apps/desktop/src/autofill/preload.ts @@ -5,6 +5,9 @@ import type { autofill } from "@bitwarden/desktop-napi"; import { Command } from "../platform/main/autofill/command"; import { RunCommandParams, RunCommandResult } from "../platform/main/autofill/native-autofill.main"; +import { AutotypeMatchError } from "./models/autotype-errors"; +import { AutotypeVaultData } from "./models/autotype-vault-data"; + export default { runCommand: (params: RunCommandParams): Promise> => ipcRenderer.invoke("autofill.runCommand", params), @@ -133,35 +136,31 @@ export default { listenAutotypeRequest: ( fn: ( windowTitle: string, - completeCallback: ( - error: Error | null, - response: { username?: string; password?: string }, - ) => void, + completeCallback: (error: Error | null, response: AutotypeVaultData | null) => void, ) => void, ) => { ipcRenderer.on( "autofill.listenAutotypeRequest", ( - event, + _event, data: { windowTitle: string; }, ) => { const { windowTitle } = data; - fn(windowTitle, (error, response) => { + fn(windowTitle, (error, vaultData) => { if (error) { - ipcRenderer.send("autofill.completeError", { + const matchError: AutotypeMatchError = { windowTitle, - error: error.message, - }); + errorMessage: error.message, + }; + ipcRenderer.send("autofill.completeAutotypeError", matchError); return; } - - ipcRenderer.send("autofill.completeAutotypeRequest", { - windowTitle, - response, - }); + if (vaultData !== null) { + ipcRenderer.send("autofill.completeAutotypeRequest", vaultData); + } }); }, ); diff --git a/apps/desktop/src/autofill/services/desktop-autofill.service.ts b/apps/desktop/src/autofill/services/desktop-autofill.service.ts index 5500bc58f5a..18f4652d72a 100644 --- a/apps/desktop/src/autofill/services/desktop-autofill.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autofill.service.ts @@ -13,6 +13,7 @@ import { import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service"; +import { DeviceType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -24,6 +25,7 @@ import { Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction, } from "@bitwarden/common/platform/abstractions/fido2/fido2-authenticator.service.abstraction"; 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 { parseCredentialId } from "@bitwarden/common/platform/services/fido2/credential-id-utils"; import { getCredentialsForAutofill } from "@bitwarden/common/platform/services/fido2/fido2-autofill-utils"; @@ -53,9 +55,15 @@ export class DesktopAutofillService implements OnDestroy { private configService: ConfigService, private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction, private accountService: AccountService, + private platformUtilsService: PlatformUtilsService, ) {} async init() { + // Currently only supported for MacOS + if (this.platformUtilsService.getDevice() !== DeviceType.MacOsDesktop) { + return; + } + this.configService .getFeatureFlag$(FeatureFlag.MacOsNativeCredentialSync) .pipe( diff --git a/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts index 7fb30333e28..555e6ceef5b 100644 --- a/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts +++ b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts @@ -1,8 +1,10 @@ import { TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom, take, timeout, TimeoutError } from "rxjs"; +import { BehaviorSubject, firstValueFrom, take } from "rxjs"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -18,10 +20,10 @@ describe("DesktopAutotypeDefaultSettingPolicy", () => { let policyService: MockProxy; let configService: MockProxy; - let mockAccountSubject: BehaviorSubject<{ id: UserId } | null>; + let mockAccountSubject: BehaviorSubject; let mockFeatureFlagSubject: BehaviorSubject; let mockAuthStatusSubject: BehaviorSubject; - let mockPolicyAppliesSubject: BehaviorSubject; + let mockPoliciesSubject: BehaviorSubject; const mockUserId = "user-123" as UserId; @@ -36,7 +38,7 @@ describe("DesktopAutotypeDefaultSettingPolicy", () => { mockAuthStatusSubject = new BehaviorSubject( AuthenticationStatus.Unlocked, ); - mockPolicyAppliesSubject = new BehaviorSubject(false); + mockPoliciesSubject = new BehaviorSubject([]); accountService = mock(); authService = mock(); @@ -50,9 +52,7 @@ describe("DesktopAutotypeDefaultSettingPolicy", () => { authService.authStatusFor$ = jest .fn() .mockImplementation((_: UserId) => mockAuthStatusSubject.asObservable()); - policyService.policyAppliesToUser$ = jest - .fn() - .mockReturnValue(mockPolicyAppliesSubject.asObservable()); + policyService.policies$ = jest.fn().mockReturnValue(mockPoliciesSubject.asObservable()); TestBed.configureTestingModule({ providers: [ @@ -72,7 +72,7 @@ describe("DesktopAutotypeDefaultSettingPolicy", () => { mockAccountSubject.complete(); mockFeatureFlagSubject.complete(); mockAuthStatusSubject.complete(); - mockPolicyAppliesSubject.complete(); + mockPoliciesSubject.complete(); }); describe("autotypeDefaultSetting$", () => { @@ -82,11 +82,20 @@ describe("DesktopAutotypeDefaultSettingPolicy", () => { expect(result).toBeNull(); }); - it("should not emit when no active account", async () => { + it("does not emit until an account appears", async () => { mockAccountSubject.next(null); - await expect( - firstValueFrom(service.autotypeDefaultSetting$.pipe(timeout({ first: 30 }))), - ).rejects.toBeInstanceOf(TimeoutError); + + mockAccountSubject.next({ id: mockUserId } as Account); + mockAuthStatusSubject.next(AuthenticationStatus.Unlocked); + mockPoliciesSubject.next([ + { + type: PolicyType.AutotypeDefaultSetting, + enabled: true, + } as Policy, + ]); + + const result = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(result).toBe(true); }); it("should emit null when user is not unlocked", async () => { @@ -96,34 +105,56 @@ describe("DesktopAutotypeDefaultSettingPolicy", () => { }); it("should emit null when no autotype policy exists", async () => { - mockPolicyAppliesSubject.next(false); + mockPoliciesSubject.next([]); const policy = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); expect(policy).toBeNull(); }); it("should emit true when autotype policy is enabled", async () => { - mockPolicyAppliesSubject.next(true); + mockPoliciesSubject.next([ + { + type: PolicyType.AutotypeDefaultSetting, + enabled: true, + } as Policy, + ]); const policyStatus = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); expect(policyStatus).toBe(true); }); - it("should emit false when autotype policy is disabled", async () => { - mockPolicyAppliesSubject.next(false); + it("should emit null when autotype policy is disabled", async () => { + mockPoliciesSubject.next([ + { + type: PolicyType.AutotypeDefaultSetting, + enabled: false, + } as Policy, + ]); const policyStatus = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); expect(policyStatus).toBeNull(); }); it("should emit null when autotype policy does not apply", async () => { - mockPolicyAppliesSubject.next(false); + mockPoliciesSubject.next([ + { + type: PolicyType.RequireSso, + enabled: true, + } as Policy, + ]); const policy = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); expect(policy).toBeNull(); }); it("should react to authentication status changes", async () => { + mockPoliciesSubject.next([ + { + type: PolicyType.AutotypeDefaultSetting, + enabled: true, + } as Policy, + ]); + // Expect one emission when unlocked mockAuthStatusSubject.next(AuthenticationStatus.Unlocked); const first = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); - expect(first).toBeNull(); + expect(first).toBe(true); // Expect null emission when locked mockAuthStatusSubject.next(AuthenticationStatus.Locked); @@ -134,33 +165,131 @@ describe("DesktopAutotypeDefaultSettingPolicy", () => { it("should react to account changes", async () => { const newUserId = "user-456" as UserId; + mockPoliciesSubject.next([ + { + type: PolicyType.AutotypeDefaultSetting, + enabled: true, + } as Policy, + ]); + // First value for original user const firstValue = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); - expect(firstValue).toBeNull(); + expect(firstValue).toBe(true); // Change account and expect a new emission mockAccountSubject.next({ id: newUserId, - }); + } as Account); const secondValue = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); - expect(secondValue).toBeNull(); + expect(secondValue).toBe(true); // Verify the auth lookup was switched to the new user expect(authService.authStatusFor$).toHaveBeenCalledWith(newUserId); + expect(policyService.policies$).toHaveBeenCalledWith(newUserId); }); it("should react to policy changes", async () => { - mockPolicyAppliesSubject.next(false); + mockPoliciesSubject.next([]); const nullValue = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); expect(nullValue).toBeNull(); - mockPolicyAppliesSubject.next(true); + mockPoliciesSubject.next([ + { + type: PolicyType.AutotypeDefaultSetting, + enabled: true, + } as Policy, + ]); const trueValue = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); expect(trueValue).toBe(true); - mockPolicyAppliesSubject.next(false); + mockPoliciesSubject.next([]); const nullValueAgain = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); expect(nullValueAgain).toBeNull(); }); + + it("emits null again if the feature flag turns off after emitting", async () => { + mockPoliciesSubject.next([ + { type: PolicyType.AutotypeDefaultSetting, enabled: true } as Policy, + ]); + expect(await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1)))).toBe(true); + + mockFeatureFlagSubject.next(false); + expect(await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1)))).toBeNull(); + }); + + it("replays the latest value to late subscribers", async () => { + mockPoliciesSubject.next([ + { type: PolicyType.AutotypeDefaultSetting, enabled: true } as Policy, + ]); + + await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + + const late = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(late).toBe(true); + }); + + it("does not re-emit when effective value is unchanged", async () => { + mockAccountSubject.next({ id: mockUserId } as Account); + mockAuthStatusSubject.next(AuthenticationStatus.Unlocked); + + const policies = [ + { + type: PolicyType.AutotypeDefaultSetting, + enabled: true, + } as Policy, + ]; + + mockPoliciesSubject.next(policies); + const first = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(first).toBe(true); + + let emissionCount = 0; + const subscription = service.autotypeDefaultSetting$.subscribe(() => { + emissionCount++; + }); + + mockPoliciesSubject.next(policies); + + await new Promise((resolve) => setTimeout(resolve, 50)); + subscription.unsubscribe(); + + expect(emissionCount).toBe(1); + }); + + it("does not emit policy values while locked; emits after unlocking", async () => { + mockAuthStatusSubject.next(AuthenticationStatus.Locked); + mockPoliciesSubject.next([ + { type: PolicyType.AutotypeDefaultSetting, enabled: true } as Policy, + ]); + + expect(await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1)))).toBeNull(); + + mockAuthStatusSubject.next(AuthenticationStatus.Unlocked); + expect(await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1)))).toBe(true); + }); + + it("emits correctly if auth unlocks before policies arrive", async () => { + mockAccountSubject.next({ id: mockUserId } as Account); + mockAuthStatusSubject.next(AuthenticationStatus.Unlocked); + mockPoliciesSubject.next([ + { + type: PolicyType.AutotypeDefaultSetting, + enabled: true, + } as Policy, + ]); + + const result = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(result).toBe(true); + }); + + it("wires dependencies with initial user id", async () => { + mockPoliciesSubject.next([ + { type: PolicyType.AutotypeDefaultSetting, enabled: true } as Policy, + ]); + await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + + expect(authService.authStatusFor$).toHaveBeenCalledWith(mockUserId); + expect(policyService.policies$).toHaveBeenCalledWith(mockUserId); + }); }); }); diff --git a/apps/desktop/src/autofill/services/desktop-autotype-policy.service.ts b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.ts index 887a30ef6f6..d3ae67d2c8d 100644 --- a/apps/desktop/src/autofill/services/desktop-autotype-policy.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.ts @@ -34,7 +34,7 @@ export class DesktopAutotypeDefaultSettingPolicy { } return this.accountService.activeAccount$.pipe( - filter((account) => account != null), + filter((account) => account != null && account.id != null), getUserId, distinctUntilChanged(), switchMap((userId) => { @@ -43,13 +43,16 @@ export class DesktopAutotypeDefaultSettingPolicy { distinctUntilChanged(), ); - const policy$ = this.policyService - .policyAppliesToUser$(PolicyType.AutotypeDefaultSetting, userId) - .pipe( - map((appliesToUser) => (appliesToUser ? true : null)), - distinctUntilChanged(), - shareReplay({ bufferSize: 1, refCount: true }), - ); + const policy$ = this.policyService.policies$(userId).pipe( + map((policies) => { + const autotypePolicy = policies.find( + (policy) => policy.type === PolicyType.AutotypeDefaultSetting && policy.enabled, + ); + return autotypePolicy ? true : null; + }), + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: true }), + ); return isUnlocked$.pipe(switchMap((unlocked) => (unlocked ? policy$ : of(null)))); }), diff --git a/apps/desktop/src/autofill/services/desktop-autotype.service.spec.ts b/apps/desktop/src/autofill/services/desktop-autotype.service.spec.ts new file mode 100644 index 00000000000..30cc800dd28 --- /dev/null +++ b/apps/desktop/src/autofill/services/desktop-autotype.service.spec.ts @@ -0,0 +1,50 @@ +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { getAutotypeVaultData } from "./desktop-autotype.service"; + +describe("getAutotypeVaultData", () => { + it("should return vault data when cipher has username and password", () => { + const cipherView = new CipherView(); + cipherView.login.username = "foo"; + cipherView.login.password = "bar"; + + const [error, vaultData] = getAutotypeVaultData(cipherView); + + expect(error).toBeNull(); + expect(vaultData?.username).toEqual("foo"); + expect(vaultData?.password).toEqual("bar"); + }); + + it("should return error when firstCipher is undefined", () => { + const cipherView = undefined; + const [error, vaultData] = getAutotypeVaultData(cipherView); + + expect(vaultData).toBeNull(); + expect(error).toBeDefined(); + expect(error?.message).toEqual("No matching vault item."); + }); + + it("should return error when username is undefined", () => { + const cipherView = new CipherView(); + cipherView.login.username = undefined; + cipherView.login.password = "bar"; + + const [error, vaultData] = getAutotypeVaultData(cipherView); + + expect(vaultData).toBeNull(); + expect(error).toBeDefined(); + expect(error?.message).toEqual("Vault item is undefined."); + }); + + it("should return error when password is undefined", () => { + const cipherView = new CipherView(); + cipherView.login.username = "foo"; + cipherView.login.password = undefined; + + const [error, vaultData] = getAutotypeVaultData(cipherView); + + expect(vaultData).toBeNull(); + expect(error).toBeDefined(); + expect(error?.message).toEqual("Vault item is undefined."); + }); +}); diff --git a/apps/desktop/src/autofill/services/desktop-autotype.service.ts b/apps/desktop/src/autofill/services/desktop-autotype.service.ts index 34f70be64cb..7ee889e7b81 100644 --- a/apps/desktop/src/autofill/services/desktop-autotype.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autotype.service.ts @@ -1,6 +1,6 @@ import { combineLatest, filter, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; @@ -17,6 +17,8 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { UserId } from "@bitwarden/user-core"; +import { AutotypeVaultData } from "../models/autotype-vault-data"; + import { DesktopAutotypeDefaultSettingPolicy } from "./desktop-autotype-policy.service"; export const defaultWindowsAutotypeKeyboardShortcut: string[] = ["Control", "Shift", "B"]; @@ -27,6 +29,8 @@ export const AUTOTYPE_ENABLED = new KeyDefinition( { deserializer: (b) => b }, ); +export type Result = [E, null] | [null, T]; + /* Valid windows shortcut keys: Control, Alt, Super, Shift, letters A - Z Valid macOS shortcut keys: Control, Alt, Command, Shift, letters A - Z @@ -63,17 +67,16 @@ export class DesktopAutotypeService { ipc.autofill.listenAutotypeRequest(async (windowTitle, callback) => { const possibleCiphers = await this.matchCiphersToWindowTitle(windowTitle); const firstCipher = possibleCiphers?.at(0); - - return callback(null, { - username: firstCipher?.login?.username, - password: firstCipher?.login?.password, - }); + const [error, vaultData] = getAutotypeVaultData(firstCipher); + callback(error, vaultData); }); } async init() { this.autotypeEnabledUserSetting$ = this.autotypeEnabledState.state$; - this.autotypeKeyboardShortcut$ = this.autotypeKeyboardShortcut.state$; + this.autotypeKeyboardShortcut$ = this.autotypeKeyboardShortcut.state$.pipe( + map((shortcut) => shortcut ?? defaultWindowsAutotypeKeyboardShortcut), + ); // Currently Autotype is only supported for Windows if (this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop) { @@ -109,9 +112,9 @@ export class DesktopAutotypeService { switchMap((userId) => this.authService.authStatusFor$(userId)), ), this.accountService.activeAccount$.pipe( - map((activeAccount) => activeAccount?.id), - switchMap((userId) => - this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId), + filter((account): account is Account => !!account), + switchMap((account) => + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), ), ), ]).pipe( @@ -174,3 +177,23 @@ export class DesktopAutotypeService { return possibleCiphers; } } + +/** + * @return an `AutotypeVaultData` object or an `Error` if the + * cipher or vault data within are undefined. + */ +export function getAutotypeVaultData( + cipherView: CipherView | undefined, +): Result { + if (!cipherView) { + return [Error("No matching vault item."), null]; + } else if (cipherView.login.username === undefined || cipherView.login.password === undefined) { + return [Error("Vault item is undefined."), null]; + } else { + const vaultData: AutotypeVaultData = { + username: cipherView.login.username, + password: cipherView.login.password, + }; + return [null, vaultData]; + } +} diff --git a/apps/desktop/src/billing/app/accounts/premium.component.ts b/apps/desktop/src/billing/app/accounts/premium.component.ts index 5d0fa7a5dde..637969c1a21 100644 --- a/apps/desktop/src/billing/app/accounts/premium.component.ts +++ b/apps/desktop/src/billing/app/accounts/premium.component.ts @@ -10,6 +10,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components"; +// 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-premium", templateUrl: "premium.component.html", diff --git a/apps/desktop/src/images/loading.svg b/apps/desktop/src/images/loading.svg index 5f4102a5921..e05a42f6c70 100644 --- a/apps/desktop/src/images/loading.svg +++ b/apps/desktop/src/images/loading.svg @@ -1,5 +1,5 @@  - Loading... diff --git a/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts b/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts index 97e1d322a0e..59d9dcce7fe 100644 --- a/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts @@ -13,4 +13,9 @@ export abstract class DesktopBiometricsService extends BiometricsService { ): Promise; abstract deleteBiometricUnlockKeyForUser(userId: UserId): Promise; abstract setupBiometrics(): Promise; + abstract enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise; + abstract hasPersistentKey(userId: UserId): Promise; + /* Enables the v2 biometrics re-write. This will stay enabled until the application is restarted. */ + abstract enableLinuxV2Biometrics(): Promise; + abstract isLinuxV2BiometricsEnabled(): Promise; } diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts b/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts index d4ce01f53f4..e30e4af3bac 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts @@ -51,6 +51,17 @@ export class MainBiometricsIPCListener { return await this.biometricService.setShouldAutopromptNow(message.data as boolean); case BiometricAction.GetShouldAutoprompt: return await this.biometricService.getShouldAutopromptNow(); + case BiometricAction.HasPersistentKey: + return await this.biometricService.hasPersistentKey(message.userId as UserId); + case BiometricAction.EnrollPersistent: + return await this.biometricService.enrollPersistent( + message.userId as UserId, + SymmetricCryptoKey.fromString(message.key as string), + ); + case BiometricAction.EnableLinuxV2: + return await this.biometricService.enableLinuxV2Biometrics(); + case BiometricAction.IsLinuxV2Enabled: + return await this.biometricService.isLinuxV2BiometricsEnabled(); default: return; } diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts b/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts index bc57a7e55fb..0cbe032aa76 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts @@ -7,6 +7,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { EncryptionType } from "@bitwarden/common/platform/enums"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; +import { newGuid } from "@bitwarden/guid"; import { BiometricsService, BiometricsStatus, @@ -16,9 +17,9 @@ import { import { WindowMain } from "../../main/window.main"; import { MainBiometricsService } from "./main-biometrics.service"; +import { WindowsBiometricsSystem } from "./native-v2"; import OsBiometricsServiceLinux from "./os-biometrics-linux.service"; import OsBiometricsServiceMac from "./os-biometrics-mac.service"; -import OsBiometricsServiceWindows from "./os-biometrics-windows.service"; import { OsBiometricService } from "./os-biometrics.service"; jest.mock("@bitwarden/desktop-napi", () => { @@ -28,6 +29,13 @@ jest.mock("@bitwarden/desktop-napi", () => { }; }); +jest.mock("./native-v2", () => ({ + WindowsBiometricsSystem: jest.fn(), + biometrics_v2: { + initBiometricSystem: jest.fn(), + }, +})); + const unlockKey = new SymmetricCryptoKey(new Uint8Array(64)); describe("MainBiometricsService", function () { @@ -38,24 +46,6 @@ describe("MainBiometricsService", function () { const cryptoFunctionService = mock(); const encryptService = mock(); - it("Should call the platformspecific methods", async () => { - const sut = new MainBiometricsService( - i18nService, - windowMain, - logService, - process.platform, - biometricStateService, - encryptService, - cryptoFunctionService, - ); - - const mockService = mock(); - (sut as any).osBiometricsService = mockService; - - await sut.authenticateBiometric(); - expect(mockService.authenticateBiometric).toBeCalled(); - }); - describe("Should create a platform specific service", function () { it("Should create a biometrics service specific for Windows", () => { const sut = new MainBiometricsService( @@ -70,7 +60,7 @@ describe("MainBiometricsService", function () { const internalService = (sut as any).osBiometricsService; expect(internalService).not.toBeNull(); - expect(internalService).toBeInstanceOf(OsBiometricsServiceWindows); + expect(internalService).toBeInstanceOf(WindowsBiometricsSystem); }); it("Should create a biometrics service specific for MacOs", () => { @@ -207,46 +197,6 @@ describe("MainBiometricsService", function () { }); }); - describe("setupBiometrics", () => { - it("should call the platform specific setup method", async () => { - const sut = new MainBiometricsService( - i18nService, - windowMain, - logService, - process.platform, - biometricStateService, - encryptService, - cryptoFunctionService, - ); - const osBiometricsService = mock(); - (sut as any).osBiometricsService = osBiometricsService; - - await sut.setupBiometrics(); - - expect(osBiometricsService.runSetup).toHaveBeenCalled(); - }); - }); - - describe("authenticateWithBiometrics", () => { - it("should call the platform specific authenticate method", async () => { - const sut = new MainBiometricsService( - i18nService, - windowMain, - logService, - process.platform, - biometricStateService, - encryptService, - cryptoFunctionService, - ); - const osBiometricsService = mock(); - (sut as any).osBiometricsService = osBiometricsService; - - await sut.authenticateWithBiometrics(); - - expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled(); - }); - }); - describe("unlockWithBiometricsForUser", () => { let sut: MainBiometricsService; let osBiometricsService: MockProxy; @@ -288,55 +238,6 @@ describe("MainBiometricsService", function () { }); }); - describe("setBiometricProtectedUnlockKeyForUser", () => { - let sut: MainBiometricsService; - let osBiometricsService: MockProxy; - - beforeEach(() => { - sut = new MainBiometricsService( - i18nService, - windowMain, - logService, - process.platform, - biometricStateService, - encryptService, - cryptoFunctionService, - ); - osBiometricsService = mock(); - (sut as any).osBiometricsService = osBiometricsService; - }); - - it("should call the platform specific setBiometricKey method", async () => { - const userId = "test" as UserId; - - await sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey); - - expect(osBiometricsService.setBiometricKey).toHaveBeenCalledWith(userId, unlockKey); - }); - }); - - describe("deleteBiometricUnlockKeyForUser", () => { - it("should call the platform specific deleteBiometricKey method", async () => { - const sut = new MainBiometricsService( - i18nService, - windowMain, - logService, - process.platform, - biometricStateService, - encryptService, - cryptoFunctionService, - ); - const osBiometricsService = mock(); - (sut as any).osBiometricsService = osBiometricsService; - - const userId = "test" as UserId; - - await sut.deleteBiometricUnlockKeyForUser(userId); - - expect(osBiometricsService.deleteBiometricKey).toHaveBeenCalledWith(userId); - }); - }); - describe("setShouldAutopromptNow", () => { let sut: MainBiometricsService; @@ -386,4 +287,66 @@ describe("MainBiometricsService", function () { expect(shouldAutoPrompt).toBe(true); }); }); + + describe("pass through methods that call platform specific osBiometricsService methods", () => { + const userId = newGuid() as UserId; + let sut: MainBiometricsService; + let osBiometricsService: MockProxy; + + beforeEach(() => { + sut = new MainBiometricsService( + i18nService, + windowMain, + logService, + process.platform, + biometricStateService, + encryptService, + cryptoFunctionService, + ); + osBiometricsService = mock(); + (sut as any).osBiometricsService = osBiometricsService; + }); + + it("calls the platform specific setBiometricKey method", async () => { + await sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey); + + expect(osBiometricsService.setBiometricKey).toHaveBeenCalledWith(userId, unlockKey); + }); + + it("calls the platform specific enrollPersistent method", async () => { + await sut.enrollPersistent(userId, unlockKey); + + expect(osBiometricsService.enrollPersistent).toHaveBeenCalledWith(userId, unlockKey); + }); + + it("calls the platform specific hasPersistentKey method", async () => { + await sut.hasPersistentKey(userId); + + expect(osBiometricsService.hasPersistentKey).toHaveBeenCalledWith(userId); + }); + + it("calls the platform specific deleteBiometricUnlockKeyForUser method", async () => { + await sut.deleteBiometricUnlockKeyForUser(userId); + + expect(osBiometricsService.deleteBiometricKey).toHaveBeenCalledWith(userId); + }); + + it("calls the platform specific authenticateWithBiometrics method", async () => { + await sut.authenticateWithBiometrics(); + + expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled(); + }); + + it("calls the platform specific authenticateBiometric method", async () => { + await sut.authenticateBiometric(); + + expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled(); + }); + + it("calls the platform specific setupBiometrics method", async () => { + await sut.setupBiometrics(); + + expect(osBiometricsService.runSetup).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts index 1de8e3cd12d..ad9ec62afcc 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts @@ -10,32 +10,29 @@ import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-manageme import { WindowMain } from "../../main/window.main"; import { DesktopBiometricsService } from "./desktop.biometrics.service"; +import { LinuxBiometricsSystem, WindowsBiometricsSystem } from "./native-v2"; import { OsBiometricService } from "./os-biometrics.service"; export class MainBiometricsService extends DesktopBiometricsService { private osBiometricsService: OsBiometricService; private shouldAutoPrompt = true; + private linuxV2BiometricsEnabled = false; constructor( private i18nService: I18nService, private windowMain: WindowMain, private logService: LogService, - platform: NodeJS.Platform, + private platform: NodeJS.Platform, private biometricStateService: BiometricStateService, private encryptService: EncryptService, private cryptoFunctionService: CryptoFunctionService, ) { super(); if (platform === "win32") { - // eslint-disable-next-line - const OsBiometricsServiceWindows = require("./os-biometrics-windows.service").default; - this.osBiometricsService = new OsBiometricsServiceWindows( + this.osBiometricsService = new WindowsBiometricsSystem( this.i18nService, this.windowMain, this.logService, - this.biometricStateService, - this.encryptService, - this.cryptoFunctionService, ); } else if (platform === "darwin") { // eslint-disable-next-line @@ -144,4 +141,24 @@ export class MainBiometricsService extends DesktopBiometricsService { async canEnableBiometricUnlock(): Promise { return true; } + + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise { + return await this.osBiometricsService.enrollPersistent(userId, key); + } + + async hasPersistentKey(userId: UserId): Promise { + return await this.osBiometricsService.hasPersistentKey(userId); + } + + async enableLinuxV2Biometrics(): Promise { + if (this.platform === "linux" && !this.linuxV2BiometricsEnabled) { + this.logService.info("[BiometricsMain] Loading native biometrics module v2 for linux"); + this.osBiometricsService = new LinuxBiometricsSystem(); + this.linuxV2BiometricsEnabled = true; + } + } + + async isLinuxV2BiometricsEnabled(): Promise { + return this.linuxV2BiometricsEnabled; + } } diff --git a/apps/desktop/src/key-management/biometrics/native-v2/index.ts b/apps/desktop/src/key-management/biometrics/native-v2/index.ts new file mode 100644 index 00000000000..94de850b759 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v2/index.ts @@ -0,0 +1,2 @@ +export { default as WindowsBiometricsSystem } from "./os-biometrics-windows.service"; +export { default as LinuxBiometricsSystem } from "./os-biometrics-linux.service"; diff --git a/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-linux.service.spec.ts b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-linux.service.spec.ts new file mode 100644 index 00000000000..91e2caba0cb --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-linux.service.spec.ts @@ -0,0 +1,96 @@ +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; +import { biometrics_v2, passwords } from "@bitwarden/desktop-napi"; +import { BiometricsStatus } from "@bitwarden/key-management"; + +import OsBiometricsServiceLinux from "./os-biometrics-linux.service"; + +jest.mock("@bitwarden/desktop-napi", () => ({ + biometrics_v2: { + initBiometricSystem: jest.fn(() => "mockSystem"), + provideKey: jest.fn(), + unenroll: jest.fn(), + unlock: jest.fn(), + authenticate: jest.fn(), + authenticateAvailable: jest.fn(), + unlockAvailable: jest.fn(), + }, + passwords: { + isAvailable: jest.fn(), + }, +})); + +const mockKey = new Uint8Array(64); + +jest.mock("../../../utils", () => ({ + isFlatpak: jest.fn(() => false), + isLinux: jest.fn(() => true), + isSnapStore: jest.fn(() => false), +})); + +describe("OsBiometricsServiceLinux", () => { + const userId = "user-id" as UserId; + const key = { toEncoded: () => ({ buffer: Buffer.from(mockKey) }) } as SymmetricCryptoKey; + let service: OsBiometricsServiceLinux; + + beforeEach(() => { + service = new OsBiometricsServiceLinux(); + jest.clearAllMocks(); + }); + + it("should set biometric key", async () => { + await service.setBiometricKey(userId, key); + expect(biometrics_v2.provideKey).toHaveBeenCalled(); + }); + + it("should delete biometric key", async () => { + await service.deleteBiometricKey(userId); + expect(biometrics_v2.unenroll).toHaveBeenCalled(); + }); + + it("should get biometric key", async () => { + (biometrics_v2.unlock as jest.Mock).mockResolvedValue(mockKey); + const result = await service.getBiometricKey(userId); + expect(result).toBeInstanceOf(SymmetricCryptoKey); + }); + + it("should return null if no biometric key", async () => { + (biometrics_v2.unlock as jest.Mock).mockResolvedValue(null); + const result = await service.getBiometricKey(userId); + expect(result).toBeNull(); + }); + + it("should authenticate biometric", async () => { + (biometrics_v2.authenticate as jest.Mock).mockResolvedValue(true); + const result = await service.authenticateBiometric(); + expect(result).toBe(true); + }); + + it("should check if biometrics is supported", async () => { + (passwords.isAvailable as jest.Mock).mockResolvedValue(true); + const result = await service.supportsBiometrics(); + expect(result).toBe(true); + }); + + it("should check if setup is needed", async () => { + (biometrics_v2.authenticateAvailable as jest.Mock).mockResolvedValue(false); + const result = await service.needsSetup(); + expect(result).toBe(true); + }); + + it("should check if can auto setup", async () => { + const result = await service.canAutoSetup(); + expect(result).toBe(true); + }); + + it("should get biometrics first unlock status for user", async () => { + (biometrics_v2.unlockAvailable as jest.Mock).mockResolvedValue(true); + const result = await service.getBiometricsFirstUnlockStatusForUser(userId); + expect(result).toBe(BiometricsStatus.Available); + }); + + it("should return false for hasPersistentKey", async () => { + const result = await service.hasPersistentKey(userId); + expect(result).toBe(false); + }); +}); diff --git a/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-linux.service.ts b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-linux.service.ts new file mode 100644 index 00000000000..110db23ec79 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-linux.service.ts @@ -0,0 +1,118 @@ +import { spawn } from "child_process"; + +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; +import { biometrics_v2, passwords } from "@bitwarden/desktop-napi"; +import { BiometricsStatus } from "@bitwarden/key-management"; + +import { isSnapStore, isFlatpak, isLinux } from "../../../utils"; +import { OsBiometricService } from "../os-biometrics.service"; + +const polkitPolicy = ` + + + + + Unlock Bitwarden + Authenticate to unlock Bitwarden + + no + no + auth_self + + +`; +const policyFileName = "com.bitwarden.Bitwarden.policy"; +const policyPath = "/usr/share/polkit-1/actions/"; + +export default class OsBiometricsServiceLinux implements OsBiometricService { + private biometricsSystem: biometrics_v2.BiometricLockSystem; + + constructor() { + this.biometricsSystem = biometrics_v2.initBiometricSystem(); + } + + async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise { + await biometrics_v2.provideKey( + this.biometricsSystem, + userId, + Buffer.from(key.toEncoded().buffer), + ); + } + + async deleteBiometricKey(userId: UserId): Promise { + await biometrics_v2.unenroll(this.biometricsSystem, userId); + } + + async getBiometricKey(userId: UserId): Promise { + const result = await biometrics_v2.unlock(this.biometricsSystem, userId, Buffer.from("")); + return result ? new SymmetricCryptoKey(Uint8Array.from(result)) : null; + } + + async authenticateBiometric(): Promise { + return await biometrics_v2.authenticate( + this.biometricsSystem, + Buffer.from(""), + "Authenticate to unlock", + ); + } + + async supportsBiometrics(): Promise { + // We assume all linux distros have some polkit implementation + // that either has bitwarden set up or not, which is reflected in osBiomtricsNeedsSetup. + // Snap does not have access at the moment to polkit + // This could be dynamically detected on dbus in the future. + // We should check if a libsecret implementation is available on the system + // because otherwise we cannot offlod the protected userkey to secure storage. + return await passwords.isAvailable(); + } + + async needsSetup(): Promise { + if (isSnapStore()) { + return false; + } + + // check whether the polkit policy is loaded via dbus call to polkit + return !(await biometrics_v2.authenticateAvailable(this.biometricsSystem)); + } + + async canAutoSetup(): Promise { + // We cannot auto setup on snap or flatpak since the filesystem is sandboxed. + // The user needs to manually set up the polkit policy outside of the sandbox + // since we allow access to polkit via dbus for the sandboxed clients, the authentication works from + // the sandbox, once the policy is set up outside of the sandbox. + return isLinux() && !isSnapStore() && !isFlatpak(); + } + + async runSetup(): Promise { + const process = spawn("pkexec", [ + "bash", + "-c", + `echo '${polkitPolicy}' > ${policyPath + policyFileName} && chown root:root ${policyPath + policyFileName} && chcon system_u:object_r:usr_t:s0 ${policyPath + policyFileName}`, + ]); + + await new Promise((resolve, reject) => { + process.on("close", (code) => { + if (code !== 0) { + reject("Failed to set up polkit policy"); + } else { + resolve(null); + } + }); + }); + } + + async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise { + return (await biometrics_v2.unlockAvailable(this.biometricsSystem, userId)) + ? BiometricsStatus.Available + : BiometricsStatus.UnlockNeeded; + } + + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise {} + + async hasPersistentKey(userId: UserId): Promise { + return false; + } +} diff --git a/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.spec.ts b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.spec.ts new file mode 100644 index 00000000000..28b05c490b0 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.spec.ts @@ -0,0 +1,126 @@ +import { mock } from "jest-mock-extended"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; +import { biometrics_v2 } from "@bitwarden/desktop-napi"; +import { BiometricsStatus } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; + +import { WindowMain } from "../../main/window.main"; + +import OsBiometricsServiceWindows from "./os-biometrics-windows.service"; + +jest.mock("@bitwarden/desktop-napi", () => ({ + biometrics_v2: { + initBiometricSystem: jest.fn(() => "mockSystem"), + provideKey: jest.fn(), + enrollPersistent: jest.fn(), + unenroll: jest.fn(), + unlock: jest.fn(), + authenticate: jest.fn(), + authenticateAvailable: jest.fn(), + unlockAvailable: jest.fn(), + hasPersistent: jest.fn(), + }, + passwords: { + isAvailable: jest.fn(), + }, +})); + +const mockKey = new Uint8Array(64); + +jest.mock("../../../utils", () => ({ + isFlatpak: jest.fn(() => false), + isLinux: jest.fn(() => true), + isSnapStore: jest.fn(() => false), +})); + +describe("OsBiometricsServiceWindows", () => { + const userId = "user-id" as UserId; + + let service: OsBiometricsServiceWindows; + let i18nService: I18nService; + let windowMain: WindowMain; + let logService: LogService; + + beforeEach(() => { + i18nService = mock(); + windowMain = mock(); + logService = mock(); + + windowMain.win.getNativeWindowHandle = jest.fn().mockReturnValue(Buffer.from([1, 2, 3, 4])); + service = new OsBiometricsServiceWindows(i18nService, windowMain, logService); + }); + + it("should enroll persistent biometric key", async () => { + await service.enrollPersistent("user-id" as UserId, new SymmetricCryptoKey(mockKey)); + expect(biometrics_v2.enrollPersistent).toHaveBeenCalled(); + }); + + it("should set biometric key", async () => { + await service.setBiometricKey(userId, new SymmetricCryptoKey(mockKey)); + expect(biometrics_v2.provideKey).toHaveBeenCalled(); + }); + + it("should delete biometric key", async () => { + await service.deleteBiometricKey(userId); + expect(biometrics_v2.unenroll).toHaveBeenCalled(); + }); + + it("should get biometric key", async () => { + (biometrics_v2.unlock as jest.Mock).mockResolvedValue(mockKey); + const result = await service.getBiometricKey(userId); + expect(result).toBeInstanceOf(SymmetricCryptoKey); + }); + + it("should return null if no biometric key", async () => { + const error = new Error("No key found"); + (biometrics_v2.unlock as jest.Mock).mockRejectedValue(error); + const result = await service.getBiometricKey(userId); + expect(result).toBeNull(); + expect(logService.warning).toHaveBeenCalledWith( + `[OsBiometricsServiceWindows] Fetching the biometric key failed: ${error} returning null`, + ); + }); + + it("should authenticate biometric", async () => { + (biometrics_v2.authenticate as jest.Mock).mockResolvedValue(true); + const result = await service.authenticateBiometric(); + expect(result).toBe(true); + }); + + it("should check if biometrics is supported", async () => { + (biometrics_v2.authenticateAvailable as jest.Mock).mockResolvedValue(true); + const result = await service.supportsBiometrics(); + expect(result).toBe(true); + }); + + it("should return needs setup false", async () => { + const result = await service.needsSetup(); + expect(result).toBe(false); + }); + + it("should return auto setup false", async () => { + const result = await service.canAutoSetup(); + expect(result).toBe(false); + }); + + it("should get biometrics first unlock status for user", async () => { + (biometrics_v2.unlockAvailable as jest.Mock).mockResolvedValue(true); + const result = await service.getBiometricsFirstUnlockStatusForUser(userId); + expect(result).toBe(BiometricsStatus.Available); + }); + + it("should return false for hasPersistentKey false", async () => { + (biometrics_v2.hasPersistent as jest.Mock).mockResolvedValue(false); + const result = await service.hasPersistentKey(userId); + expect(result).toBe(false); + }); + + it("should return false for hasPersistentKey true", async () => { + (biometrics_v2.hasPersistent as jest.Mock).mockResolvedValue(true); + const result = await service.hasPersistentKey(userId); + expect(result).toBe(true); + }); +}); diff --git a/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.ts b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.ts new file mode 100644 index 00000000000..4d9794daa74 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.ts @@ -0,0 +1,91 @@ +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; +import { biometrics_v2 } from "@bitwarden/desktop-napi"; +import { BiometricsStatus } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; + +import { WindowMain } from "../../../main/window.main"; +import { OsBiometricService } from "../os-biometrics.service"; + +export default class OsBiometricsServiceWindows implements OsBiometricService { + private biometricsSystem: biometrics_v2.BiometricLockSystem; + + constructor( + private i18nService: I18nService, + private windowMain: WindowMain, + private logService: LogService, + ) { + this.biometricsSystem = biometrics_v2.initBiometricSystem(); + } + + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise { + await biometrics_v2.enrollPersistent( + this.biometricsSystem, + userId, + Buffer.from(key.toEncoded().buffer), + ); + } + + async hasPersistentKey(userId: UserId): Promise { + return await biometrics_v2.hasPersistent(this.biometricsSystem, userId); + } + + async supportsBiometrics(): Promise { + return await biometrics_v2.authenticateAvailable(this.biometricsSystem); + } + + async getBiometricKey(userId: UserId): Promise { + try { + const key = await biometrics_v2.unlock( + this.biometricsSystem, + userId, + this.windowMain.win.getNativeWindowHandle(), + ); + return key ? new SymmetricCryptoKey(Uint8Array.from(key)) : null; + } catch (error) { + this.logService.warning( + `[OsBiometricsServiceWindows] Fetching the biometric key failed: ${error} returning null`, + ); + return null; + } + } + + async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise { + await biometrics_v2.provideKey( + this.biometricsSystem, + userId, + Buffer.from(key.toEncoded().buffer), + ); + } + + async deleteBiometricKey(userId: UserId): Promise { + await biometrics_v2.unenroll(this.biometricsSystem, userId); + } + + async authenticateBiometric(): Promise { + const hwnd = this.windowMain.win.getNativeWindowHandle(); + return await biometrics_v2.authenticate( + this.biometricsSystem, + hwnd, + this.i18nService.t("windowsHelloConsentMessage"), + ); + } + + async needsSetup() { + return false; + } + + async canAutoSetup(): Promise { + return false; + } + + async runSetup(): Promise {} + + async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise { + return (await biometrics_v2.hasPersistent(this.biometricsSystem, userId)) || + (await biometrics_v2.unlockAvailable(this.biometricsSystem, userId)) + ? BiometricsStatus.Available + : BiometricsStatus.UnlockNeeded; + } +} diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts index 0ef3033b4c5..400918a69bb 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts @@ -47,6 +47,12 @@ export default class OsBiometricsServiceLinux implements OsBiometricService { private logService: LogService, ) {} + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise {} + + async hasPersistentKey(userId: UserId): Promise { + return false; + } + private _iv: string | null = null; // Use getKeyMaterial helper instead of direct access private _osKeyHalf: string | null = null; diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts index 1dc64f1bcd5..87d63971750 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts @@ -20,6 +20,14 @@ export default class OsBiometricsServiceMac implements OsBiometricService { private logService: LogService, ) {} + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise { + return await passwords.setPassword(SERVICE, getLookupKeyForUser(userId), key.toBase64()); + } + + async hasPersistentKey(userId: UserId): Promise { + return (await passwords.getPassword(SERVICE, getLookupKeyForUser(userId))) != null; + } + async supportsBiometrics(): Promise { return systemPreferences.canPromptTouchID(); } diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.spec.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.spec.ts deleted file mode 100644 index f301efc70e7..00000000000 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.spec.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { randomBytes } from "node:crypto"; - -import { BrowserWindow } from "electron"; -import { mock } from "jest-mock-extended"; - -import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { UserId } from "@bitwarden/common/types/guid"; -import { biometrics, passwords } from "@bitwarden/desktop-napi"; -import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management"; - -import { WindowMain } from "../../main/window.main"; - -import OsBiometricsServiceWindows from "./os-biometrics-windows.service"; - -import OsDerivedKey = biometrics.OsDerivedKey; - -jest.mock("@bitwarden/desktop-napi", () => { - return { - biometrics: { - available: jest.fn().mockResolvedValue(true), - getBiometricSecret: jest.fn().mockResolvedValue(""), - setBiometricSecret: jest.fn().mockResolvedValue(""), - deleteBiometricSecret: jest.fn(), - deriveKeyMaterial: jest.fn().mockResolvedValue({ - keyB64: "", - ivB64: "", - }), - prompt: jest.fn().mockResolvedValue(true), - }, - passwords: { - getPassword: jest.fn().mockResolvedValue(null), - deletePassword: jest.fn().mockImplementation(() => {}), - isAvailable: jest.fn(), - PASSWORD_NOT_FOUND: "Password not found", - }, - }; -}); - -describe("OsBiometricsServiceWindows", function () { - const i18nService = mock(); - const windowMain = mock(); - const browserWindow = mock(); - const encryptionService: EncryptService = mock(); - const cryptoFunctionService: CryptoFunctionService = mock(); - const biometricStateService: BiometricStateService = mock(); - const logService = mock(); - - let service: OsBiometricsServiceWindows; - - const key = new SymmetricCryptoKey(new Uint8Array(64)); - const userId = "test-user-id" as UserId; - const serviceKey = "Bitwarden_biometric"; - const storageKey = `${userId}_user_biometric`; - - beforeEach(() => { - windowMain.win = browserWindow; - - service = new OsBiometricsServiceWindows( - i18nService, - windowMain, - logService, - biometricStateService, - encryptionService, - cryptoFunctionService, - ); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe("getBiometricsFirstUnlockStatusForUser", () => { - const userId = "test-user-id" as UserId; - it("should return Available when client key half is set", async () => { - (service as any).clientKeyHalves = new Map(); - (service as any).clientKeyHalves.set(userId, new Uint8Array([1, 2, 3, 4])); - const result = await service.getBiometricsFirstUnlockStatusForUser(userId); - expect(result).toBe(BiometricsStatus.Available); - }); - it("should return UnlockNeeded when client key half is not set", async () => { - (service as any).clientKeyHalves = new Map(); - const result = await service.getBiometricsFirstUnlockStatusForUser(userId); - expect(result).toBe(BiometricsStatus.UnlockNeeded); - }); - }); - - describe("getOrCreateBiometricEncryptionClientKeyHalf", () => { - it("should return cached key half if already present", async () => { - const cachedKeyHalf = new Uint8Array([10, 20, 30]); - (service as any).clientKeyHalves.set(userId.toString(), cachedKeyHalf); - const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); - expect(result).toBe(cachedKeyHalf); - }); - - it("should decrypt and return existing encrypted client key half", async () => { - biometricStateService.getEncryptedClientKeyHalf = jest - .fn() - .mockResolvedValue(new Uint8Array([1, 2, 3])); - const decrypted = new Uint8Array([4, 5, 6]); - encryptionService.decryptBytes = jest.fn().mockResolvedValue(decrypted); - - const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); - - expect(biometricStateService.getEncryptedClientKeyHalf).toHaveBeenCalledWith(userId); - expect(encryptionService.decryptBytes).toHaveBeenCalledWith(new Uint8Array([1, 2, 3]), key); - expect(result).toEqual(decrypted); - expect((service as any).clientKeyHalves.get(userId.toString())).toEqual(decrypted); - }); - - it("should generate, encrypt, store, and cache a new key half if none exists", async () => { - biometricStateService.getEncryptedClientKeyHalf = jest.fn().mockResolvedValue(null); - const randomBytes = new Uint8Array([7, 8, 9]); - cryptoFunctionService.randomBytes = jest.fn().mockResolvedValue(randomBytes); - const encrypted = new Uint8Array([10, 11, 12]); - encryptionService.encryptBytes = jest.fn().mockResolvedValue(encrypted); - biometricStateService.setEncryptedClientKeyHalf = jest.fn().mockResolvedValue(undefined); - - const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); - - expect(cryptoFunctionService.randomBytes).toHaveBeenCalledWith(32); - expect(encryptionService.encryptBytes).toHaveBeenCalledWith(randomBytes, key); - expect(biometricStateService.setEncryptedClientKeyHalf).toHaveBeenCalledWith( - encrypted, - userId, - ); - expect(result).toEqual(randomBytes); - expect((service as any).clientKeyHalves.get(userId.toString())).toEqual(randomBytes); - }); - }); - - describe("supportsBiometrics", () => { - it("should return true if biometrics are available", async () => { - biometrics.available = jest.fn().mockResolvedValue(true); - - const result = await service.supportsBiometrics(); - - expect(result).toBe(true); - }); - - it("should return false if biometrics are not available", async () => { - biometrics.available = jest.fn().mockResolvedValue(false); - - const result = await service.supportsBiometrics(); - - expect(result).toBe(false); - }); - }); - - describe("getBiometricKey", () => { - beforeEach(() => { - biometrics.prompt = jest.fn().mockResolvedValue(true); - }); - - it("should return null when unsuccessfully authenticated biometrics", async () => { - biometrics.prompt = jest.fn().mockResolvedValue(false); - - const result = await service.getBiometricKey(userId); - - expect(result).toBeNull(); - }); - - it.each([null, undefined, ""])( - "should throw error when no biometric key is found '%s'", - async (password) => { - passwords.getPassword = jest.fn().mockResolvedValue(password); - - await expect(service.getBiometricKey(userId)).rejects.toThrow( - "Biometric key not found for user", - ); - - expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey); - }, - ); - - it.each([[false], [true]])( - "should return the biometricKey and setBiometricSecret called if password is not encrypted and cached clientKeyHalves is %s", - async (haveClientKeyHalves) => { - const clientKeyHalveBytes = new Uint8Array([1, 2, 3]); - if (haveClientKeyHalves) { - service["clientKeyHalves"].set(userId, clientKeyHalveBytes); - } - const biometricKey = key.toBase64(); - passwords.getPassword = jest.fn().mockResolvedValue(biometricKey); - biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue({ - keyB64: "testKeyB64", - ivB64: "testIvB64", - } satisfies OsDerivedKey); - - const result = await service.getBiometricKey(userId); - - expect(result.toBase64()).toBe(biometricKey); - expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey); - expect(biometrics.setBiometricSecret).toHaveBeenCalledWith( - serviceKey, - storageKey, - biometricKey, - { - osKeyPartB64: "testKeyB64", - clientKeyPartB64: haveClientKeyHalves - ? Utils.fromBufferToB64(clientKeyHalveBytes) - : undefined, - }, - "testIvB64", - ); - }, - ); - - it.each([[false], [true]])( - "should return the biometricKey if password is encrypted and cached clientKeyHalves is %s", - async (haveClientKeyHalves) => { - const clientKeyHalveBytes = new Uint8Array([1, 2, 3]); - if (haveClientKeyHalves) { - service["clientKeyHalves"].set(userId, clientKeyHalveBytes); - } - const biometricKey = key.toBase64(); - const biometricKeyEncrypted = "2.testId|data|mac"; - passwords.getPassword = jest.fn().mockResolvedValue(biometricKeyEncrypted); - biometrics.getBiometricSecret = jest.fn().mockResolvedValue(biometricKey); - biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue({ - keyB64: "testKeyB64", - ivB64: "testIvB64", - } satisfies OsDerivedKey); - - const result = await service.getBiometricKey(userId); - - expect(result.toBase64()).toBe(biometricKey); - expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey); - expect(biometrics.setBiometricSecret).not.toHaveBeenCalled(); - expect(biometrics.getBiometricSecret).toHaveBeenCalledWith(serviceKey, storageKey, { - osKeyPartB64: "testKeyB64", - clientKeyPartB64: haveClientKeyHalves - ? Utils.fromBufferToB64(clientKeyHalveBytes) - : undefined, - }); - }, - ); - }); - - describe("deleteBiometricKey", () => { - const serviceName = "Bitwarden_biometric"; - const keyName = "test-user-id_user_biometric"; - - it("should delete biometric key successfully", async () => { - await service.deleteBiometricKey(userId); - - expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); - }); - - it.each([[false], [true]])("should not throw error if key found: %s", async (keyFound) => { - if (!keyFound) { - passwords.deletePassword = jest - .fn() - .mockRejectedValue(new Error(passwords.PASSWORD_NOT_FOUND)); - } - - await service.deleteBiometricKey(userId); - - expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); - if (!keyFound) { - expect(logService.debug).toHaveBeenCalledWith( - "[OsBiometricService] Biometric key %s not found for service %s.", - keyName, - serviceName, - ); - } - }); - - it("should throw error when deletePassword for key throws unexpected errors", async () => { - const error = new Error("Unexpected error"); - passwords.deletePassword = jest.fn().mockRejectedValue(error); - - await expect(service.deleteBiometricKey(userId)).rejects.toThrow(error); - - expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); - }); - }); - - describe("authenticateBiometric", () => { - const hwnd = randomBytes(32).buffer; - const consentMessage = "Test Windows Hello Consent Message"; - - beforeEach(() => { - windowMain.win.getNativeWindowHandle = jest.fn().mockReturnValue(hwnd); - i18nService.t.mockReturnValue(consentMessage); - }); - - it("should return true when biometric authentication is successful", async () => { - const result = await service.authenticateBiometric(); - - expect(result).toBe(true); - expect(biometrics.prompt).toHaveBeenCalledWith(hwnd, consentMessage); - }); - - it("should return false when biometric authentication fails", async () => { - biometrics.prompt = jest.fn().mockResolvedValue(false); - - const result = await service.authenticateBiometric(); - - expect(result).toBe(false); - expect(biometrics.prompt).toHaveBeenCalledWith(hwnd, consentMessage); - }); - }); - - describe("getStorageDetails", () => { - it.each([ - ["testClientKeyHalfB64", "testIvB64"], - [undefined, "testIvB64"], - ["testClientKeyHalfB64", null], - [undefined, null], - ])( - "should derive key material and ivB64 and return it when os key half not saved yet", - async (clientKeyHalfB64, ivB64) => { - service["setIv"](ivB64); - - const derivedKeyMaterial = { - keyB64: "derivedKeyB64", - ivB64: "derivedIvB64", - }; - biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue(derivedKeyMaterial); - - const result = await service["getStorageDetails"]({ clientKeyHalfB64 }); - - expect(result).toEqual({ - key_material: { - osKeyPartB64: derivedKeyMaterial.keyB64, - clientKeyPartB64: clientKeyHalfB64, - }, - ivB64: derivedKeyMaterial.ivB64, - }); - expect(biometrics.deriveKeyMaterial).toHaveBeenCalledWith(ivB64); - expect(service["_osKeyHalf"]).toEqual(derivedKeyMaterial.keyB64); - expect(service["_iv"]).toEqual(derivedKeyMaterial.ivB64); - }, - ); - - it("should throw an error when deriving key material and returned iv is null", async () => { - service["setIv"]("testIvB64"); - - const derivedKeyMaterial = { - keyB64: "derivedKeyB64", - ivB64: null as string | undefined | null, - }; - biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue(derivedKeyMaterial); - - await expect( - service["getStorageDetails"]({ clientKeyHalfB64: "testClientKeyHalfB64" }), - ).rejects.toThrow("Initialization Vector is null"); - - expect(biometrics.deriveKeyMaterial).toHaveBeenCalledWith("testIvB64"); - }); - }); - - describe("setIv", () => { - it("should set the iv and reset the osKeyHalf", () => { - const iv = "testIv"; - service["_osKeyHalf"] = "testOsKeyHalf"; - - service["setIv"](iv); - - expect(service["_iv"]).toBe(iv); - expect(service["_osKeyHalf"]).toBeNull(); - }); - - it("should set the iv to null when iv is undefined and reset the osKeyHalf", () => { - service["_osKeyHalf"] = "testOsKeyHalf"; - - service["setIv"](undefined); - - expect(service["_iv"]).toBeNull(); - expect(service["_osKeyHalf"]).toBeNull(); - }); - }); -}); diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts deleted file mode 100644 index 897304c9f61..00000000000 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; -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"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { UserId } from "@bitwarden/common/types/guid"; -import { biometrics, passwords } from "@bitwarden/desktop-napi"; -import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management"; - -import { WindowMain } from "../../main/window.main"; - -import { OsBiometricService } from "./os-biometrics.service"; - -const SERVICE = "Bitwarden_biometric"; - -function getLookupKeyForUser(userId: UserId): string { - return `${userId}_user_biometric`; -} - -export default class OsBiometricsServiceWindows implements OsBiometricService { - // Use set helper method instead of direct access - private _iv: string | null = null; - // Use getKeyMaterial helper instead of direct access - private _osKeyHalf: string | null = null; - private clientKeyHalves = new Map(); - - constructor( - private i18nService: I18nService, - private windowMain: WindowMain, - private logService: LogService, - private biometricStateService: BiometricStateService, - private encryptService: EncryptService, - private cryptoFunctionService: CryptoFunctionService, - ) {} - - async supportsBiometrics(): Promise { - return await biometrics.available(); - } - - async getBiometricKey(userId: UserId): Promise { - const success = await this.authenticateBiometric(); - if (!success) { - return null; - } - - const value = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId)); - if (value == null || value == "") { - throw new Error("Biometric key not found for user"); - } - - let clientKeyHalfB64: string | null = null; - if (this.clientKeyHalves.has(userId)) { - clientKeyHalfB64 = Utils.fromBufferToB64(this.clientKeyHalves.get(userId)!); - } - - if (!EncString.isSerializedEncString(value)) { - // Update to format encrypted with client key half - const storageDetails = await this.getStorageDetails({ - clientKeyHalfB64: clientKeyHalfB64 ?? undefined, - }); - - await biometrics.setBiometricSecret( - SERVICE, - getLookupKeyForUser(userId), - value, - storageDetails.key_material, - storageDetails.ivB64, - ); - return SymmetricCryptoKey.fromString(value); - } else { - const encValue = new EncString(value); - this.setIv(encValue.iv); - const storageDetails = await this.getStorageDetails({ - clientKeyHalfB64: clientKeyHalfB64 ?? undefined, - }); - return SymmetricCryptoKey.fromString( - await biometrics.getBiometricSecret( - SERVICE, - getLookupKeyForUser(userId), - storageDetails.key_material, - ), - ); - } - } - - async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise { - const clientKeyHalf = await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); - - const storageDetails = await this.getStorageDetails({ - clientKeyHalfB64: Utils.fromBufferToB64(clientKeyHalf), - }); - await biometrics.setBiometricSecret( - SERVICE, - getLookupKeyForUser(userId), - key.toBase64(), - storageDetails.key_material, - storageDetails.ivB64, - ); - } - - async deleteBiometricKey(userId: UserId): Promise { - try { - await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId)); - } catch (e) { - if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) { - this.logService.debug( - "[OsBiometricService] Biometric key %s not found for service %s.", - getLookupKeyForUser(userId), - SERVICE, - ); - } else { - throw e; - } - } - } - - /** - * Prompts Windows Hello - */ - async authenticateBiometric(): Promise { - const hwnd = this.windowMain.win.getNativeWindowHandle(); - return await biometrics.prompt(hwnd, this.i18nService.t("windowsHelloConsentMessage")); - } - - private async getStorageDetails({ - clientKeyHalfB64, - }: { - clientKeyHalfB64: string | undefined; - }): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> { - if (this._osKeyHalf == null) { - const keyMaterial = await biometrics.deriveKeyMaterial(this._iv); - this._osKeyHalf = keyMaterial.keyB64; - this._iv = keyMaterial.ivB64; - } - - if (this._iv == null) { - throw new Error("Initialization Vector is null"); - } - - const result = { - key_material: { - osKeyPartB64: this._osKeyHalf, - clientKeyPartB64: clientKeyHalfB64, - }, - ivB64: this._iv, - }; - - // napi-rs fails to convert null values - if (result.key_material.clientKeyPartB64 == null) { - delete result.key_material.clientKeyPartB64; - } - return result; - } - - // Nulls out key material in order to force a re-derive. This should only be used in getBiometricKey - // when we want to force a re-derive of the key material. - private setIv(iv?: string) { - this._iv = iv ?? null; - this._osKeyHalf = null; - } - - async needsSetup() { - return false; - } - - async canAutoSetup(): Promise { - return false; - } - - async runSetup(): Promise {} - - async getOrCreateBiometricEncryptionClientKeyHalf( - userId: UserId, - key: SymmetricCryptoKey, - ): Promise { - if (this.clientKeyHalves.has(userId)) { - return this.clientKeyHalves.get(userId)!; - } - - // Retrieve existing key half if it exists - let clientKeyHalf: Uint8Array | null = null; - const encryptedClientKeyHalf = - await this.biometricStateService.getEncryptedClientKeyHalf(userId); - if (encryptedClientKeyHalf != null) { - clientKeyHalf = await this.encryptService.decryptBytes(encryptedClientKeyHalf, key); - } - if (clientKeyHalf == null) { - // Set a key half if it doesn't exist - clientKeyHalf = await this.cryptoFunctionService.randomBytes(32); - const encKey = await this.encryptService.encryptBytes(clientKeyHalf, key); - await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId); - } - - this.clientKeyHalves.set(userId, clientKeyHalf); - - return clientKeyHalf; - } - - async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise { - if (this.clientKeyHalves.has(userId)) { - return BiometricsStatus.Available; - } else { - return BiometricsStatus.UnlockNeeded; - } - } -} diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts index 63e0527c034..064b28f2ff2 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts @@ -25,4 +25,6 @@ export interface OsBiometricService { setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise; deleteBiometricKey(userId: UserId): Promise; getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise; + enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise; + hasPersistentKey(userId: UserId): Promise; } diff --git a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts index c7ed88d390f..8e28d3ca614 100644 --- a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts @@ -68,4 +68,20 @@ export class RendererBiometricsService extends DesktopBiometricsService { BiometricsStatus.ManualSetupNeeded, ].includes(biometricStatus); } + + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise { + return await ipc.keyManagement.biometric.enrollPersistent(userId, key.toBase64()); + } + + async hasPersistentKey(userId: UserId): Promise { + return await ipc.keyManagement.biometric.hasPersistentKey(userId); + } + + async enableLinuxV2Biometrics(): Promise { + return await ipc.keyManagement.biometric.enableLinuxV2Biometrics(); + } + + async isLinuxV2BiometricsEnabled(): Promise { + return await ipc.keyManagement.biometric.isLinuxV2BiometricsEnabled(); + } } diff --git a/apps/desktop/src/key-management/electron-key.service.spec.ts b/apps/desktop/src/key-management/electron-key.service.spec.ts index 2d60c47217d..cc1d68ed050 100644 --- a/apps/desktop/src/key-management/electron-key.service.spec.ts +++ b/apps/desktop/src/key-management/electron-key.service.spec.ts @@ -4,7 +4,6 @@ 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"; import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service"; -import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @@ -26,7 +25,6 @@ import { ElectronKeyService } from "./electron-key.service"; describe("ElectronKeyService", () => { let keyService: ElectronKeyService; - const pinService = mock(); const keyGenerationService = mock(); const cryptoFunctionService = mock(); const encryptService = mock(); @@ -48,7 +46,6 @@ describe("ElectronKeyService", () => { stateProvider = new FakeStateProvider(accountService); keyService = new ElectronKeyService( - pinService, masterPasswordService, keyGenerationService, cryptoFunctionService, diff --git a/apps/desktop/src/key-management/electron-key.service.ts b/apps/desktop/src/key-management/electron-key.service.ts index 59295b2ca21..8de079a826b 100644 --- a/apps/desktop/src/key-management/electron-key.service.ts +++ b/apps/desktop/src/key-management/electron-key.service.ts @@ -3,7 +3,6 @@ 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"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @@ -22,7 +21,6 @@ import { DesktopBiometricsService } from "./biometrics/desktop.biometrics.servic // TODO Remove this class once biometric client key half storage is moved https://bitwarden.atlassian.net/browse/PM-22342 export class ElectronKeyService extends DefaultKeyService { constructor( - pinService: PinServiceAbstraction, masterPasswordService: InternalMasterPasswordServiceAbstraction, keyGenerationService: KeyGenerationService, cryptoFunctionService: CryptoFunctionService, @@ -37,7 +35,6 @@ export class ElectronKeyService extends DefaultKeyService { private biometricService: DesktopBiometricsService, ) { super( - pinService, masterPasswordService, keyGenerationService, cryptoFunctionService, @@ -51,10 +48,6 @@ export class ElectronKeyService extends DefaultKeyService { ); } - override async clearStoredUserKey(keySuffix: KeySuffixOptions, userId: UserId): Promise { - await super.clearStoredUserKey(keySuffix, userId); - } - protected override async storeAdditionalKeys(key: UserKey, userId: UserId) { await super.storeAdditionalKeys(key, userId); diff --git a/apps/desktop/src/key-management/key-connector/remove-password.component.ts b/apps/desktop/src/key-management/key-connector/remove-password.component.ts index 1b07f04ba8a..d9fea9409f8 100644 --- a/apps/desktop/src/key-management/key-connector/remove-password.component.ts +++ b/apps/desktop/src/key-management/key-connector/remove-password.component.ts @@ -2,6 +2,8 @@ import { Component } from "@angular/core"; import { RemovePasswordComponent as BaseRemovePasswordComponent } from "@bitwarden/key-management-ui"; +// 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-remove-password", templateUrl: "remove-password.component.html", diff --git a/apps/desktop/src/key-management/preload.ts b/apps/desktop/src/key-management/preload.ts index 7f8576b8472..844a81ff8e3 100644 --- a/apps/desktop/src/key-management/preload.ts +++ b/apps/desktop/src/key-management/preload.ts @@ -50,6 +50,25 @@ const biometric = { action: BiometricAction.SetShouldAutoprompt, data: should, } satisfies BiometricMessage), + enrollPersistent: (userId: string, keyB64: string): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.EnrollPersistent, + userId: userId, + key: keyB64, + } satisfies BiometricMessage), + hasPersistentKey: (userId: string): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.HasPersistentKey, + userId: userId, + } satisfies BiometricMessage), + enableLinuxV2Biometrics: (): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.EnableLinuxV2, + } satisfies BiometricMessage), + isLinuxV2BiometricsEnabled: (): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.IsLinuxV2Enabled, + } satisfies BiometricMessage), }; export default { diff --git a/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts new file mode 100644 index 00000000000..91c8126cdd7 --- /dev/null +++ b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts @@ -0,0 +1,48 @@ +import { defer, from, map, Observable } from "rxjs"; + +import { + VaultTimeout, + VaultTimeoutOption, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui"; + +export class DesktopSessionTimeoutSettingsComponentService + implements SessionTimeoutSettingsComponentService +{ + availableTimeoutOptions$: Observable = defer(() => + from(ipc.platform.powermonitor.isLockMonitorAvailable()).pipe( + map((isLockMonitorAvailable) => { + const options: VaultTimeoutOption[] = [ + { name: this.i18nService.t("oneMinute"), value: 1 }, + { name: this.i18nService.t("fiveMinutes"), value: 5 }, + { name: this.i18nService.t("fifteenMinutes"), value: 15 }, + { name: this.i18nService.t("thirtyMinutes"), value: 30 }, + { name: this.i18nService.t("oneHour"), value: 60 }, + { name: this.i18nService.t("fourHours"), value: 240 }, + { name: this.i18nService.t("onIdle"), value: VaultTimeoutStringType.OnIdle }, + { name: this.i18nService.t("onSleep"), value: VaultTimeoutStringType.OnSleep }, + ]; + + if (isLockMonitorAvailable) { + options.push({ + name: this.i18nService.t("onLocked"), + value: VaultTimeoutStringType.OnLocked, + }); + } + + options.push( + { name: this.i18nService.t("onRestart"), value: VaultTimeoutStringType.OnRestart }, + { name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never }, + ); + + return options; + }), + ), + ); + + constructor(private readonly i18nService: I18nService) {} + + onTimeoutSave(_: VaultTimeout): void {} +} diff --git a/apps/desktop/src/locales/af/messages.json b/apps/desktop/src/locales/af/messages.json index e579c498ded..1c6a2bc49c9 100644 --- a/apps/desktop/src/locales/af/messages.json +++ b/apps/desktop/src/locales/af/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Dien In" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Pasgemaakte omgewing" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Ongeldige hoofwagwoord" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Tweestapsaantekening maak u rekening veiliger deur u aantekenpoging te bevestig met ’n ander toestel soos ’n beveiligingsleutel, waarmerktoep, SMS, telefoonoproep of e-pos. U kan tweestapsaantekening in die webkluis op bitwarden.com aktiveer. Wil u die webwerf nou besoek?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Skrap rekening" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Ongelukkig word blaaierintegrasie tans slegs in die weergawe vir die Mac-toepwinkel ondersteun." - }, "browserIntegrationWindowsStoreDesc": { "message": "Ongelukkig word blaaierintegrasie tans nie in die weergawe vir die Windows-winkel ondersteun nie." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Vergrendel" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/ar/messages.json b/apps/desktop/src/locales/ar/messages.json index 4efec524886..ca404f4e179 100644 --- a/apps/desktop/src/locales/ar/messages.json +++ b/apps/desktop/src/locales/ar/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "مرحبًا بعودتك" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "استخدام تسجيل دخول واحد" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "إرسال" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "يجب عليك إضافة رابط الخادم الأساسي أو على الأقل بيئة مخصصة." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "بيئة مخصصة" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "كلمة المرور الرئيسية غير صالحة" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "تسجيل الدخول بخطوتين يجعل حسابك أكثر أمنا من خلال مطالبتك بالتحقق من تسجيل الدخول باستخدام جهاز آخر مثل مفتاح الأمان، تطبيق المصادقة، الرسائل القصيرة، المكالمة الهاتفية، أو البريد الإلكتروني. يمكن تمكين تسجيل الدخول بخطوتين على خزانة الويب bitwarden.com. هل تريد زيارة الموقع الآن؟" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "قفل مع كلمة المرور الرئيسية عند إعادة تشغيل" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "حذف الحساب" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "حدث خطأ أثناء تمكين دمج المتصفح." }, - "browserIntegrationMasOnlyDesc": { - "message": "للأسف، لا يتم دعم تكامل المتصفح إلا في إصدار متجر تطبيقات ماك في الوقت الحالي." - }, "browserIntegrationWindowsStoreDesc": { "message": "للأسف، لا يتم دعم تكامل المتصفح في إصدار متجر ويندوز في الوقت الحالي." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "تم قبول الدعوة" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "مقفل" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/az/messages.json b/apps/desktop/src/locales/az/messages.json index 9618bafca3e..55c2bdcd677 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "Bu elementə düzəliş etmə icazəniz yoxdur" + }, "welcomeBack": { "message": "Yenidən xoş gəlmisiniz" }, @@ -508,7 +511,7 @@ "description": "This describes a value that is 'linked' (related) to another value." }, "remove": { - "message": "Çıxart" + "message": "Xaric et" }, "nameRequired": { "message": "Ad lazımdır." @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Vahid daxil olma üsulunu istifadə et" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Təşkilatınız, vahid daxil olma tələb edir." + }, "submit": { "message": "Göndər" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Təməl server URL-sini və ya ən azı bir özəl mühiti əlavə etməlisiniz." }, + "selfHostedEnvMustUseHttps": { + "message": "URL-lər, HTTPS istifadə etməlidir." + }, "customEnvironment": { "message": "Özəl mühit" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Yararsız ana parol" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Yararsız ana parol. E-poçtunuzun doğru olduğunu və hesabınızın $HOST$ üzərində yaradıldığını təsdiqləyin.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "İki addımlı giriş, güvənlik açarı, kimlik doğrulayıcı tətbiq, SMS, telefon zəngi və ya e-poçt kimi digər cihazlarla girişinizi doğrulamanızı tələb edərək hesabınızı daha da güvənli edir. İki addımlı giriş, bitwarden.com veb seyfində qurula bilər. Veb saytı indi ziyarət etmək istəyirsiniz?" }, @@ -1641,7 +1659,7 @@ } }, "passwordSafe": { - "message": "Bu parol, veri pozuntularında qeydə alınmayıb. Rahatlıqla istifadə edə bilərsiniz." + "message": "Bu parol, veri pozuntularında qeydə alınmayıb. Əmniyyətlə istifadə edə bilərsiniz." }, "baseDomain": { "message": "Baza domeni", @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Yenidən başladılanda ana parol ilə kilidlə" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Tətbiq yenidən başladıqda ana parol və ya PIN tələb edilsin" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Tətbiq yenidən başladıqda ana parol tələb edilsin" + }, "deleteAccount": { "message": "Hesabı sil" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Brauzer inteqrasiyasını fəallaşdırarkən bir xəta baş verdi." }, - "browserIntegrationMasOnlyDesc": { - "message": "Təəssüf ki, brauzer inteqrasiyası indilik yalnız Mac App Store versiyasında dəstəklənir." - }, "browserIntegrationWindowsStoreDesc": { "message": "Təəssüf ki, brauzer inteqrasiyası hal-hazırda Windows Store versiyasında dəstəklənmir." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum özəl bitmə vaxtı 1 dəqiqədir." + }, "inviteAccepted": { "message": "Dəvət qəbul edildi" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Yalnız $ORGANIZATION$ ilə əlaqələndirilmiş təşkilat seyfi xaricə köçürüləcək.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Yalnız $ORGANIZATION$ ilə əlaqələndirilmiş təşkilat seyfi xaricə köçürüləcək. Element kolleksiyalarım daxil edilməyəcək.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Kilidli" }, @@ -3861,7 +3903,7 @@ "message": "Ana qovluğun adından sonra \"/\" əlavə edərək qovluğu ardıcıl yerləşdirin. Nümunə: Social/Forums" }, "sendsTitleNoItems": { - "message": "Send, həssas məlumatlar təhlükəsizdir", + "message": "Send ilə həssas məlumatlar əmniyyətdədir", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsBodyNoItems": { @@ -3916,7 +3958,7 @@ "message": "Kimliklərinizlə, uzun qeydiyyat və ya əlaqə xanalarını daha tez avtomatik doldurun." }, "newNoteNudgeTitle": { - "message": "Həssas verilərinizi güvənli şəkildə saxlayın" + "message": "Həssas verilərinizi əmniyyətdə saxlayın" }, "newNoteNudgeBody": { "message": "Notlarla, bankçılıq və ya sığorta təfsilatları kimi həssas veriləri təhlükəsiz saxlayın." @@ -4080,12 +4122,18 @@ "showLess": { "message": "Daha az göstər" }, - "enableAutotype": { - "message": "Avto-yazmanı fəallaşdır" - }, "enableAutotypeDescription": { "message": "Bitwarden, giriş yerlərini doğrulamır, qısayolu istifadə etməzdən əvvəl doğru pəncərədə və xanada olduğunuza əmin olun." }, + "typeShortcut": { + "message": "Yazma qısayolu" + }, + "editAutotypeShortcutDescription": { + "message": "Aşağıdakı dəyişdiricilərdən birini və ya ikisini daxil edin: Ctrl, Alt, Win və ya Shift və bir hərf." + }, + "invalidShortcut": { + "message": "Yararsız qısayol" + }, "moreBreadcrumbs": { "message": "Daha çox naviqasiya yolu", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Təsdiqlə" }, - "enableAutotypeTransitionKey": { - "message": "Avto-yazma qısayolunu fəallaşdır" + "enableAutotypeShortcutPreview": { + "message": "Avto-yazma qısayolunu fəallaşdır (Özəllik önizləməsi)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Veriləri yanlış yerə doldurmamaq üçün qısayolu istifadə etməzdən əvvəl doğru xanada olduğunuza əmin olun." }, "editShortcut": { "message": "Qısayola düzəliş et" }, - "archive": { - "message": "Arxivlə" + "archiveNoun": { + "message": "Arxiv", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Arxivlə", + "description": "Verb" + }, + "unArchive": { "message": "Arxivdən çıxart" }, "itemsInArchive": { @@ -4123,10 +4176,10 @@ "noItemsInArchiveDesc": { "message": "Arxivlənmiş elementlər burada görünəcək, ümumi axtarış nəticələrindən və avto-doldurma təkliflərindən xaric ediləcək." }, - "itemSentToArchive": { + "itemWasSentToArchive": { "message": "Element arxivə göndərildi" }, - "itemRemovedFromArchive": { + "itemWasUnarchived": { "message": "Element arxivdən çıxarıldı" }, "archiveItem": { @@ -4134,5 +4187,41 @@ }, "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?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Poçt kodu" + }, + "cardNumberLabel": { + "message": "Kart nömrəsi" + }, + "upgradeNow": { + "message": "İndi yüksəlt" + }, + "builtInAuthenticator": { + "message": "Daxili kimlik doğrulayıcı" + }, + "secureFileStorage": { + "message": "Güvənli fayl anbarı" + }, + "emergencyAccess": { + "message": "Fövqəladə hal erişimi" + }, + "breachMonitoring": { + "message": "Pozuntu monitorinqi" + }, + "andMoreFeatures": { + "message": "Və daha çoxu!" + }, + "planDescPremium": { + "message": "Tam onlayn təhlükəsizlik" + }, + "upgradeToPremium": { + "message": "\"Premium\"a yüksəlt" + }, + "sessionTimeoutSettingsAction": { + "message": "Vaxt bitmə əməliyyatı" + }, + "sessionTimeoutHeader": { + "message": "Sessiya vaxt bitməsi" } } diff --git a/apps/desktop/src/locales/be/messages.json b/apps/desktop/src/locales/be/messages.json index eb5971c97af..b2e4db47b32 100644 --- a/apps/desktop/src/locales/be/messages.json +++ b/apps/desktop/src/locales/be/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Адправіць" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Карыстальніцкае асяроддзе" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Памылковы асноўны пароль" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Двухэтапны ўваход робіць ваш уліковы запіс больш бяспечным, патрабуючы пацвярджэнне ўваходу на іншай прыладзе з выкарыстаннем ключа бяспекі, праграмы аўтэнтыфікацыі, SMS, тэлефоннага званка або электроннай пошты. Двухэтапны ўваход уключаецца на bitwarden.com. Перайсці на вэб-сайт, каб зрабіць гэта?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Выдаліць уліковы запіс" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "На жаль, інтэграцыя з браўзерам зараз падтрымліваецца толькі ў версіі для Mac App Store." - }, "browserIntegrationWindowsStoreDesc": { "message": "На жаль, інтэграцыя з браўзерам у цяперашні час не падтрымліваецца ў версіі для Microsoft Store." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Заблакіравана" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/bg/messages.json b/apps/desktop/src/locales/bg/messages.json index e32363f0c55..ad03c2cc023 100644 --- a/apps/desktop/src/locales/bg/messages.json +++ b/apps/desktop/src/locales/bg/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "Нямате право за редактиране на този елемент" + }, "welcomeBack": { "message": "Добре дошли отново" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Използване на еднократна идентификация" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Вашата организация изисква еднократно удостоверяване." + }, "submit": { "message": "Подаване" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Трябва да добавите или основния адрес на сървъра, или поне една специална среда." }, + "selfHostedEnvMustUseHttps": { + "message": "Адресите трябва да ползват HTTPS." + }, "customEnvironment": { "message": "Специална среда" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Грешна главна парола" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Грешна главна парола. Проверете дали е-пощата е правилна и дали акаунтът Ви е създаден в $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Двустепенното вписване защищава регистрацията ви като ви кара да потвърдите влизането си чрез устройство-ключ, приложение за идентификация, мобилно съобщение, телефонно обаждане или е-поща. Двустепенното вписване може да се включи чрез сайта bitwarden.com. Искате ли да го посетите?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Заключване с главната парола при повторно пускане" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Изискване на главната парола или ПИН код при повторно пускане на приложението" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Изискване на главната парола при повторно пускане на приложението" + }, "deleteAccount": { "message": "Изтриване на регистрацията" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Възникна грешка при включването на интеграцията с браузъра." }, - "browserIntegrationMasOnlyDesc": { - "message": "За жалост в момента интеграцията с браузър не се поддържа във версията за магазина на Mac." - }, "browserIntegrationWindowsStoreDesc": { "message": "За жалост в момента интеграцията с браузър не се поддържа във версията за магазина на Windows." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Минималното персонализирано време за достъп е 1 минута." + }, "inviteAccepted": { "message": "Поканата е приета" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Ще бъдат изнесени само записите от трезора свързан с $ORGANIZATION$.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Само трезора свързан с $ORGANIZATION$ ще бъде експортиран. Моите записи няма да бъдат включени.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Заключено" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Показване на по-малко" }, - "enableAutotype": { - "message": "Включване на автоматичното въвеждане" - }, "enableAutotypeDescription": { "message": "Битуорден не проверява местата за въвеждане, така че се уверете, че сте в правилния прозорец, преди да ползвате клавишната комбинация." }, + "typeShortcut": { + "message": "Комбинация за въвеждане" + }, + "editAutotypeShortcutDescription": { + "message": "Използвайте един или повече от модификаторите Ctrl, Alt, Win или Shift, заедно с някоя буква." + }, + "invalidShortcut": { + "message": "Неправилна комбинация" + }, "moreBreadcrumbs": { "message": "Още елементи в пътечката", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Потвърждаване" }, - "enableAutotypeTransitionKey": { - "message": "Включване на клавишната комбинация за автоматично попълване" + "enableAutotypeShortcutPreview": { + "message": "Включване на комбинация за автоматично попълване (Функционалност в изпитание)" }, - "enableAutotypeDescriptionTransitionKey": { - "message": "Уверете се, че сет в правилното поле, преди да използвате комбинацията, за да избегнете попълването на данните на грешното място." + "enableAutotypeShortcutDescription": { + "message": "Уверете се, че сте в правилното поле, преди да използвате комбинацията, за да избегнете попълването на данните на грешното място." }, "editShortcut": { "message": "Редактиране на комбинацията" }, - "archive": { - "message": "Архивиране" + "archiveNoun": { + "message": "Архив", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Архивиране", + "description": "Verb" + }, + "unArchive": { "message": "Изваждане от архива" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Архивираните елементи ще се показват тук и ще бъдат изключени от общите резултати при търсене и от предложенията за автоматично попълване." }, - "itemSentToArchive": { - "message": "Елементът е преместен в архива" + "itemWasSentToArchive": { + "message": "Елементът беше преместен в архива" }, - "itemRemovedFromArchive": { - "message": "Елементът е изваден от архива" + "itemWasUnarchived": { + "message": "Елементът беше изваден от архива" }, "archiveItem": { "message": "Архивиране на елемента" }, "archiveItemConfirmDesc": { "message": "Архивираните елементи са изключени от общите резултати при търсене и от предложенията за автоматично попълване. Наистина ли искате да архивирате този елемент?" + }, + "zipPostalCodeLabel": { + "message": "Пощенски код" + }, + "cardNumberLabel": { + "message": "Номер на картата" + }, + "upgradeNow": { + "message": "Надграждане сега" + }, + "builtInAuthenticator": { + "message": "Вграден удостоверител" + }, + "secureFileStorage": { + "message": "Сигурно съхранение на файлове" + }, + "emergencyAccess": { + "message": "Авариен достъп" + }, + "breachMonitoring": { + "message": "Наблюдение за пробиви" + }, + "andMoreFeatures": { + "message": "И още!" + }, + "planDescPremium": { + "message": "Пълна сигурност в Интернет" + }, + "upgradeToPremium": { + "message": "Надградете до Платения план" + }, + "sessionTimeoutSettingsAction": { + "message": "Действие при изтичането на времето за достъп" + }, + "sessionTimeoutHeader": { + "message": "Изтичане на времето за сесията" } } diff --git a/apps/desktop/src/locales/bn/messages.json b/apps/desktop/src/locales/bn/messages.json index 60b925af2e3..d6c61c1ab51 100644 --- a/apps/desktop/src/locales/bn/messages.json +++ b/apps/desktop/src/locales/bn/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "জমা দিন" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "পছন্দসই পরিবেশ" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "অবৈধ মূল পাসওয়ার্ড" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "দ্বি-পদক্ষেপ লগইন অন্য ডিভাইসে আপনার লগইনটি যাচাই করার জন্য সিকিউরিটি কী, প্রমাণীকরণকারী অ্যাপ্লিকেশন, এসএমএস, ফোন কল বা ই-মেইল ব্যাবহারের মাধ্যমে আপনার অ্যাকাউন্টকে আরও সুরক্ষিত করে। bitwarden.com ওয়েব ভল্টে দ্বি-পদক্ষেপের লগইন সক্ষম করা যাবে। আপনি কি এখনই ওয়েবসাইটটি দেখতে চান?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/bs/messages.json b/apps/desktop/src/locales/bs/messages.json index e6cdff50696..569f1072c4b 100644 --- a/apps/desktop/src/locales/bs/messages.json +++ b/apps/desktop/src/locales/bs/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Potvrdi" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Prilagođeno okruženje" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Neispravna glavna lozinka" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Prijava u dva koraka čini Vaš račun sigurnijim tako što zahtjeva da verifikujete svoje podatke pomoću drugog uređaja, kao što su sigurnosni ključ, aplikacija za autentifikaciju, SMS, telefonski poziv ili E-Mail. Prijavljivanje u dva koraka može se omogućiti na bitwarden.com web trezoru. Da li želite da posjetite web stranicu sada?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Brisanje računa" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Nažalost, za sada je integracija sa preglednikom podržana samo u Mac App Store verziji aplikacije." - }, "browserIntegrationWindowsStoreDesc": { "message": "Nažalost, integracija sa preglednikom nije podržana u Windows Store verziji aplikacije." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/ca/messages.json b/apps/desktop/src/locales/ca/messages.json index 0defa7a878a..de468f1e8b3 100644 --- a/apps/desktop/src/locales/ca/messages.json +++ b/apps/desktop/src/locales/ca/messages.json @@ -42,7 +42,7 @@ "message": "Cerca en la caixa forta" }, "resetSearch": { - "message": "Reset search" + "message": "Restableix la cerca" }, "addItem": { "message": "Afegeix element" @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "No teniu permisos per editar aquest element" + }, "welcomeBack": { "message": "Benvingut/da de nou" }, @@ -703,10 +706,10 @@ "message": "S'ha guardat el fitxer adjunt." }, "addAttachment": { - "message": "Add attachment" + "message": "Afig adjunt" }, "maxFileSizeSansPunctuation": { - "message": "Maximum file size is 500 MB" + "message": "La mida màxima del fitxer és de 500 MB" }, "file": { "message": "Fitxer" @@ -754,7 +757,7 @@ "message": "Inicia sessió a Bitwarden" }, "enterTheCodeSentToYourEmail": { - "message": "Enter the code sent to your email" + "message": "Introduïu el codi que us hem enviat al correu electrònic" }, "enterTheCodeFromYourAuthenticatorApp": { "message": "Introduïu el codi de la vostra aplicació d'autenticació" @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Usa inici de sessió únic" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Envia" }, @@ -948,14 +954,14 @@ } }, "dontAskAgainOnThisDeviceFor30Days": { - "message": "Don't ask again on this device for 30 days" + "message": "No ho torneu a preguntar en aquest dispositiu durant 30 dies" }, "selectAnotherMethod": { "message": "Select another method", "description": "Select another two-step login method" }, "useYourRecoveryCode": { - "message": "Use your recovery code" + "message": "Utilitzeu el codi de recuperació" }, "insertU2f": { "message": "Introduïu la vostra clau de seguretat al port USB de l'ordinador. Si té un botó, premeu-lo." @@ -1009,16 +1015,16 @@ "message": "Inici de sessió no disponible" }, "noTwoStepProviders": { - "message": "Aquest compte té habilitat l'inici de sessió en dues passes, però aquest navegador web no admet cap dels dos proveïdors configurats." + "message": "Aquest compte té habilitat l'inici de sessió en dos passos, però aquest navegador web no admet cap dels dos proveïdors configurats." }, "noTwoStepProviders2": { "message": "Afegiu proveïdors addicionals que s'adapten millor als dispositius (com ara una aplicació d'autenticació)." }, "twoStepOptions": { - "message": "Opcions d'inici de sessió en dues passes" + "message": "Opcions d'inici de sessió en dos passos" }, "selectTwoStepLoginMethod": { - "message": "Seleccioneu un mètode d'inici de sessió en dues passes" + "message": "Seleccioneu un mètode d'inici de sessió en dos passos" }, "selfHostedEnvironment": { "message": "Entorn d'allotjament propi" @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Entorn personalitzat" }, @@ -1222,14 +1231,23 @@ "invalidMasterPassword": { "message": "Contrasenya mestra no vàlida" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { - "message": "L'inici de sessió en dues passes fa que el vostre compte siga més segur, ja que obliga a comprovar el vostre inici de sessió amb un altre dispositiu, com ara una clau de seguretat, una aplicació autenticadora, un SMS, una trucada telefònica o un correu electrònic. Es pot habilitar l'inici de sessió en dues passes a la caixa forta web de bitwarden.com. Voleu visitar el lloc web ara?" + "message": "L'inici de sessió en dos passos fa que el vostre compte siga més segur, ja que obliga a comprovar el vostre inici de sessió amb un altre dispositiu, com ara una clau de seguretat, una aplicació autenticadora, un SMS, una trucada telefònica o un correu electrònic. Es pot habilitar l'inici de sessió en dos passos a la caixa forta web de bitwarden.com. Voleu visitar el lloc web ara?" }, "twoStepLogin": { - "message": "Inici de sessió en dues passes" + "message": "Inici de sessió en dos passos" }, "vaultTimeoutHeader": { - "message": "Vault timeout" + "message": "Temps d'espera de la caixa forta" }, "vaultTimeout": { "message": "Temps d'espera de la caixa forta" @@ -1238,7 +1256,7 @@ "message": "Temps d'espera" }, "vaultTimeoutAction1": { - "message": "Timeout action" + "message": "Acció després del temps d'espera" }, "vaultTimeoutDesc": { "message": "Trieu quan es tancarà la vostra caixa forta i feu l'acció seleccionada." @@ -1449,7 +1467,7 @@ "description": "Copy credit card security code (CVV)" }, "cardNumber": { - "message": "card number" + "message": "núm. targeta de crèdit" }, "premiumMembership": { "message": "Subscripció Premium" @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Bloqueja amb la contrasenya mestra en reiniciar" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Sol·licita la contrasenya mestra o el PIN en reiniciar l'aplicació" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Sol·licita la contrasenya mestra en reiniciar l'aplicació" + }, "deleteAccount": { "message": "Suprimeix el compte" }, @@ -1990,7 +2014,7 @@ } }, "learnMoreAboutAuthenticators": { - "message": "Learn more about authenticators" + "message": "Més informació sobre els autenticadors" }, "copyTOTP": { "message": "Copy Authenticator key (TOTP)" @@ -1999,7 +2023,7 @@ "message": "Make 2-step verification seamless" }, "totpHelper": { - "message": "Bitwarden can store and fill 2-step verification codes. Copy and paste the key into this field." + "message": "Bitwarden pot emmagatzemar i omplir codis de verificació en dos passos. Copieu i enganxeu la clau en aquest camp." }, "totpHelperWithCapture": { "message": "Bitwarden can store and fill 2-step verification codes. Select the camera icon to take a screenshot of this website's authenticator QR code, or copy and paste the key into this field." @@ -2024,7 +2048,7 @@ } }, "cardExpiredTitle": { - "message": "Expired card" + "message": "Targeta de crèdit caducada" }, "cardExpiredMessage": { "message": "If you've renewed it, update the card's information" @@ -2109,7 +2133,7 @@ "message": "Habilita la integració amb el navegador" }, "enableBrowserIntegrationDesc1": { - "message": "Used to allow biometric unlock in browsers that are not Safari." + "message": "Es fa servir per permetre el desbloqueig biomètric en navegadors que no són Safari." }, "enableDuckDuckGoBrowserIntegration": { "message": "Permet la integració del navegador DuckDuckGo" @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "S'ha produït un error en activar la integració del navegador." }, - "browserIntegrationMasOnlyDesc": { - "message": "Malauradament, la integració del navegador només és compatible amb la versió de Mac App Store." - }, "browserIntegrationWindowsStoreDesc": { "message": "Malauradament, la integració del navegador només és compatible amb la versió de Microsoft Store." }, @@ -2371,7 +2392,7 @@ "message": "Autenticar WebAuthn" }, "readSecurityKey": { - "message": "Llegeix clau de seguretat" + "message": "Llig la clau de seguretat" }, "awaitingSecurityKeyInteraction": { "message": "S'està esperant la interacció amb la clau de seguretat..." @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitació acceptada" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Bloquejat" }, @@ -2887,7 +2929,7 @@ } }, "forwarderNoDomain": { - "message": "Domini de $SERVICENAME$ invàlid.", + "message": "Domini de $SERVICENAME$ no vàlid.", "description": "Displayed when the domain is empty or domain authorization failed at the forwarding service.", "placeholders": { "servicename": { @@ -2991,7 +3033,7 @@ "message": "aplicació web" }, "notificationSentDevicePart2": { - "message": "Assegura't que la frase d'empremta digital encaixa amb la d'aquí sota abans d'aprovar-la." + "message": "Assegureu-vos que la frase de l'empremta digital coincideix amb la que hi ha a continuació abans d'aprovar-la." }, "needAnotherOptionV1": { "message": "Necessiteu una altra opció?" @@ -3047,18 +3089,18 @@ "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." }, "webApp": { - "message": "Web app" + "message": "Aplicació web" }, "mobile": { - "message": "Mobile", + "message": "Mòbil", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "Extensió", "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "Escriptori", "description": "Desktop app" }, "cli": { @@ -3069,10 +3111,10 @@ "description": "Software Development Kit" }, "server": { - "message": "Server" + "message": "Servidor" }, "loginRequest": { - "message": "Login request" + "message": "Petició d'inici de sessió" }, "deviceType": { "message": "Tipus de dispositiu" @@ -3108,7 +3150,7 @@ "message": "Aquesta sol·licitud ja no és vàlida." }, "confirmAccessAttempt": { - "message": "Confirma l'intent d'accés de $EMAIL$", + "message": "Confirmeu l'intent d'accés de $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -3120,7 +3162,7 @@ "message": "S'ha sol·licitat inici de sessió" }, "accountAccessRequested": { - "message": "Accés al compte demanat" + "message": "S'ha sol·licitat accés al compte" }, "creatingAccountOn": { "message": "Creant compte en" @@ -3771,7 +3813,7 @@ "message": "Denega" }, "sshkeyApprovalTitle": { - "message": "Confirma l'ús de la clau SSH" + "message": "Confirmeu l'ús de la clau SSH" }, "agentForwardingWarningTitle": { "message": "Advertència: Reenviament de l'Agent" @@ -3922,15 +3964,15 @@ "message": "With notes, securely store sensitive data like banking or insurance details." }, "newSshNudgeTitle": { - "message": "Developer-friendly SSH access" + "message": "Accés SSH fàcil per a desenvolupadors" }, "newSshNudgeBodyOne": { - "message": "Store your keys and connect with the SSH agent for fast, encrypted authentication.", + "message": "Emmagatzemeu les claus i connecteu-vos amb l'agent SSH per a una autenticació ràpida i xifrada.", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "newSshNudgeBodyTwo": { - "message": "Learn more about SSH agent", + "message": "Més informació sobre l'agent SSH", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/cs/messages.json b/apps/desktop/src/locales/cs/messages.json index 2c5ed437187..c02dbabbc93 100644 --- a/apps/desktop/src/locales/cs/messages.json +++ b/apps/desktop/src/locales/cs/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "Nemáte oprávnění upravit tuto položku" + }, "welcomeBack": { "message": "Vítejte zpět" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Použít jednotné přihlášení" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Vaše organizace vyžaduje jednotné přihlášení." + }, "submit": { "message": "Odeslat" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Musíte přidat buď základní adresu URL serveru nebo alespoň jedno vlastní prostředí." }, + "selfHostedEnvMustUseHttps": { + "message": "URL adresy musí používat HTTPS." + }, "customEnvironment": { "message": "Vlastní prostředí" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Chybné hlavní heslo" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Neplatné hlavní heslo. Potvrďte správnost e-mailu a zda byl Váš účet vytvořen na $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Dvoufázové přihlášení činí Váš účet mnohem bezpečnějším díky nutnosti po každém úspěšném přihlášení zadat ověřovací kód získaný z bezpečnostního klíče, aplikace, SMS, telefonního hovoru nebo e-mailu. Dvoufázové přihlášení lze aktivovat na webové stránce bitwarden.com. Chcete tuto stránku nyní otevřít?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Zamknout trezor při restartu pomocí hlavního hesla" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Vyžadovat hlavní heslo nebo PIN při restartu aplikace" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Vyžadovat hlavní heslo při restartu aplikace" + }, "deleteAccount": { "message": "Smazat účet" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Vyskytla se chyba při povolování integrace prohlížeče." }, - "browserIntegrationMasOnlyDesc": { - "message": "Integrace prohlížeče je podporována jen ve verzi pro Mac App Store." - }, "browserIntegrationWindowsStoreDesc": { "message": "Integrace prohlížeče není ve verzi pro Windows Store podporována." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimální vlastní časový limit je 1 minuta." + }, "inviteAccepted": { "message": "Pozvánka byla přijata" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Bude exportován jen trezor organizace přidružený k $ORGANIZATION$.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Bude exportován jen trezor organizace přidružený k $ORGANIZATION$. Položky mých sbírek nebudou zahrnuty.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Uzamčeno" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Zobrazit méně" }, - "enableAutotype": { - "message": "Povolit automatický zápis" - }, "enableAutotypeDescription": { "message": "Bitwarden neověřuje umístění vstupu. Před použitím zkratky se ujistěte, že jste ve správném okně a poli." }, + "typeShortcut": { + "message": "Napsat zkratku" + }, + "editAutotypeShortcutDescription": { + "message": "Zahrňte jeden nebo dva z následujících modifikátorů: Ctrl, Alt, Win nebo Shift a písmeno." + }, + "invalidShortcut": { + "message": "Neplatná zkratka" + }, "moreBreadcrumbs": { "message": "Více...", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Potvrdit" }, - "enableAutotypeTransitionKey": { - "message": "Povolit zkratku automatického psaní" + "enableAutotypeShortcutPreview": { + "message": "Povolit zkratku Autotype (náhled funkce)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Před použitím zkratky se ujistěte, že jste ve správném poli, abyste se vyhnuli vyplnění dat na nesprávné místo." }, "editShortcut": { "message": "Upravit zkratku" }, - "archive": { - "message": "Archivovat" + "archiveNoun": { + "message": "Archiv", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archivovat", + "description": "Verb" + }, + "unArchive": { "message": "Odebrat z archivu" }, "itemsInArchive": { @@ -4123,10 +4176,10 @@ "noItemsInArchiveDesc": { "message": "Zde se zobrazí archivované položky a budou vyloučeny z obecných výsledků vyhledávání a návrhů automatického vyplňování." }, - "itemSentToArchive": { + "itemWasSentToArchive": { "message": "Položka byla přesunuta do archivu" }, - "itemRemovedFromArchive": { + "itemWasUnarchived": { "message": "Položka byla odebrána z archivu" }, "archiveItem": { @@ -4134,5 +4187,41 @@ }, "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?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / PSČ" + }, + "cardNumberLabel": { + "message": "Číslo karty" + }, + "upgradeNow": { + "message": "Aktualizovat nyní" + }, + "builtInAuthenticator": { + "message": "Vestavěný autentifikátor" + }, + "secureFileStorage": { + "message": "Zabezpečené úložiště souborů" + }, + "emergencyAccess": { + "message": "Nouzový přístup" + }, + "breachMonitoring": { + "message": "Sledování úniků" + }, + "andMoreFeatures": { + "message": "A ještě více!" + }, + "planDescPremium": { + "message": "Dokončit online zabezpečení" + }, + "upgradeToPremium": { + "message": "Aktualizovat na Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Akce vypršení časového limitu" + }, + "sessionTimeoutHeader": { + "message": "Časový limit relace" } } diff --git a/apps/desktop/src/locales/cy/messages.json b/apps/desktop/src/locales/cy/messages.json index 9ff42bfa2c7..25b52fcc101 100644 --- a/apps/desktop/src/locales/cy/messages.json +++ b/apps/desktop/src/locales/cy/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/da/messages.json b/apps/desktop/src/locales/da/messages.json index 4a064a004cb..1d135a533f2 100644 --- a/apps/desktop/src/locales/da/messages.json +++ b/apps/desktop/src/locales/da/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Velkommen tilbage" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Brug Single Sign-On" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Indsend" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Der skal tilføjes enten basis Server-URL'en eller mindst ét tilpasset miljø." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Tilpasset miljø" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Ugyldig hovedadgangskode" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Totrins-login gør kontoen mere sikker ved at kræve, at man bekræfter sit login med en anden enhed, såsom en sikkerhedsnøgle, godkendelses-app, SMS, telefonopkald eller e-mail. Totrins-login kan aktiveres via bitwarden.com web-boksen. Besøg webstedet nu?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lås med hovedadgangskode ved genstart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Slet konto" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "En fejl opstod under aktivering af webbrowserintegration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Desværre understøttes browserintegration indtil videre kun i Mac App Store-versionen." - }, "browserIntegrationWindowsStoreDesc": { "message": "Desværre understøttes browserintegration pt. ikke i Microsoft Store-versionen." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepteret" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Låst" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index 47b3bad34e8..2f8daec5b68 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "Du bist nicht berechtigt, diesen Eintrag zu bearbeiten" + }, "welcomeBack": { "message": "Willkommen zurück" }, @@ -703,10 +706,10 @@ "message": "Anhang gespeichert" }, "addAttachment": { - "message": "Add attachment" + "message": "Anhang hinzufügen" }, "maxFileSizeSansPunctuation": { - "message": "Maximum file size is 500 MB" + "message": "Die maximale Dateigröße beträgt 500 MB" }, "file": { "message": "Datei" @@ -769,7 +772,10 @@ "message": "Anmelden mit einem anderen Gerät" }, "useSingleSignOn": { - "message": "Single Sign-on verwenden" + "message": "Single Sign-On verwenden" + }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Deine Organisation erfordert Single Sign-On." }, "submit": { "message": "Absenden" @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Du musst entweder die Basis-Server-URL oder mindestens eine benutzerdefinierte Umgebung hinzufügen." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs müssen HTTPS verwenden." + }, "customEnvironment": { "message": "Benutzerdefinierte Umgebung" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Ungültiges Master-Passwort" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Ungültiges Master-Passwort. Überprüfe, ob deine E-Mail-Adresse korrekt ist und dein Konto auf $HOST$ erstellt wurde.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Mit der Zwei-Faktor-Authentifizierung wird dein Konto zusätzlich abgesichert, da jede Anmeldung mit einem anderen Gerät wie einem Sicherheitsschlüssel, einer Authentifizierungs-App, einer SMS, einem Anruf oder einer E-Mail verifiziert werden muss. Die Zwei-Faktor-Authentifizierung kann im bitwarden.com Web-Tresor aktiviert werden. Möchtest du die Website jetzt öffnen?" }, @@ -1303,7 +1321,7 @@ "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Website-Symbole anzeigen und URLs zum Ändern von Passwörtern abrufen" }, "enableMinToTray": { "message": "In Infobereich-Symbol minimieren" @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Beim Neustart mit Master-Passwort sperren" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Master-Passwort oder PIN beim App-Neustart erfordern" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Master-Passwort beim App-Neustart erfordern" + }, "deleteAccount": { "message": "Konto löschen" }, @@ -1955,7 +1979,7 @@ "message": "Timeout-Aktion bestätigen" }, "enterpriseSingleSignOn": { - "message": "Enterprise Single-Sign-On" + "message": "Enterprise Single Sign-On" }, "setMasterPassword": { "message": "Master-Passwort festlegen" @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Beim Aktivieren der Browser-Integration ist ein Fehler aufgetreten." }, - "browserIntegrationMasOnlyDesc": { - "message": "Leider wird die Browser-Integration derzeit nur in der Mac App Store Version unterstützt." - }, "browserIntegrationWindowsStoreDesc": { "message": "Leider wird die Browser-Integration derzeit nicht in der Microsoft Store Version unterstützt." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Das minimal benutzerdefinierte Timeout beträgt 1 Minute." + }, "inviteAccepted": { "message": "Einladung angenommen" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Nur der mit $ORGANIZATION$ verknüpfte Organisations-Tresor wird exportiert.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Nur der mit $ORGANIZATION$ verknüpfte Organisations-Tresor wird exportiert. Meine Eintrags-Sammlungen werden nicht eingeschlossen.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Gesperrt" }, @@ -3486,10 +3528,10 @@ "message": "Sammlung auswählen" }, "importTargetHintCollection": { - "message": "Select this option if you want the imported file contents moved to a collection" + "message": "Wähle diese Option, wenn der importierte Dateiinhalt in eine Sammlung verschoben werden soll" }, "importTargetHintFolder": { - "message": "Select this option if you want the imported file contents moved to a folder" + "message": "Wähle diese Option, wenn der importierte Dateiinhalt in einen Ordner verschoben werden soll" }, "importUnassignedItemsError": { "message": "Die Datei enthält nicht zugewiesene Einträge." @@ -3586,10 +3628,10 @@ "message": "Bitte melde dich weiterhin mit deinen Firmenzugangsdaten an." }, "importDirectlyFromBrowser": { - "message": "Import directly from browser" + "message": "Direkt aus dem Browser importieren" }, "browserProfile": { - "message": "Browser Profile" + "message": "Browser-Profil" }, "seeDetailedInstructions": { "message": "Detaillierte Anleitungen auf unserer Hilfeseite unter", @@ -3834,10 +3876,10 @@ "message": "Gefährdetes Passwort ändern" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "message": "Diese Zugangsdaten sind gefährdet und es fehlt eine Website. Füge eine Website hinzu und ändere das Passwort für mehr Sicherheit." }, "missingWebsite": { - "message": "Missing website" + "message": "Fehlende Website" }, "cannotRemoveViewOnlyCollections": { "message": "Du kannst Sammlungen mit Leseberechtigung nicht entfernen: $COLLECTIONS$", @@ -3935,10 +3977,10 @@ "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "aboutThisSetting": { - "message": "About this setting" + "message": "Über diese Einstellung" }, "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." + "message": "Bitwarden verwendet gespeicherte URIs für die Anmeldung, um zu bestimmen, welches Symbol oder welche URL zum Ändern des Passworts verwendet werden soll, um dein Erlebnis zu verbessern. Es werden keine Informationen erfasst oder gespeichert, wenn du diesen Dienst nutzt." }, "assignToCollections": { "message": "Sammlungen zuweisen" @@ -4080,59 +4122,106 @@ "showLess": { "message": "Weniger anzeigen" }, - "enableAutotype": { - "message": "Autotype aktivieren" - }, "enableAutotypeDescription": { "message": "Bitwarden überprüft die Eingabestellen nicht. Vergewissere dich, dass du dich im richtigen Fenster und Feld befindest, bevor du die Tastenkombination verwendest." }, + "typeShortcut": { + "message": "Autotype-Tastaturkürzel" + }, + "editAutotypeShortcutDescription": { + "message": "Füge einen oder zwei der folgenden Modifikatoren ein: Strg, Alt, Win oder Umschalttaste, sowie einen Buchstaben." + }, + "invalidShortcut": { + "message": "Ungültiges Tastaturkürzel" + }, "moreBreadcrumbs": { - "message": "More breadcrumbs", + "message": "Weitere Navigationspfade", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." }, "next": { - "message": "Next" + "message": "Weiter" }, "confirmKeyConnectorDomain": { - "message": "Confirm Key Connector domain" + "message": "Key Connector-Domain bestätigen" }, "confirm": { - "message": "Confirm" + "message": "Bestätigen" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Autotype-Tastaturkürzel aktivieren (Funktionsvorschau)" }, - "enableAutotypeDescriptionTransitionKey": { - "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." + "enableAutotypeShortcutDescription": { + "message": "Stell sicher, dass du dich im richtigen Feld befindest, bevor du das Tastaturkürzel benutzt, um zu vermeiden, dass Daten an der falschen Stelle ausgefüllt werden." }, "editShortcut": { - "message": "Edit shortcut" + "message": "Tastaturkürzel bearbeiten" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archiv", + "description": "Noun" }, - "unarchive": { - "message": "Unarchive" + "archiveVerb": { + "message": "Archivieren", + "description": "Verb" + }, + "unArchive": { + "message": "Nicht mehr archivieren" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Einträge im Archiv" }, "noItemsInArchive": { - "message": "No items in archive" + "message": "Keine Einträge im Archiv" }, "noItemsInArchiveDesc": { - "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + "message": "Archivierte Einträge werden hier angezeigt und von allgemeinen Suchergebnissen sowie Auto-Ausfüllen-Vorschlägen ausgeschlossen." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Eintrag wurde ins Archiv verschoben" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Eintrag wird nicht mehr archiviert" }, "archiveItem": { - "message": "Archive item" + "message": "Eintrag archivieren" }, "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "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?" + }, + "zipPostalCodeLabel": { + "message": "PLZ / Postleitzahl" + }, + "cardNumberLabel": { + "message": "Kartennummer" + }, + "upgradeNow": { + "message": "Jetzt upgraden" + }, + "builtInAuthenticator": { + "message": "Integrierter Authenticator" + }, + "secureFileStorage": { + "message": "Sicherer Dateispeicher" + }, + "emergencyAccess": { + "message": "Notfallzugriff" + }, + "breachMonitoring": { + "message": "Datendiebstahl-Überwachung" + }, + "andMoreFeatures": { + "message": "Und mehr!" + }, + "planDescPremium": { + "message": "Umfassende Online-Sicherheit" + }, + "upgradeToPremium": { + "message": "Upgrade auf Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout-Aktion" + }, + "sessionTimeoutHeader": { + "message": "Sitzungs-Timeout" } } diff --git a/apps/desktop/src/locales/el/messages.json b/apps/desktop/src/locales/el/messages.json index 0c2ee1fab65..0b869c1e02f 100644 --- a/apps/desktop/src/locales/el/messages.json +++ b/apps/desktop/src/locales/el/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Καλωσορίσατε και πάλι" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Χρήση single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Υποβολή" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Πρέπει να προσθέσετε είτε το βασικό URL του διακομιστή ή τουλάχιστον ένα προσαρμοσμένο περιβάλλον." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Προσαρμοσμένο περιβάλλον" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Μη έγκυρος κύριος κωδικός πρόσβασης" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Η σύνδεση δύο βημάτων καθιστά τον λογαριασμό σας πιο ασφαλή απαιτώντας από εσάς να επαληθεύσετε τη σύνδεσή σας με άλλη συσκευή, όπως ένα κλειδί ασφαλείας, μία εφαρμογή αυθεντικοποίησης, ένα SMS, μία τηλεφωνική κλήση, ή ένα μήνυμα ηλ. ταχυδρομείου. Η σύνδεση δύο βημάτων μπορεί να ρυθμιστεί στη διαδικτυακή κρύπτη bitwarden.com. Θέλετε να επισκεφθείτε την ιστοσελίδα τώρα;" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Κλείδωμα με τον κύριο κωδικό πρόσβασης κατά την επανεκκίνηση" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Διαγραφή λογαριασμού" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Παρουσιάστηκε σφάλμα κατά την ενεργοποίηση ενσωμάτωσης του περιηγητή." }, - "browserIntegrationMasOnlyDesc": { - "message": "Δυστυχώς η ενσωμάτωση του προγράμματος περιήγησης υποστηρίζεται μόνο στην έκδοση Mac App Store για τώρα." - }, "browserIntegrationWindowsStoreDesc": { "message": "Δυστυχώς η ενσωμάτωση του περιηγητή, δεν υποστηρίζεται προς το παρόν στην έκδοση Windows Store." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Η πρόσκληση έγινε αποδεκτή" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Κλειδωμένο" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 08ec76af874..6bef882d970 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4108,16 +4150,21 @@ "enableAutotypeShortcutPreview": { "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4129,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/en_GB/messages.json b/apps/desktop/src/locales/en_GB/messages.json index 625d8804676..16af69361c6 100644 --- a/apps/desktop/src/locales/en_GB/messages.json +++ b/apps/desktop/src/locales/en_GB/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organisation requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organisation vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organisation vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/en_IN/messages.json b/apps/desktop/src/locales/en_IN/messages.json index 41211c2e7d7..c6f1253bb59 100644 --- a/apps/desktop/src/locales/en_IN/messages.json +++ b/apps/desktop/src/locales/en_IN/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organisation requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be enabled on the bitwarden.com web vault. Do you want to visit the website now?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Windows Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organisation vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organisation vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "PIN" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/eo/messages.json b/apps/desktop/src/locales/eo/messages.json index cebc2fa1432..28a9f3b8bce 100644 --- a/apps/desktop/src/locales/eo/messages.json +++ b/apps/desktop/src/locales/eo/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Bonrevenon!" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Propra medio" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Ŝlosi per la ĉefa pasvorto ĉe relanĉo" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Forigi la konton" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Ŝlosita" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/es/messages.json b/apps/desktop/src/locales/es/messages.json index 346dc0d4221..9966fa1064c 100644 --- a/apps/desktop/src/locales/es/messages.json +++ b/apps/desktop/src/locales/es/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Bienvenido de nuevo" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Usar inicio de sesión único" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Enviar" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Debes añadir o bien la URL del servidor base, o al menos un entorno personalizado." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Entorno personalizado" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Contraseña maestra no válida" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "La autenticación en dos pasos hace que tu cuenta sea mucho más segura, requiriendo que introduzcas un código de seguridad de una aplicación de autenticación cada vez que accedes. La autenticación en dos pasos puede ser habilitada en la caja fuerte web de bitwarden.com. ¿Quieres visitar ahora el sitio web?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Bloquear con contraseña maestra al reiniciar" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Eliminar cuenta" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Se ha producido un error mientras se habilitaba la integración del navegador." }, - "browserIntegrationMasOnlyDesc": { - "message": "Por desgracia la integración del navegador sólo está soportada por ahora en la versión de la Mac App Store." - }, "browserIntegrationWindowsStoreDesc": { "message": "Lamentablemente, la integración del navegador no está actualmente soportada en la versión de Microsoft Store." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitación aceptada" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Bloqueado" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden no valida las ubicaciones de entrada, asegúrate de que estás en la ventana y en el capo correctos antes de usar el atajo." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/et/messages.json b/apps/desktop/src/locales/et/messages.json index b8afeb2ed6a..d85c52bb763 100644 --- a/apps/desktop/src/locales/et/messages.json +++ b/apps/desktop/src/locales/et/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Tere tulemast tagasi" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Kinnita" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Sa pead lisama serveri nime (base URL) või vähemalt ühe iseseadistatud keskkonna." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Kohandatud keskkond" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Vale ülemparool" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Kaheastmeline kinnitamine aitab konto turvalisust tõsta. Lisaks paroolile pead kontole ligipääsemiseks kinnitama sisselogimise päringu SMS-ga, telefonikõnega, autentimise rakendusega või e-postiga. Kaheastmelist kinnitust saab sisse lülitada bitwarden.com veebihoidlas. Soovid seda kohe avada?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lukusta ülemparooliga, kui rakendus taaskäivitatakse" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Kustuta konto" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Midagi läks valesti brauseriga ühendamisel." }, - "browserIntegrationMasOnlyDesc": { - "message": "Paraku on brauseri integratsioon hetkel toetatud ainult Mac App Store'i versioonis." - }, "browserIntegrationWindowsStoreDesc": { "message": "Paraku ei ole brauseri integratsioon hetkel Microsoft Store versioonis toetatud." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Kutse vastu võetud" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Lukustatud" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/eu/messages.json b/apps/desktop/src/locales/eu/messages.json index 7f719ec0a4b..36401df0078 100644 --- a/apps/desktop/src/locales/eu/messages.json +++ b/apps/desktop/src/locales/eu/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Bidali" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Ingurune pertsonalizatua" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Pasahitz nagusi baliogabea" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Bi urratseko saio hasiera dela eta, zure kontua seguruagoa da, beste aplikazio/gailu batekin saioa hastea eskatzen baitizu; adibidez, segurtasun-gako, autentifikazio-aplikazio, SMS, telefono dei edo email bidez. Bi urratseko saio hasiera bitwarden.com webgunean aktibatu daiteke. Orain joan nahi duzu webgunera?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Ezabatu kontua" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Zoritxarrez, Mac App Storeren bertsioan soilik onartzen da oraingoz nabigatzailearen integrazioa." - }, "browserIntegrationWindowsStoreDesc": { "message": "Zoritxarrez, nabigatzailearen integrazioa ez da onartzen Windows Storen bertsioan." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Blokeatuta" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/fa/messages.json b/apps/desktop/src/locales/fa/messages.json index fbbbdfd8c7f..ac0e83dd44d 100644 --- a/apps/desktop/src/locales/fa/messages.json +++ b/apps/desktop/src/locales/fa/messages.json @@ -42,7 +42,7 @@ "message": "جستجوی گاوصندوق" }, "resetSearch": { - "message": "Reset search" + "message": "پاک کردن جستجو" }, "addItem": { "message": "افزودن مورد" @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "شما اجازه ویرایش این آیتم را ندارید" + }, "welcomeBack": { "message": "خوش آمدید" }, @@ -576,7 +579,7 @@ "message": "کپی کد تأیید (TOTP)" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "کپی$FIELD$،$CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -703,10 +706,10 @@ "message": "پیوست ذخیره شد" }, "addAttachment": { - "message": "Add attachment" + "message": "افزودن پیوست" }, "maxFileSizeSansPunctuation": { - "message": "Maximum file size is 500 MB" + "message": "حداکثر حجم فایل ۵۰۰ مگابایت است" }, "file": { "message": "پرونده" @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "استفاده از ورود تک مرحله‌ای" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "سازمان شما ورود یکپارچه را الزامی کرده است." + }, "submit": { "message": "ثبت" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "شما باید یا نشانی اینترنتی پایه سرور را اضافه کنید، یا حداقل یک محیط سفارشی تعریف کنید." }, + "selfHostedEnvMustUseHttps": { + "message": "آدرس‌های وب باید از HTTPS استفاده کنند." + }, "customEnvironment": { "message": "محیط سفارشی" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "کلمه عبور اصلی نامعتبر است" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "رمز عبور اصلی نامعتبر است. تأیید کنید که ایمیل شما صحیح است و حساب کاربری شما در $HOST$ ایجاد شده است.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "ورود دو مرحله‌ای باعث می شود که حساب کاربری شما با استفاده از یک دستگاه دیگر مانند کلید امنیتی، برنامه احراز هویت، پیامک، تماس تلفنی و یا رایانامه، اعتبار خود را با ایمنی بیشتر اثبات کند. ورود دو مرحله‌ای می‌تواند در bitwarden.com راه‌اندازی شود. آیا می‌خواهید از سایت بازدید کنید؟" }, @@ -1229,7 +1247,7 @@ "message": "ورود دو مرحله‌ای" }, "vaultTimeoutHeader": { - "message": "Vault timeout" + "message": "وقفه زمانی گاوصندوق" }, "vaultTimeout": { "message": "متوقف شدن گاو‌صندوق" @@ -1238,7 +1256,7 @@ "message": "پایان زمان" }, "vaultTimeoutAction1": { - "message": "Timeout action" + "message": "اقدام وقفه زمانی" }, "vaultTimeoutDesc": { "message": "انتخاب کنید که گاو‌صندوق شما چه زمانی عمل توقف زمانی گاوصندوق را انجام دهد." @@ -1303,7 +1321,7 @@ "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "نمایش آیکون‌های وب‌سایت و دریافت آدرس‌های تغییر رمز عبور" }, "enableMinToTray": { "message": "کوچک کردن به نماد سینی" @@ -1449,7 +1467,7 @@ "description": "Copy credit card security code (CVV)" }, "cardNumber": { - "message": "card number" + "message": "شماره کارت" }, "premiumMembership": { "message": "عضویت پرمیوم" @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "در زمان شروع مجدد، با کلمه عبور اصلی قفل کن" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "درخواست رمز عبور اصلی یا پین هنگام راه‌اندازی مجدد برنامه" + }, + "requireMasterPasswordOnAppRestart": { + "message": "درخواست رمز عبور اصلی هنگام راه‌اندازی مجدد برنامه" + }, "deleteAccount": { "message": "حذف حساب کاربری" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "خطایی هنگام فعال‌سازی یکپارچه سازی مرورگر رخ داده است." }, - "browserIntegrationMasOnlyDesc": { - "message": "متأسفانه در حال حاضر ادغام مرورگر فقط در نسخه Mac App Store پشتیبانی می‌شود." - }, "browserIntegrationWindowsStoreDesc": { "message": "متأسفانه در حال حاضر ادغام مرورگر در نسخه فروشگاه ویندوز پشتیبانی نمی‌شود." }, @@ -2416,13 +2437,13 @@ "message": "کلمه عبور اصلی شما با یک یا چند سیاست سازمان‌تان مطابقت ندارد. برای دسترسی به گاوصندوق، باید همین حالا کلمه عبور اصلی خود را به‌روزرسانی کنید. در صورت ادامه، شما از نشست فعلی خود خارج می‌شوید و باید دوباره وارد سیستم شوید. نشست فعال در دستگاه‌های دیگر ممکن است تا یک ساعت همچنان فعال باقی بمانند." }, "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": "برای تکمیل بازیابی حساب، رمز عبور اصلی خود را تغییر دهید." }, "updateMasterPasswordSubtitle": { - "message": "Your master password does not meet this organization’s requirements. Change your master password to continue." + "message": "رمز عبور اصلی شما با الزامات این سازمان مطابقت ندارد. برای ادامه، رمز عبور اصلی خود را تغییر دهید." }, "tdeDisabledMasterPasswordRequired": { "message": "سازمان شما رمزگذاری دستگاه‌های مورد اعتماد را غیرفعال کرده است. لطفاً برای دسترسی به گاوصندوق خود یک کلمه عبور اصلی تنظیم کنید." @@ -2512,10 +2533,10 @@ "message": "مهلت زمانی شما بیش از محدودیت‌های تعیین شده توسط سازمان‌تان است." }, "vaultTimeoutPolicyAffectingOptions": { - "message": "Enterprise policy requirements have been applied to your timeout options" + "message": "الزامات سیاست سازمانی بر روی گزینه‌های وقفه زمانی شما اعمال شده است" }, "vaultTimeoutPolicyInEffect": { - "message": "Your organization policies have set your maximum allowed vault timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).", + "message": "سیاست‌های سازمان شما حداکثر زمان مجاز وقفه گاوصندوق را روی $HOURS$ ساعت و $MINUTES$ دقیقه تنظیم کرده است.", "placeholders": { "hours": { "content": "$1", @@ -2528,7 +2549,7 @@ } }, "vaultTimeoutPolicyMaximumError": { - "message": "Timeout exceeds the restriction set by your organization: $HOURS$ hour(s) and $MINUTES$ minute(s) maximum", + "message": "زمان وقفه از محدودیت تعیین شده توسط سازمان شما بیشتر است: حداکثر $HOURS$ ساعت و $MINUTES$ دقیقه", "placeholders": { "hours": { "content": "$1", @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "حداقل زمان وقفه سفارشی ۱ دقیقه است." + }, "inviteAccepted": { "message": "دعوتنامه پذیرفته شد" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "فقط از گاوصندوق سازمانی مرتبط با $ORGANIZATION$ خروجی گرفته خواهد شد.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "فقط از گاوصندوق سازمانی مرتبط با $ORGANIZATION$ خروجی گرفته خواهد شد. مجموعه‌های «آیتم‌های من» شامل نخواهند شد.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "قفل شد" }, @@ -3031,7 +3073,7 @@ } }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "درخواست ورود برای $EMAIL$ در $DEVICE$ تأیید شد", "placeholders": { "email": { "content": "$1", @@ -3044,21 +3086,21 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "شما یک تلاش ورود از دستگاهی دیگر را رد کردید. اگر این شما بودید، دوباره با آن دستگاه برای ورود تلاش کنید." }, "webApp": { - "message": "Web app" + "message": "نسخه وب" }, "mobile": { - "message": "Mobile", + "message": "موبایل", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "افزونه", "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "دسکتاپ", "description": "Desktop app" }, "cli": { @@ -3069,10 +3111,10 @@ "description": "Software Development Kit" }, "server": { - "message": "Server" + "message": "سرور" }, "loginRequest": { - "message": "Login request" + "message": "درخواست ورود" }, "deviceType": { "message": "نوع دستگاه" @@ -3216,10 +3258,10 @@ "message": "درخواست تأیید مدیر" }, "unableToCompleteLogin": { - "message": "Unable to complete login" + "message": "امکان تکمیل ورود وجود ندارد" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { - "message": "You need to log in on a trusted device or ask your administrator to assign you a password." + "message": "شما باید در یک دستگاه قابل اعتماد وارد شوید یا از مدیر خود بخواهید رمز عبوری به شما اختصاص دهد." }, "region": { "message": "منطقه" @@ -3486,10 +3528,10 @@ "message": "یک مجموعه انتخاب کنید" }, "importTargetHintCollection": { - "message": "Select this option if you want the imported file contents moved to a collection" + "message": "اگر می‌خواهید محتویات فایل وارد شده به یک مجموعه منتقل شود، این گزینه را انتخاب کنید" }, "importTargetHintFolder": { - "message": "Select this option if you want the imported file contents moved to a folder" + "message": "اگر می‌خواهید محتویات فایل وارد شده به یک پوشه منتقل شود، این گزینه را انتخاب کنید" }, "importUnassignedItemsError": { "message": "پرونده حاوی موارد اختصاص نیافته است." @@ -3586,10 +3628,10 @@ "message": "لطفاً برای ورود، از اطلاعات کاربری شرکت خود استفاده کنید." }, "importDirectlyFromBrowser": { - "message": "Import directly from browser" + "message": "وارد کردن مستقیم از مرورگر" }, "browserProfile": { - "message": "Browser Profile" + "message": "پروفایل مرورگر" }, "seeDetailedInstructions": { "message": "دستورالعمل‌های کامل را در سایت راهنمای ما مشاهده کنید در", @@ -3631,11 +3673,11 @@ "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "گزینه‌های پیشرفته", "description": "Advanced option placeholder for uri option component" }, "warningCapitalized": { - "message": "Warning", + "message": "هشدار", "description": "Warning (should maintain locale-relevant capitalization)" }, "success": { @@ -3834,10 +3876,10 @@ "message": "تغییر کلمه عبور در معرض خطر" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "message": "این ورود در معرض خطر است و فاقد وب‌سایت می‌باشد. برای امنیت بیشتر، یک وب‌سایت اضافه کنید و رمز عبور را تغییر دهید." }, "missingWebsite": { - "message": "Missing website" + "message": "وب‌سایت وجود ندارد" }, "cannotRemoveViewOnlyCollections": { "message": "نمی‌توانید مجموعه‌هایی را که فقط دسترسی مشاهده دارند حذف کنید: $COLLECTIONS$", @@ -3935,10 +3977,10 @@ "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "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." + "message": "بیت‌واردن از URI های ورود ذخیره شده استفاده می‌کند تا تشخیص دهد کدام آیکون یا آدرس تغییر رمز عبور باید برای بهبود تجربه شما استفاده شود. هنگام استفاده از این سرویس، هیچ اطلاعاتی جمع‌آوری یا ذخیره نمی‌شود." }, "assignToCollections": { "message": "اختصاص به مجموعه‌ها" @@ -4075,64 +4117,111 @@ } }, "showMore": { - "message": "Show more" + "message": "نمایش بیشتر" }, "showLess": { - "message": "Show less" - }, - "enableAutotype": { - "message": "Enable Autotype" + "message": "نمایش کمتر" }, "enableAutotypeDescription": { - "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." + "message": "بیت‌واردن مکان‌های ورودی را اعتبارسنجی نمی‌کند؛ قبل از استفاده از میانبر، مطمئن شوید که در پنجره و فیلد صحیح قرار دارید." + }, + "typeShortcut": { + "message": "تایپ میانبر" + }, + "editAutotypeShortcutDescription": { + "message": "شامل یک یا دو مورد از کلیدهای تغییردهنده زیر: Ctrl، Alt، Win یا Shift و یک حرف." + }, + "invalidShortcut": { + "message": "میانبر نامعتبر" }, "moreBreadcrumbs": { - "message": "More breadcrumbs", + "message": "مسیرهای بیشتر", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." }, "next": { - "message": "Next" + "message": "بعدی" }, "confirmKeyConnectorDomain": { - "message": "Confirm Key Connector domain" + "message": "تأیید دامنه Key Connector" }, "confirm": { - "message": "Confirm" + "message": "تأیید" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "فعال‌سازی میانبر تایپ خودکار (پیش‌نمایش ویژگی)" }, - "enableAutotypeDescriptionTransitionKey": { - "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." + "enableAutotypeShortcutDescription": { + "message": "برای جلوگیری از پر کردن داده‌ها در مکان اشتباه، قبل از استفاده از میانبر مطمئن شوید که در فیلد صحیح قرار دارید." }, "editShortcut": { - "message": "Edit shortcut" + "message": "ویرایش میانبر" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "بایگانی", + "description": "Noun" }, - "unarchive": { - "message": "Unarchive" + "archiveVerb": { + "message": "بایگانی کردن", + "description": "Verb" + }, + "unArchive": { + "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": "آیتم‌های بایگانی‌شده در اینجا نمایش داده می‌شوند و از نتایج جستجوی عمومی و پیشنهاد ها پر کردن خودکار حذف خواهند شد." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "آیتم به بایگانی فرستاده شد" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "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?" + "message": "آیتم‌های بایگانی‌شده از نتایج جستجوی عمومی و پیشنهاد ها پر کردن خودکار حذف می‌شوند. آیا مطمئن هستید که می‌خواهید این آیتم را بایگانی کنید؟" + }, + "zipPostalCodeLabel": { + "message": "کد پستی" + }, + "cardNumberLabel": { + "message": "شماره کارت" + }, + "upgradeNow": { + "message": "هم‌اکنون ارتقا دهید" + }, + "builtInAuthenticator": { + "message": "تولیدکننده رمز دوعاملی داخلی" + }, + "secureFileStorage": { + "message": "ذخیره‌سازی امن فایل" + }, + "emergencyAccess": { + "message": "دسترسی اضطراری" + }, + "breachMonitoring": { + "message": "پایش نشت اطلاعات" + }, + "andMoreFeatures": { + "message": "و بیشتر!" + }, + "planDescPremium": { + "message": "امنیت آنلاین کامل" + }, + "upgradeToPremium": { + "message": "ارتقا به نسخه پرمیوم" + }, + "sessionTimeoutSettingsAction": { + "message": "اقدام وقفه زمانی" + }, + "sessionTimeoutHeader": { + "message": "وقفه زمانی نشست" } } diff --git a/apps/desktop/src/locales/fi/messages.json b/apps/desktop/src/locales/fi/messages.json index ecde260d80e..e2952659d03 100644 --- a/apps/desktop/src/locales/fi/messages.json +++ b/apps/desktop/src/locales/fi/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Tervetuloa takaisin" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Käytä kertakirjautumista" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Jatka" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Sinun on lisättävä joko palvelimen perusosoite tai ainakin yksi mukautettu palvelinympäristö." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Mukautettu palvelinympäristö" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Virheellinen pääsalasana" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Kaksivaiheinen kirjautuminen parantaa tilisi suojausta vaatimalla kirjautumisen vahvistuksen salasanan lisäksi suojausavaimen, todennussovelluksen, tekstiviestin, puhelun tai sähköpostin avulla. Voit ottaa kaksivaiheisen kirjautumisen käyttöön bitwarden.com‑verkkoholvissa. Haluatko avata sen nyt?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lukitse pääsalasanalla uudelleenkäynnistyksen yhteydessä" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Poista tili" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Otettaessa selainintegraatiota käyttöön tapahtui virhe." }, - "browserIntegrationMasOnlyDesc": { - "message": "Valitettavasti selainintegraatiota tuetaan toistaiseksi vain Mac App Store -versiossa." - }, "browserIntegrationWindowsStoreDesc": { "message": "Valitettavasti selainintegraatiota ei toistaiseksi tueta Microsoft Store -versiossa." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Kutsu hyväksyttiin" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Lukittu" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/fil/messages.json b/apps/desktop/src/locales/fil/messages.json index 5ad2661b46a..6eaa5577807 100644 --- a/apps/desktop/src/locales/fil/messages.json +++ b/apps/desktop/src/locales/fil/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Isumite" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Kapaligirang Custom" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Imbalidong master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Ang two-step login ay nagpapagaan sa iyong account sa pamamagitan ng pag-verify sa iyong login sa isa pang device tulad ng security key, authenticator app, SMS, tawag sa telepono o email. Ang two-step login ay maaaring magawa sa bitwarden.com web vault. Gusto mo bang bisitahin ang website ngayon?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Tanggalin ang account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Sa kasamaang palad ang pagsasama ng browser ay suportado lamang sa bersyon ng Mac App Store para sa ngayon." - }, "browserIntegrationWindowsStoreDesc": { "message": "Sa kasamaang palad ang pagsasama ng browser ay kasalukuyang hindi suportado sa bersyon ng Microsoft Store." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Naka-lock" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/fr/messages.json b/apps/desktop/src/locales/fr/messages.json index f85708edbe0..6cca98444b8 100644 --- a/apps/desktop/src/locales/fr/messages.json +++ b/apps/desktop/src/locales/fr/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "Vous n'avez pas l'autorisation de modifier cet élément" + }, "welcomeBack": { "message": "Content de vous revoir" }, @@ -280,7 +283,7 @@ "message": "Bitwarden n'a pas pu déchiffrer le(s) élément(s) du coffre listé(s) ci-dessous." }, "contactCSToAvoidDataLossPart1": { - "message": "Contacter le service clientèle", + "message": "Contacter succès client", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "contactCSToAvoidDataLossPart2": { @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Utiliser l'authentification unique" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Votre organisation exige l’authentification unique." + }, "submit": { "message": "Soumettre" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Vous devez ajouter soit l'URL du serveur de base, soit au moins un environnement personnalisé." }, + "selfHostedEnvMustUseHttps": { + "message": "Les URL doivent utiliser le protocole HTTPS." + }, "customEnvironment": { "message": "Environnement personnalisé" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Mot de passe principal invalide" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Mot de passe principal invalide. Confirmez que votre adresse courriel est correcte et que votre compte a été créé sur $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "L'authentification à deux facteurs rend votre compte plus sûr en vous demandant de vérifier votre connexion avec un autre dispositif tel qu'une clé de sécurité, une application d'authentification, un SMS, un appel téléphonique ou un courriel. L'authentification à deux facteurs peut être configurée sur le coffre web de bitwarden.com. Voulez-vous visiter le site web maintenant ?" }, @@ -1817,7 +1835,7 @@ "message": "Code PIN invalide." }, "tooManyInvalidPinEntryAttemptsLoggingOut": { - "message": "Trop de tentatives de saisie du code PIN incorrectes. Déconnexion." + "message": "Trop de tentatives de saisie du code NIP incorrectes. Déconnexion." }, "unlockWithWindowsHello": { "message": "Déverrouiller avec Windows Hello" @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Verrouiller avec le mot de passe principal au redémarrage" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Exiger un mot de passe principal ou un code NIP au redémarrage de l'application" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Exiger un mot de passe principal redémarrage de l'application" + }, "deleteAccount": { "message": "Supprimer le compte" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Une erreur s'est produite lors de l'action de l'intégration du navigateur." }, - "browserIntegrationMasOnlyDesc": { - "message": "Malheureusement l'intégration avec le navigateur est uniquement supportée dans la version Mac App Store pour le moment." - }, "browserIntegrationWindowsStoreDesc": { "message": "Malheureusement l'intégration avec le navigateur n'est pas supportée dans la version Windows Store pour le moment." }, @@ -2431,10 +2452,10 @@ "message": "Essayez de nouveau" }, "verificationRequiredForActionSetPinToContinue": { - "message": "Vérification requise pour cette action. Définissez un code PIN pour continuer." + "message": "Vérification requise pour cette action. Définissez un code NIP pour continuer." }, "setPin": { - "message": "Définir un code PIN" + "message": "Définir un code NIP" }, "verifyWithBiometrics": { "message": "Vérifier par biométrie" @@ -2452,7 +2473,7 @@ "message": "Utiliser le mot de passe principal" }, "usePin": { - "message": "Utiliser le code PIN" + "message": "Utiliser le code NIP" }, "useBiometrics": { "message": "Utiliser la biométrie" @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Le délai d'expiration personnalisé minimum est de 1 minute." + }, "inviteAccepted": { "message": "Invitation acceptée" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Seul le coffre de l'organisation associé à $ORGANIZATION$ sera exporté.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Seul le coffre de l'organisation associé à $ORGANIZATION$ sera exporté. Mes éléments de mes collections ne seront pas inclus.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Verrouillé" }, @@ -3547,7 +3589,7 @@ "message": "Code incorrect" }, "incorrectPin": { - "message": "Code PIN incorrect" + "message": "Code NIP incorrect" }, "multifactorAuthenticationFailed": { "message": "Authentification multifacteur échouée" @@ -4080,12 +4122,18 @@ "showLess": { "message": "Afficher moins" }, - "enableAutotype": { - "message": "Activer la Saisie Auto" - }, "enableAutotypeDescription": { "message": "Bitwarden ne valide pas les emplacements d'entrée, assurez-vous d'être dans la bonne fenêtre et le bon champ avant d'utiliser le raccourci." }, + "typeShortcut": { + "message": "Saisir le raccourci" + }, + "editAutotypeShortcutDescription": { + "message": "Inclure un ou deux des modificateurs suivants : Ctrl, Alt, Win, ou Shift, et une lettre." + }, + "invalidShortcut": { + "message": "Raccourci invalide" + }, "moreBreadcrumbs": { "message": "Plus de fil d'Ariane", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirmer" }, - "enableAutotypeTransitionKey": { - "message": "Activer le raccourci de la Saisie Auto" + "enableAutotypeShortcutPreview": { + "message": "Activer le raccourci autotype (aperçu de la fonctionnalité)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Assurez-vous d'être dans le bon champ avant d'utiliser le raccourci pour éviter de remplir les données au mauvais endroit." }, "editShortcut": { "message": "Modifier le raccourci" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archiver", + "description": "Verb" + }, + "unArchive": { "message": "Désarchiver" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Les éléments archivés apparaîtront ici et seront exclus des résultats de recherche généraux et des suggestions de remplissage automatique." }, - "itemSentToArchive": { - "message": "Élément envoyé à l'archive" + "itemWasSentToArchive": { + "message": "L'élément a été envoyé à l'archive" }, - "itemRemovedFromArchive": { - "message": "Élément retiré de l'archive" + "itemWasUnarchived": { + "message": "L'élément a été désarchivé" }, "archiveItem": { "message": "Archiver l'élément" }, "archiveItemConfirmDesc": { "message": "Les éléments archivés sont exclus des résultats de recherche généraux et des suggestions de remplissage automatique. Êtes-vous sûr de vouloir archiver cet élément ?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Code postal" + }, + "cardNumberLabel": { + "message": "Numéro de carte" + }, + "upgradeNow": { + "message": "Mettre à niveau maintenant" + }, + "builtInAuthenticator": { + "message": "Authentificateur intégré" + }, + "secureFileStorage": { + "message": "Stockage sécurisé de fichier" + }, + "emergencyAccess": { + "message": "Accès d'urgence" + }, + "breachMonitoring": { + "message": "Surveillance des fuites" + }, + "andMoreFeatures": { + "message": "Et encore plus !" + }, + "planDescPremium": { + "message": "Sécurité en ligne complète" + }, + "upgradeToPremium": { + "message": "Mettre à niveau vers Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Action à l’expiration" + }, + "sessionTimeoutHeader": { + "message": "Délai d'expiration de la session" } } diff --git a/apps/desktop/src/locales/gl/messages.json b/apps/desktop/src/locales/gl/messages.json index 5849d9d4cee..d607bb8d097 100644 --- a/apps/desktop/src/locales/gl/messages.json +++ b/apps/desktop/src/locales/gl/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/he/messages.json b/apps/desktop/src/locales/he/messages.json index 5cbceb3ad76..868cd9ccbc5 100644 --- a/apps/desktop/src/locales/he/messages.json +++ b/apps/desktop/src/locales/he/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "אין לך הרשאות לערוך את הפריט הזה" + }, "welcomeBack": { "message": "ברוך שובך" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "השתמש בכניסה יחידה" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "הארגון שלך דורש כניסה יחידה." + }, "submit": { "message": "שלח" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "אתה מוכרח להוסיף או את בסיס ה־URL של השרת או לפחות סביבה מותאמת אישית אחת." }, + "selfHostedEnvMustUseHttps": { + "message": "כתובות URL מוכרחות להשתמש ב־HTTPS." + }, "customEnvironment": { "message": "סביבה מותאמת אישית" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "סיסמה ראשית שגויה" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "סיסמה ראשית אינה תקינה. יש לאשר שהדוא\"ל שלך נכון ושהחשבון שלך נוצר ב־$HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "אימות דו שלבי הופך את החשבון שלך למאובטח יותר בכך שתצטרך לאשר התחברות בעזרת מפתח אבטחה, תוכנת אימות, SMS, שיחת טלפון, או אימייל. ניתן להפעיל את \"אימות דו שלבי\" בכספת שבאתר bitwarden.com. האם ברצונך לפתוח את האתר כעת?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "נעל בעזרת הסיסמה הראשית בהפעלה מחדש" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "דרוש סיסמה ראשית או PIN בעת הפעלה מחדש של היישום" + }, + "requireMasterPasswordOnAppRestart": { + "message": "דרוש סיסמה ראשית בעת הפעלה מחדש של היישום" + }, "deleteAccount": { "message": "מחק חשבון" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "אירעה שגיאה בעת אפשור שילוב דפדפן." }, - "browserIntegrationMasOnlyDesc": { - "message": "למרבה הצער שילוב דפדפן נתמך רק בגרסת Mac App Store לעת עתה." - }, "browserIntegrationWindowsStoreDesc": { "message": "למרבה הצער שילוב דפדפן אינו נתמך כרגע בגרסת ה־Microsoft Store." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "פסק זמן מותאם אישית מינימלי הוא דקה 1." + }, "inviteAccepted": { "message": "ההזמנה התקבלה" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "רק הכספת הארגונית המשויכת עם $ORGANIZATION$ תיוצא.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "רק הכספת הארגונית המשויכת עם $ORGANIZATION$ תיוצא. פריטי האוספים שלי לא יכללו.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "נעול" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "הצג פחות" }, - "enableAutotype": { - "message": "הפעל הקלדה אוטומטית" - }, "enableAutotypeDescription": { "message": "Bitwarden לא מאמת את מקומות הקלט, נא לוודא שזה החלון והשדה הנכונים בטרם שימוש בקיצור הדרך." }, + "typeShortcut": { + "message": "הקלד קיצור דרך" + }, + "editAutotypeShortcutDescription": { + "message": "כלול אחד או שניים ממקשי הצירוף הבאים: Ctrl, Alt, Win, או Shift, ואות." + }, + "invalidShortcut": { + "message": "קיצור דרך לא חוקי" + }, "moreBreadcrumbs": { "message": "עוד סימני דרך", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,40 +4147,81 @@ "confirm": { "message": "אשר" }, - "enableAutotypeTransitionKey": { - "message": "הפעל קיצור דרך להקלדה אוטומטית" + "enableAutotypeShortcutPreview": { + "message": "הפעל קיצור דרך להקלדה אוטומטית (תצוגה תכונה מקדימה)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "וודא שאתה נמצא בשדה הנכון לפני השימוש בקיצור הדרך כדי להימנע ממילוי נתונים במקום הלא נכון." }, "editShortcut": { "message": "ערוך קיצור דרך" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "ארכיון", + "description": "Noun" }, - "unarchive": { - "message": "Unarchive" + "archiveVerb": { + "message": "העבר לארכיון", + "description": "Verb" + }, + "unArchive": { + "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": "פריטים בארכיון יופיעו כאן ויוחרגו מתוצאות חיפוש כללי והצעות למילוי אוטומטי." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "הפריט נשלח לארכיון" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "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?" + "message": "פריטים בארכיון מוחרגים מתוצאות חיפוש כללי והצעות למילוי אוטומטי. האם אתה בטוח שברצונך להעביר פריט זה לארכיון?" + }, + "zipPostalCodeLabel": { + "message": "מיקוד" + }, + "cardNumberLabel": { + "message": "מספר כרטיס" + }, + "upgradeNow": { + "message": "שדרג עכשיו" + }, + "builtInAuthenticator": { + "message": "מאמת מובנה" + }, + "secureFileStorage": { + "message": "אחסון קבצים מאובטח" + }, + "emergencyAccess": { + "message": "גישת חירום" + }, + "breachMonitoring": { + "message": "ניטור פרצות" + }, + "andMoreFeatures": { + "message": "ועוד!" + }, + "planDescPremium": { + "message": "השלם אבטחה מקוונת" + }, + "upgradeToPremium": { + "message": "שדרג לפרימיום" + }, + "sessionTimeoutSettingsAction": { + "message": "פעולת פסק זמן" + }, + "sessionTimeoutHeader": { + "message": "פסק זמן להפעלה" } } diff --git a/apps/desktop/src/locales/hi/messages.json b/apps/desktop/src/locales/hi/messages.json index 25ecbdf3840..2ab323eedc9 100644 --- a/apps/desktop/src/locales/hi/messages.json +++ b/apps/desktop/src/locales/hi/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/hr/messages.json b/apps/desktop/src/locales/hr/messages.json index 64f89a8b15f..0f7a8185118 100644 --- a/apps/desktop/src/locales/hr/messages.json +++ b/apps/desktop/src/locales/hr/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "Nemaš dozvolu za uređivanje ove stavke" + }, "welcomeBack": { "message": "Dobro došli natrag" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Jedinstvena prijava (SSO)" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Tvoja organizacija zahtijeva jedinstvenu prijavu." + }, "submit": { "message": "Pošalji" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Moraš dodati ili osnovni URL poslužitelja ili barem jedno prilagođeno okruženje." }, + "selfHostedEnvMustUseHttps": { + "message": "URL mora koristiti HTTPS." + }, "customEnvironment": { "message": "Prilagođeno okruženje" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Neispravna glavna lozinka" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Nevažeća glavna lozinka. Provjeri je li tvoja adresa e-pošta ispravna i je li račun kreiran na $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Prijava dvostrukom autentifikacijom čini tvoj račun još sigurnijim tako što će zahtijevati potvrdu prijave drugim uređajem kao što je sigurnosni ključ, autentifikatorska aplikacija, SMS, poziv ili e-pošta. Prijavu dvostrukom autentifikacijom možeš omogućiti na web trezoru. Želiš li sada posjetiti bitwarden.com?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Zaključaj glavnom lozinkom kod svakog pokretanja" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Traži glavnu lozinku ili PIN kod ponovnog pokretanja aplikacije" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Traži glavnu lozinku kod ponovnog pokretanja aplikacije" + }, "deleteAccount": { "message": "Obriši račun" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Pogreška prillikom integracije s preglednikom." }, - "browserIntegrationMasOnlyDesc": { - "message": "Nažalost, za sada je integracija s preglednikom podržana samo u Mac App Store verziji aplikacije." - }, "browserIntegrationWindowsStoreDesc": { "message": "Nažalost, integracija s preglednikom trenutno nije podržana u Windows Store verziji aplikacije." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Najmanje prilagođeno vrijeme je 1 minuta." + }, "inviteAccepted": { "message": "Pozivnica prihvaćena" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Izvezt će se samo trezor organizacije povezan s $ORGANIZATION$.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Izvezt će se samo trezor organizacije povezan s $ORGANIZATION$. Zbirka mojih stavki neće biti uključena.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Zaključano" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Pokaži manje" }, - "enableAutotype": { - "message": "Omogući automatski unos" - }, "enableAutotypeDescription": { "message": "Bitwarden ne provjerava lokacije unosa, prije korištenja prečaca provjeri da si u pravom prozoru i polju." }, + "typeShortcut": { + "message": "Vrsta prečaca" + }, + "editAutotypeShortcutDescription": { + "message": "Uključi jedan ili dva modifikatora: Ctrl, Alt, Win ili Shift i slovo." + }, + "invalidShortcut": { + "message": "Nevažeći prečac" + }, "moreBreadcrumbs": { "message": "Više mrvica", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Potvrdi" }, - "enableAutotypeTransitionKey": { - "message": "Omogući prečac za automatsko tipkanje" + "enableAutotypeShortcutPreview": { + "message": "Uključi prečac Autotype (Pretpregled značajke)" }, - "enableAutotypeDescriptionTransitionKey": { - "message": "Prije korištenja prečaca provjeri nalaziš li se u ispravnom polju kako se podaci ne bi unijeli na pogrešno mjesto." + "enableAutotypeShortcutDescription": { + "message": "Prije korištenja prečaca provjeri jesi li u ispravnom polju kako podaci ne bi bili uneseni na pogrešno mjesto." }, "editShortcut": { "message": "Uredi prečac" }, - "archive": { - "message": "Arhiviraj" + "archiveNoun": { + "message": "Arhiva", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Arhiviraj", + "description": "Verb" + }, + "unArchive": { "message": "Poništi arhiviranje" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Arhivirane stavke biti će prikazane ovdje i biti će izuzete iz rezultata općih pretraga i preporuka auto-ispune." }, - "itemSentToArchive": { + "itemWasSentToArchive": { "message": "Stavka poslana u arhivu" }, - "itemRemovedFromArchive": { - "message": "Stavka maknute iz arhive" + "itemWasUnarchived": { + "message": "Stavka vraćena iz arhive" }, "archiveItem": { "message": "Arhiviraj stavku" }, "archiveItemConfirmDesc": { "message": "Arhivirane stavke biti će izuzete iz rezultata općih pretraga i preporuka auto-ispune. Sigurno želiš arhivirati?" + }, + "zipPostalCodeLabel": { + "message": "Poštanski broj" + }, + "cardNumberLabel": { + "message": "Broj kartice" + }, + "upgradeNow": { + "message": "Nadogradi sada" + }, + "builtInAuthenticator": { + "message": "Ugrađeni autentifikator" + }, + "secureFileStorage": { + "message": "Sigurna pohrana datoteka" + }, + "emergencyAccess": { + "message": "Pristup u nuždi" + }, + "breachMonitoring": { + "message": "Nadzor proboja" + }, + "andMoreFeatures": { + "message": "I više!" + }, + "planDescPremium": { + "message": "Dovrši online sigurnost" + }, + "upgradeToPremium": { + "message": " Nadogradi na Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Radnja nakon isteka" + }, + "sessionTimeoutHeader": { + "message": "Istek sesije" } } diff --git a/apps/desktop/src/locales/hu/messages.json b/apps/desktop/src/locales/hu/messages.json index 9f71448ce5a..9a6dd787f8c 100644 --- a/apps/desktop/src/locales/hu/messages.json +++ b/apps/desktop/src/locales/hu/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "Nincs jogosulltság ezen elem szerkesztéséhez." + }, "welcomeBack": { "message": "Üdvözlet újra" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Egyszeri bejelentkezés használata" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "A szervezet egyszeri bejelentkezést igényel." + }, "submit": { "message": "Beküldés" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Hozzá kell adni az alapszerver webcímét vagy legalább egy egyedi környezetet." }, + "selfHostedEnvMustUseHttps": { + "message": "A webcímeknek HTTPS-t kell használniuk." + }, "customEnvironment": { "message": "Egyedi környezet" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "A mesterjelszó érvénytelen." }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "A mesterjelszó érvénytelen. Erősítsük meg, hogy email cím helyes és a fiók létrehozásának helye: $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "A kétlépcsős bejelentkezés biztonságosabbá teszi a fiókot azzal, hogy meg kell erősíteni a bejelentkezést egy másik olyan eszközzel mint például biztonsági kulcs, hitelesítő alkalmazás, SMS, telefonhívás vagy email. A kétlépcsős bejelentkezést a bitwarden.com webes széfben lehet megváltoztatni. Szeretnénk felkeresni most a webhelyet?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lezárás mesterjelszóval újraindításkor" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Mesterjelszó vagy PIN kód szükséges az alkalmazás indításakor." + }, + "requireMasterPasswordOnAppRestart": { + "message": "Mesterjelszó szükséges az alkalmazás indításakor." + }, "deleteAccount": { "message": "Fiók törlése" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Hiba történt a böngésző integrációjának engedélyezése közben." }, - "browserIntegrationMasOnlyDesc": { - "message": "Sajnos a böngésző integrációt egyelőre csak a Mac App Store verzió támogatja." - }, "browserIntegrationWindowsStoreDesc": { "message": "A böngésző integrációt egyelőre csak a Windows Store verzió támogatja." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "A minimális egyedi időkifutás 1 perc." + }, "inviteAccepted": { "message": "A meghívás elfogadásra került." }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Csak a $ORGANIZATION$ szervezetehez kapcsolódó szervezeti széf kerül exportálásra.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Csak a $ORGANIZATION$ szervezethez kapcsolódó szervezeti széf kerül exportálásra. A saját elem gyűjtemények nem lesznek benne.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Lezárva" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Kevesebb megjelenítése" }, - "enableAutotype": { - "message": "Autotípus engedélyezése" - }, "enableAutotypeDescription": { "message": "A Bitwarden nem érvényesíti a beviteli helyeket, győződjünk meg róla, hogy a megfelelő ablakban és mezőben vagyunk, mielőtt a parancsikont használnánk." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "További morzsamenük", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Megerősítés" }, - "enableAutotypeTransitionKey": { - "message": "Automatikus típusú parancsikon engedélyezése" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Győződjünk meg arról, hogy a megfelelő mezőben vagyunk, mielőtt a parancsikont használnánk, hogy elkerüljük az adatok rossz helyre történő kitöltését." }, "editShortcut": { "message": "Parancsikon szerkesztése" }, - "archive": { - "message": "Archívum" + "archiveNoun": { + "message": "Archívum", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archívum", + "description": "Verb" + }, + "unArchive": { "message": "Visszavétel archívumból" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Az archivált elemek itt jelennek meg és kizárásra kerülnek az általános keresési eredményekből és az automatikus kitöltési javaslatokból." }, - "itemSentToArchive": { - "message": "Archívumba küldött elemek száma" + "itemWasSentToArchive": { + "message": "Az elem az archivumba került." }, - "itemRemovedFromArchive": { - "message": "Az elem kikerült az archívumból." + "itemWasUnarchived": { + "message": "Az elem visszavéelre került az archivumból." }, "archiveItem": { "message": "Elem archiválása" }, "archiveItemConfirmDesc": { "message": "Az archivált elemek ki vannak zárva az általános keresési eredményekből és az automatikus kitöltési javaslatokból. Biztosan archiválni szeretnénk ezt az elemet?" + }, + "zipPostalCodeLabel": { + "message": "Irányítószám" + }, + "cardNumberLabel": { + "message": "Kártya szám" + }, + "upgradeNow": { + "message": "Áttérés most" + }, + "builtInAuthenticator": { + "message": "Beépített hitelesítő" + }, + "secureFileStorage": { + "message": "Biztonságos fájl tárolás" + }, + "emergencyAccess": { + "message": "Sürgősségi hozzáférés" + }, + "breachMonitoring": { + "message": "Adatszivárgás figyelés" + }, + "andMoreFeatures": { + "message": "És még több!" + }, + "planDescPremium": { + "message": "Teljes körű online biztonság" + }, + "upgradeToPremium": { + "message": "Áttérés Prémium csomagra" + }, + "sessionTimeoutSettingsAction": { + "message": "Időkifutási művelet" + }, + "sessionTimeoutHeader": { + "message": "Munkamenet időkifutás" } } diff --git a/apps/desktop/src/locales/id/messages.json b/apps/desktop/src/locales/id/messages.json index 3f44fd8bf97..188ee153da1 100644 --- a/apps/desktop/src/locales/id/messages.json +++ b/apps/desktop/src/locales/id/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Kirim" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Lingkungan Kustom" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Sandi utama tidak valid" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Info masuk dua langkah membuat akun Anda lebih aman dengan mengharuskan Anda memverifikasi info masuk Anda dengan peranti lain seperti kode keamanan, aplikasi autentikasi, SMS, panggilan telepon, atau email. Info masuk dua langkah dapat diaktifkan di brankas web bitwarden.com. Anda ingin mengunjungi situs web sekarang?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Hapus akun" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Sayangnya integrasi browser hanya didukung di versi Mac App Store untuk saat ini." - }, "browserIntegrationWindowsStoreDesc": { "message": "Sayangnya integrasi browser saat ini tidak didukung di versi Windows Store." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Terkunci" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/it/messages.json b/apps/desktop/src/locales/it/messages.json index 780d09f3582..8caf4982356 100644 --- a/apps/desktop/src/locales/it/messages.json +++ b/apps/desktop/src/locales/it/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "Non hai i permessi per modificare questo elemento" + }, "welcomeBack": { "message": "Bentornato" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Usa il Single Sign-On" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "La tua organizzazione richiede un accesso Single Sign-On (SSO)." + }, "submit": { "message": "Invia" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Devi aggiungere lo URL del server di base o almeno un ambiente personalizzato." }, + "selfHostedEnvMustUseHttps": { + "message": "Gli indirizzi devono usare il protocollo HTTPS." + }, "customEnvironment": { "message": "Ambiente personalizzato" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Password principale errata" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Password principale errata. Verifica che l'email sia corretta e che l'account sia stato creato su $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "La verifica in due passaggi rende il tuo account più sicuro richiedendoti di verificare il tuo login usando un altro dispositivo come una chiave di sicurezza, app di autenticazione, SMS, telefonata, o email. Può essere abilitata nella cassaforte web su bitwarden.com. Vuoi visitare il sito?" }, @@ -1303,7 +1321,7 @@ "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Mostra le icone dei siti e recupera gli URL di cambio password" }, "enableMinToTray": { "message": "Minimizza nell'area di notifica" @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Blocca con password principale al riavvio" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Richiedi la password principale dopo il riavvio" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Richiedi la password principale dopo il riavvio" + }, "deleteAccount": { "message": "Elimina account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Si è verificato un errore durante l'attivazione dell'integrazione del browser." }, - "browserIntegrationMasOnlyDesc": { - "message": "Purtroppo l'integrazione del browser è supportata solo nella versione nell'App Store per ora." - }, "browserIntegrationWindowsStoreDesc": { "message": "Purtroppo l'integrazione del browser non è supportata nella versione del Microsoft Store per ora." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Il timeout personalizzato minimo è di 1 minuto." + }, "inviteAccepted": { "message": "Invito accettato" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Solo la cassaforte dell'organizzazione associata a $ORGANIZATION$ sarà esportata.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Solo la cassaforte dell'organizzazione associata a $ORGANIZATION$ sarà esportata. Eventuali raccolte di elementi non saranno incluse.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Bloccato" }, @@ -3586,10 +3628,10 @@ "message": "Continua ad accedere utilizzando le tue credenziali aziendali." }, "importDirectlyFromBrowser": { - "message": "Import directly from browser" + "message": "Importa direttamente dal browser" }, "browserProfile": { - "message": "Browser Profile" + "message": "Profilo Browser" }, "seeDetailedInstructions": { "message": "Consulta le istruzioni dettagliate sul nostro sito di assistenza su", @@ -3834,10 +3876,10 @@ "message": "Modifica la password non sicura o esposta" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "message": "Questo login è a rischio e non contiene un sito web. Aggiungi un sito web e cambia la password per maggiore sicurezza." }, "missingWebsite": { - "message": "Missing website" + "message": "Sito Web mancante" }, "cannotRemoveViewOnlyCollections": { "message": "Non puoi rimuovere raccolte con i soli permessi di visualizzazione: $COLLECTIONS$", @@ -3935,10 +3977,10 @@ "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "aboutThisSetting": { - "message": "About this setting" + "message": "Informazioni su questa opzione" }, "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." + "message": "Bitwarden userà gli URL memorizzati in ogni login per mostrare, se possibile, l'icona del sito Web e l'URL di modifica password per facilitare la modifica delle credenziali. Nessuna informazione è raccolta o archiviata per il funzionamento di questo servizio." }, "assignToCollections": { "message": "Assegna a una raccolta" @@ -4080,12 +4122,18 @@ "showLess": { "message": "Mostra di meno" }, - "enableAutotype": { - "message": "Abilita auto immissione" - }, "enableAutotypeDescription": { "message": "Bitwarden non convalida i campi di input: assicurati di essere nella finestra e nel campo di testo corretti prima di usare la scorciatoia." }, + "typeShortcut": { + "message": "Premi i tasti da impostare per la scorciatoia" + }, + "editAutotypeShortcutDescription": { + "message": "Includi uno o due dei seguenti modificatori: Ctrl, Alt, Win, o Shift, più una lettera." + }, + "invalidShortcut": { + "message": "Scorciatoia non valida" + }, "moreBreadcrumbs": { "message": "Ulteriori segmenti", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,40 +4147,81 @@ "confirm": { "message": "Conferma" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Abilita la scorciatoia automatica (beta)" }, - "enableAutotypeDescriptionTransitionKey": { - "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." + "enableAutotypeShortcutDescription": { + "message": "Assicurati di essere nel campo corretto prima di usare la scorciatoia." }, "editShortcut": { - "message": "Edit shortcut" + "message": "Modifica scorciatoia" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archivio", + "description": "Noun" }, - "unarchive": { - "message": "Unarchive" + "archiveVerb": { + "message": "Archivia", + "description": "Verb" + }, + "unArchive": { + "message": "Rimuovi dall'Archivio" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Elementi archiviati" }, "noItemsInArchive": { - "message": "No items in archive" + "message": "Nessun elemento nell'archivio" }, "noItemsInArchiveDesc": { - "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + "message": "Gli elementi archiviati appariranno qui e saranno esclusi dai risultati di ricerca e dall'auto-riempimento." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Elemento archiviato" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Elemento rimosso dall'archivio" }, "archiveItem": { - "message": "Archive item" + "message": "Archivia elemento" }, "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "message": "Gli elementi archiviati sono esclusi dai risultati di ricerca e dall'auto-riempimento. Vuoi davvero archiviare questo elemento?" + }, + "zipPostalCodeLabel": { + "message": "CAP / codice postale" + }, + "cardNumberLabel": { + "message": "Numero carta" + }, + "upgradeNow": { + "message": "Aggiorna ora" + }, + "builtInAuthenticator": { + "message": "App di autenticazione integrata" + }, + "secureFileStorage": { + "message": "Archiviazione sicura di file" + }, + "emergencyAccess": { + "message": "Accesso di emergenza" + }, + "breachMonitoring": { + "message": "Monitoraggio delle violazioni" + }, + "andMoreFeatures": { + "message": "E molto altro!" + }, + "planDescPremium": { + "message": "Sicurezza online completa" + }, + "upgradeToPremium": { + "message": "Aggiorna a Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Azione al timeout" + }, + "sessionTimeoutHeader": { + "message": "Timeout della sessione" } } diff --git a/apps/desktop/src/locales/ja/messages.json b/apps/desktop/src/locales/ja/messages.json index a58543302fa..ca50828b12c 100644 --- a/apps/desktop/src/locales/ja/messages.json +++ b/apps/desktop/src/locales/ja/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "ようこそ" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "シングルサインオンを使用する" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "送信" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "ベース サーバー URL または少なくとも 1 つのカスタム環境を追加する必要があります。" }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "カスタム環境" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "マスターパスワードが間違っています" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "2段階認証を使うと、ログイン時にセキュリティキーや認証アプリ、SMS、電話やメールでの認証を必要にすることでアカウントをさらに安全に出来ます。2段階認証は bitwarden.com ウェブ保管庫で有効化できます。ウェブサイトを開きますか?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "再起動時にマスターパスワードでロック" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "アカウントを削除" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "ブラウザー統合の有効化中にエラーが発生しました。" }, - "browserIntegrationMasOnlyDesc": { - "message": "残念ながら、ブラウザ統合は、Mac App Storeのバージョンでのみサポートされています。" - }, "browserIntegrationWindowsStoreDesc": { "message": "残念ながらお使いの Microsoft Store のバージョンではブラウザの統合に対応していません。" }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "招待が承認されました" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "ロック中" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/ka/messages.json b/apps/desktop/src/locales/ka/messages.json index 0bb7e929979..9337286d3fd 100644 --- a/apps/desktop/src/locales/ka/messages.json +++ b/apps/desktop/src/locales/ka/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -262,7 +265,7 @@ "message": "Remember until vault is locked" }, "premiumRequired": { - "message": "Premium required" + "message": "საჭიროა პრემიუმი" }, "premiumRequiredDesc": { "message": "A Premium membership is required to use this feature." @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "გადაცემა" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?" }, @@ -1601,7 +1619,7 @@ "message": "ყველაფრის ჩვენება" }, "quitBitwarden": { - "message": "Quit Bitwarden" + "message": "გამოდით Bitwarden-იდან" }, "valueCopied": { "message": "$VALUE$ copied", @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "ანგარიშის წაშლა" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "დაბლოკილია" }, @@ -3852,7 +3894,7 @@ "message": "Move" }, "newFolder": { - "message": "New folder" + "message": "ახალი საქაღალდე" }, "folderName": { "message": "Folder Name" @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/km/messages.json b/apps/desktop/src/locales/km/messages.json index 5849d9d4cee..d607bb8d097 100644 --- a/apps/desktop/src/locales/km/messages.json +++ b/apps/desktop/src/locales/km/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/kn/messages.json b/apps/desktop/src/locales/kn/messages.json index 66a6e43d0cb..d1375efee8c 100644 --- a/apps/desktop/src/locales/kn/messages.json +++ b/apps/desktop/src/locales/kn/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "ಒಪ್ಪಿಸು" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "ಕಸ್ಟಮ್ ಪರಿಸರ" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "ಅಮಾನ್ಯ ಮಾಸ್ಟರ್ ಪಾಸ್‌ವರ್ಡ್" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "ಭದ್ರತಾ ಕೀ, ದೃಢೀಕರಣ ಅಪ್ಲಿಕೇಶನ್, ಎಸ್‌ಎಂಎಸ್, ಫೋನ್ ಕರೆ ಅಥವಾ ಇಮೇಲ್‌ನಂತಹ ಮತ್ತೊಂದು ಸಾಧನದೊಂದಿಗೆ ನಿಮ್ಮ ಲಾಗಿನ್ ಅನ್ನು ಪರಿಶೀಲಿಸುವ ಅಗತ್ಯವಿರುವ ಎರಡು ಹಂತದ ಲಾಗಿನ್ ನಿಮ್ಮ ಖಾತೆಯನ್ನು ಹೆಚ್ಚು ಸುರಕ್ಷಿತಗೊಳಿಸುತ್ತದೆ. ಬಿಟ್ವಾರ್ಡೆನ್.ಕಾಮ್ ವೆಬ್ ವಾಲ್ಟ್ನಲ್ಲಿ ಎರಡು-ಹಂತದ ಲಾಗಿನ್ ಅನ್ನು ಸಕ್ರಿಯಗೊಳಿಸಬಹುದು. ನೀವು ಈಗ ವೆಬ್‌ಸೈಟ್‌ಗೆ ಭೇಟಿ ನೀಡಲು ಬಯಸುವಿರಾ?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "ದುರದೃಷ್ಟವಶಾತ್ ಬ್ರೌಸರ್ ಏಕೀಕರಣವನ್ನು ಇದೀಗ ಮ್ಯಾಕ್ ಆಪ್ ಸ್ಟೋರ್ ಆವೃತ್ತಿಯಲ್ಲಿ ಮಾತ್ರ ಬೆಂಬಲಿಸಲಾಗುತ್ತದೆ." - }, "browserIntegrationWindowsStoreDesc": { "message": "ದುರದೃಷ್ಟವಶಾತ್ ವಿಂಡೋಸ್ ಸ್ಟೋರ್ ಆವೃತ್ತಿಯಲ್ಲಿ ಬ್ರೌಸರ್ ಏಕೀಕರಣವನ್ನು ಪ್ರಸ್ತುತ ಬೆಂಬಲಿಸುವುದಿಲ್ಲ." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/ko/messages.json b/apps/desktop/src/locales/ko/messages.json index 59423b8ad73..2e40b8d7f23 100644 --- a/apps/desktop/src/locales/ko/messages.json +++ b/apps/desktop/src/locales/ko/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "돌아온 것을 환영합니다" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Single sign-on(SSO) 사용" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "보내기" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "사용자 지정 환경" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "잘못된 마스터 비밀번호" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "2단계 인증은 보안 키, 인증 앱, SMS, 전화 통화 등의 다른 기기로 사용자의 로그인 시도를 검증하여 사용자의 계정을 더욱 안전하게 만듭니다. 2단계 인증은 bitwarden.com 웹 보관함에서 활성화할 수 있습니다. 지금 웹 사이트를 방문하시겠습니까?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "계정 삭제" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "브라우저와 연결은 현재 Mac App Store 버전에서만 지원됩니다." - }, "browserIntegrationWindowsStoreDesc": { "message": "현재 Microsoft Store 버전에서는 브라우저와 연결이 지원되지 않습니다." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "잠김" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/lt/messages.json b/apps/desktop/src/locales/lt/messages.json index 8159bc5e28b..16f328d6240 100644 --- a/apps/desktop/src/locales/lt/messages.json +++ b/apps/desktop/src/locales/lt/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Išsaugoti" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Individualizuota aplinka" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Neteisingas pagrindinis slaptažodis" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Prisijungus dviem veiksmais, jūsų paskyra tampa saugesnė, reikalaujant patvirtinti prisijungimą naudojant kitą įrenginį, pvz., Saugos raktą, autentifikavimo programą, SMS, telefono skambutį ar el. Paštą. Dviejų žingsnių prisijungimą galima įjungti „bitwarden.com“ interneto saugykloje. Ar norite dabar apsilankyti svetainėje?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Ištrinti paskyrą" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Deja, bet naršyklės integravimas kol kas palaikomas tik Mac App Store versijoje." - }, "browserIntegrationWindowsStoreDesc": { "message": "Deja, bet naršyklės integravimas nepalaikomas Microsoft Store versijoje." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Užrakinta" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/lv/messages.json b/apps/desktop/src/locales/lv/messages.json index 5e29f10190b..7800a4e9024 100644 --- a/apps/desktop/src/locales/lv/messages.json +++ b/apps/desktop/src/locales/lv/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "Nav nepieciešamo atļauju, lai labotu šo vienumu" + }, "welcomeBack": { "message": "Laipni lūdzam atpakaļ" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Izmantot vienoto pieteikšanos" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Tava apvienība pieprasa vienoto pieteikšanos." + }, "submit": { "message": "Iesniegt" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Jāpievieno vai no servera pamata URL vai vismaz viena pielāgota vide." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Pielāgota vide" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Nederīga galvenā parole" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Nederīga galvenā parole. Jāpārliecinās, ka e-pasta adrese ir pareiza un konts tika izveidots $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Divpakāpju pieteikšanās padara kontu krietni drošāku, pieprasot apstiprināt pieteikšanos ar tādu citu ierīču vai pakalpojumu starpniecību kā drošības atslēga, autentificētāja lietotne, īsziņa, tālruņa zvans vai e-pasts. Divpakāpju pieteikšanos var iespējot bitwarden.com tīmekļa glabātavā. Vai tagad apmeklēt tīmekļvietni?" }, @@ -1369,7 +1387,7 @@ "message": "Valoda" }, "languageDesc": { - "message": "Mainīt lietotnes valodu. Ir nepieciešama pārsāknēšana." + "message": "Mainīt lietotnes valodu. Ir nepieciešama atkārtota palaišana." }, "theme": { "message": "Izskats" @@ -1405,7 +1423,7 @@ "message": "Pārsāknēt, lai atjauninātu" }, "restartToUpdateDesc": { - "message": "Versija $VERSION_NUM$ ir gatava uzstādīšanai. Ir jāpārsāknē lietotne, lai pabeigtu uzstādīšanu. Vai pārsāknēt un atjaunināt tagad?", + "message": "Versija $VERSION_NUM$ ir gatava uzstādīšanai. Lietotne ir jāpalaiž no jauna, lai pabeigtu uzstādīšanu. Vai palaist no jauna un atjaunināt tagad?", "placeholders": { "version_num": { "content": "$1", @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Aizslēgt ar galveno paroli pēc pārsāknēšanas" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Pieprasīt galveno paroli vai PIN pēc lietotnes atkārtotas palaišanas" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Pieprasīt galveno paroli pēc lietotnes atkārtotas palaišanas" + }, "deleteAccount": { "message": "Izdzēst kontu" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Atgadījās kļūda pārlūka saistīšanas iespējošanas laikā." }, - "browserIntegrationMasOnlyDesc": { - "message": "Diemžēl sasaistīšāna ar pārlūku pagaidām ir nodrošināta tikai Mac App Store laidienā." - }, "browserIntegrationWindowsStoreDesc": { "message": "Diemžēl sasaistīšana ar pārlūku pagaidām nav nodrošināta Windows veikala laidienā." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Mazākā pieļaujamā pielāgotā noildze ir 1 minūte." + }, "inviteAccepted": { "message": "Uzaicinājums apstiprināts" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Aizslēgta" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Rādīt mazāk" }, - "enableAutotype": { - "message": "Iespējot automātisko ievadi" - }, "enableAutotypeDescription": { "message": "Bitwarden nepārbauda ievades atrašanās vietas, jāpārliecinās, ka atrodies pareizajā logā un laukā, pirms saīsnes izmantošanas." }, + "typeShortcut": { + "message": "Ievadīt īsinājumtaustiņus" + }, + "editAutotypeShortcutDescription": { + "message": "Jāiekļauj viens vai divi no šiem taustiņiem - Ctrl, Alt, Win vai Shift - un burts." + }, + "invalidShortcut": { + "message": "Nederīgi īsinājumtaustiņi" + }, "moreBreadcrumbs": { "message": "Vairāk norāžu", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Apstiprināt" }, - "enableAutotypeTransitionKey": { - "message": "Iespējot automātiskās ievades saīsni" + "enableAutotypeShortcutPreview": { + "message": "Iespējot automātiskās ievades īsinājumtaustiņus (iespējas priekšskatījums)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Jāpārliecinās, ka pirms saīsnes izmantošanas kursors ir pareizajā laukā, lai izvairītos no datu ievadīšanas nepareizā vietā." }, "editShortcut": { "message": "Labot saīsni" }, - "archive": { - "message": "Arhivēt" + "archiveNoun": { + "message": "Arhīvs", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Arhivēt", + "description": "Verb" + }, + "unArchive": { "message": "Atcelt arhivēšanu" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Šeit parādīsies arhivētie vienumi, un tie netiks iekļauti vispārējās meklēšanas iznākumos un automātiskās aizpildes ieteikumos." }, - "itemSentToArchive": { - "message": "Vienums ievietots arhīvā" + "itemWasSentToArchive": { + "message": "Vienums tika ievietots arhīvā" }, - "itemRemovedFromArchive": { - "message": "Vienums izņemts no arhīva" + "itemWasUnarchived": { + "message": "Vienums tika izņemts no arhīva" }, "archiveItem": { "message": "Arhivēt vienumu" }, "archiveItemConfirmDesc": { "message": "Arhivētie vienumi netiek iekļauti vispārējās meklēšanas iznākumos un automātiskās aizpildes ieteikumos. Vai tiešām ahrivēt šo vienumu?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Pasta indekss" + }, + "cardNumberLabel": { + "message": "Kartes numurs" + }, + "upgradeNow": { + "message": "Uzlabot tagad" + }, + "builtInAuthenticator": { + "message": "Iebūvēts autentificētājs" + }, + "secureFileStorage": { + "message": "Droša datņu krātuve" + }, + "emergencyAccess": { + "message": "Ārkārtas piekļuve" + }, + "breachMonitoring": { + "message": "Noplūžu pārraudzīšana" + }, + "andMoreFeatures": { + "message": "Un vēl!" + }, + "planDescPremium": { + "message": "Pilnīga drošība tiešsaistē" + }, + "upgradeToPremium": { + "message": "Uzlabot uz Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Noildzes darbība" + }, + "sessionTimeoutHeader": { + "message": "Sesijas noildze" } } diff --git a/apps/desktop/src/locales/me/messages.json b/apps/desktop/src/locales/me/messages.json index b023d0efab0..29e3cefee0c 100644 --- a/apps/desktop/src/locales/me/messages.json +++ b/apps/desktop/src/locales/me/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Podnesi" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Prilagođeno okruženje" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Nevažeća glavna lozinka" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Prijavljivanje u dva koraka čini vaš nalog sigurnijim tako što ćete morati da verifikujete prijavu na drugom uređaju, kao što su bezbjedonosni ključ, aplikacija za potvrđivanje, SMS, telefonski poziv ili e-pošta. Prijava u dva koraka može se omogućiti u trezoru na internet strani bitwarden.com. Da li želite da posjetite internet lokaciju sada?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/ml/messages.json b/apps/desktop/src/locales/ml/messages.json index 863f3941a0f..662ce9a1fc6 100644 --- a/apps/desktop/src/locales/ml/messages.json +++ b/apps/desktop/src/locales/ml/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "സമർപ്പിക്കുക" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "ഇഷ്‌ടാനുസൃത എൻവിയോണ്മെന്റ്" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "അസാധുവായ പ്രാഥമിക പാസ്‌വേഡ്" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "സുരക്ഷാ കീ, ഓതന്റിക്കേറ്റർ അപ്ലിക്കേഷൻ, SMS, ഫോൺ കോൾ അല്ലെങ്കിൽ ഇമെയിൽ പോലുള്ള മറ്റൊരു ഉപകരണം ഉപയോഗിച്ച് തങ്ങളുടെ ലോഗിൻ സ്ഥിരീകരിക്കാൻ ആവശ്യപ്പെടുന്നതിലൂടെ രണ്ട്-ഘട്ട ലോഗിൻ തങ്ങളുടെ അക്കൗണ്ടിനെ കൂടുതൽ സുരക്ഷിതമാക്കുന്നു. bitwarden.com വെബ് വാൾട്ടിൽ രണ്ട്-ഘട്ട ലോഗിൻ പ്രവർത്തനക്ഷമമാക്കാനാകും.തങ്ങള്ക്കു ഇപ്പോൾ വെബ്സൈറ്റ് സന്ദർശിക്കാൻ ആഗ്രഹമുണ്ടോ?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/mr/messages.json b/apps/desktop/src/locales/mr/messages.json index 5849d9d4cee..d607bb8d097 100644 --- a/apps/desktop/src/locales/mr/messages.json +++ b/apps/desktop/src/locales/mr/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/my/messages.json b/apps/desktop/src/locales/my/messages.json index fae67d310f1..bcbd26cede3 100644 --- a/apps/desktop/src/locales/my/messages.json +++ b/apps/desktop/src/locales/my/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/nb/messages.json b/apps/desktop/src/locales/nb/messages.json index ec1c1bdb9b5..42fb6d479c0 100644 --- a/apps/desktop/src/locales/nb/messages.json +++ b/apps/desktop/src/locales/nb/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Velkommen tilbake" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Bruk singulær pålogging" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Send inn" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Tilpasset miljø" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Ugyldig hovedpassord" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "2-trinnsinnlogging gjør kontoen din mer sikker, ved å kreve at du verifiserer din innlogging med en annen enhet, f.eks. en autentiseringsapp, SMS, E-post, telefonsamtale, eller sikkerhetsnøkkel. 2-trinnsinnlogging kan aktiveres på bitwarden.com-netthvelvet. Vil du besøke den nettsiden nå?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Slett konto" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Nettleserintegrasjon støttes dessverre bare i Mac App Store-versjonen for øyeblikket." - }, "browserIntegrationWindowsStoreDesc": { "message": "Nettleserintegrasjon er for øyeblikket dessverre ikke støttet i Windows Store-versjonen." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitasjon akseptert" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Låst" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/ne/messages.json b/apps/desktop/src/locales/ne/messages.json index 813fa967252..cce8f6a2ba5 100644 --- a/apps/desktop/src/locales/ne/messages.json +++ b/apps/desktop/src/locales/ne/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/nl/messages.json b/apps/desktop/src/locales/nl/messages.json index c726c003776..82b51b018c5 100644 --- a/apps/desktop/src/locales/nl/messages.json +++ b/apps/desktop/src/locales/nl/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "Je hebt geen toestemming om dit item te bewerken" + }, "welcomeBack": { "message": "Welkom terug" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Single sign-on gebruiken" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Je organisatie vereist single sign-on." + }, "submit": { "message": "Opslaan" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Je moet de basisserver-URL of ten minste één aangepaste omgeving toevoegen." }, + "selfHostedEnvMustUseHttps": { + "message": "URL's moeten HTTPS gebruiken." + }, "customEnvironment": { "message": "Aangepaste omgeving" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Ongeldig hoofdwachtwoord" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Ongeldig hoofdwachtwoord. Check of je e-mailadres klopt en of je account is aangemaakt op $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Tweestapsaanmelding beschermt je account door je inlogpoging te bevestigen met een ander apparaat zoals een beveiligingssleutel, authenticatie-app, SMS, spraakoproep of e-mail. Je kunt Tweestapsaanmelding inschakelen in de webkluis op bitwarden.com. Wil je de website nu bezoeken?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Bij herstart vergrendelen met hoofdwachtwoord" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Hoofdwachtwoord of pincode vereisen bij herstart van de app" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Hoofdwachtwoord vereisen bij herstart van de app" + }, "deleteAccount": { "message": "Account verwijderen" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Er is iets misgegaan bij het tijdens het inschakelen van de browserintegratie." }, - "browserIntegrationMasOnlyDesc": { - "message": "Helaas wordt browserintegratie momenteel alleen ondersteund in de Mac App Store-versie." - }, "browserIntegrationWindowsStoreDesc": { "message": "Helaas wordt browserintegratie momenteel niet ondersteund in de Windows Store-versie." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimale aangepaste time-out is 1 minuut." + }, "inviteAccepted": { "message": "Uitnodiging geaccepteerd" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Alleen de organisatiekluis die gekoppeld is aan $ORGANIZATION$ wordt geëxporteerd.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Exporteert alleen de organisatiekluis van $ORGANIZATION$. Geen persoonlijke kluis-items.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Vergrendeld" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Minder weergeven" }, - "enableAutotype": { - "message": "Autotypen inschakelen" - }, "enableAutotypeDescription": { "message": "Bitwarden valideert de invoerlocaties niet, zorg ervoor dat je je in het juiste venster en veld bevindt voordat je de snelkoppeling gebruikt." }, + "typeShortcut": { + "message": "Typ de sneltoets" + }, + "editAutotypeShortcutDescription": { + "message": "Voeg een of twee van de volgende toetsen toe: Ctrl, Alt, Win of Shift, en een letter." + }, + "invalidShortcut": { + "message": "Ongeldige sneltoets" + }, "moreBreadcrumbs": { "message": "Meer broodkruimels", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Bevestigen" }, - "enableAutotypeTransitionKey": { - "message": "Snelkoppeling autotype inschakelen" + "enableAutotypeShortcutPreview": { + "message": "Autotype sneltoets inschakelen (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { - "message": "Zorg ervoor dat je je in het juiste veld bevindt voordat je de snelkoppeling gebruikt om te voorkomen dat je de gegevens op de verkeerde plaats invult." + "enableAutotypeShortcutDescription": { + "message": "Zorg ervoor dat je je in het juiste veld staat voordat je de snelkoppeling gebruikt om te voorkomen dat je de gegevens op de verkeerde plaats invult." }, "editShortcut": { "message": "Snelkoppeling bewerken" }, - "archive": { - "message": "Archiveren" + "archiveNoun": { + "message": "Archief", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archiveren", + "description": "Verb" + }, + "unArchive": { "message": "Dearchiveren" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Gearchiveerde items verschijnen hier en worden uitgesloten van algemene zoekresultaten en automatisch invulsuggesties." }, - "itemSentToArchive": { + "itemWasSentToArchive": { "message": "Item naar archief verzonden" }, - "itemRemovedFromArchive": { - "message": "Item verwijderd uit archief" + "itemWasUnarchived": { + "message": "Item uit het archief gehaald" }, "archiveItem": { "message": "Item archiveren" }, "archiveItemConfirmDesc": { "message": "Gearchiveerde items worden uitgesloten van algemene zoekresultaten en automatische invulsuggesties. Weet je zeker dat je dit item wilt archiveren?" + }, + "zipPostalCodeLabel": { + "message": "Postcode" + }, + "cardNumberLabel": { + "message": "Kaartnummer" + }, + "upgradeNow": { + "message": "Nu upgraden" + }, + "builtInAuthenticator": { + "message": "Ingebouwde authenticator" + }, + "secureFileStorage": { + "message": "Beveiligde bestandsopslag" + }, + "emergencyAccess": { + "message": "Noodtoegang" + }, + "breachMonitoring": { + "message": "Lek-monitoring" + }, + "andMoreFeatures": { + "message": "En meer!" + }, + "planDescPremium": { + "message": "Online beveiliging voltooien" + }, + "upgradeToPremium": { + "message": "Opwaarderen naar Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Time-out actie" + }, + "sessionTimeoutHeader": { + "message": "Sessietime-out" } } diff --git a/apps/desktop/src/locales/nn/messages.json b/apps/desktop/src/locales/nn/messages.json index 94c2196edfa..08567979e8b 100644 --- a/apps/desktop/src/locales/nn/messages.json +++ b/apps/desktop/src/locales/nn/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Send inn" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Ugyldig hovudpassord" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/or/messages.json b/apps/desktop/src/locales/or/messages.json index 217439bec80..4ca05acaac5 100644 --- a/apps/desktop/src/locales/or/messages.json +++ b/apps/desktop/src/locales/or/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/pl/messages.json b/apps/desktop/src/locales/pl/messages.json index 6caee67447f..c05e7f05cb1 100644 --- a/apps/desktop/src/locales/pl/messages.json +++ b/apps/desktop/src/locales/pl/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Witaj ponownie" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Użyj logowania jednokrotnego" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Wyślij" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Musisz dodać podstawowy adres URL serwera lub co najmniej jedno niestandardowe środowisko." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Niestandardowe środowisko" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Hasło główne jest nieprawidłowe" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Logowanie dwustopniowe zwiększa bezpieczeństwo konta, wymagając weryfikacji logowania za pomocą innego urządzenia, takiego jak klucz bezpieczeństwa, aplikacja uwierzytelniająca, wiadomość SMS, połączenie telefoniczne lub wiadomość e-mail. Logowanie dwustopniowe możesz skonfigurować w sejfie internetowym bitwarden.com. Czy chcesz przejść do strony?" }, @@ -1706,7 +1724,7 @@ "description": "ex. Date this item was created" }, "datePasswordUpdated": { - "message": "Hasło zostało zaktualizowane", + "message": "Ostatnia aktualizacja hasła", "description": "ex. Date this password was updated" }, "exportFrom": { @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Zablokuj hasłem głównym po uruchomieniu ponownym" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Wymagaj hasła głównego lub PIN po uruchomieniu aplikacji" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Wymagaj hasła głównego po uruchomieniu aplikacji" + }, "deleteAccount": { "message": "Usuń konto" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Wystąpił błąd podczas włączania połączenia z przeglądarką." }, - "browserIntegrationMasOnlyDesc": { - "message": "Połączenie z przeglądarką jest obsługiwane tylko z wersją aplikacji ze sklepu Mac App Store." - }, "browserIntegrationWindowsStoreDesc": { "message": "Połączenie z przeglądarką nie jest obecnie obsługiwane w aplikacji w wersji Windows Store." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimalny niestandardowy czas to 1 minuta." + }, "inviteAccepted": { "message": "Zaproszenie zostało zaakceptowane" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Tylko sejf organizacji $ORGANIZATION$ zostanie wyeksportowany.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Tylko sejf organizacji $ORGANIZATION$ zostanie wyeksportowany. Twoje kolekcje nie zostaną uwzględnione.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Zablokowane" }, @@ -3774,7 +3816,7 @@ "message": "Potwierdź użycie klucza SSH" }, "agentForwardingWarningTitle": { - "message": "Warning: Agent Forwarding" + "message": "Ostrzeżenie: Przekazywanie agenta" }, "agentForwardingWarningText": { "message": "Żądanie pochodzi ze zdalnego zalogowanego urządzenia" @@ -4080,12 +4122,18 @@ "showLess": { "message": "Pokaż mniej" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Rodzaj skrótu" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Skrót jest nieprawidłowy" + }, "moreBreadcrumbs": { "message": "Więcej nawigacji", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Potwierdź" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edytuj skrót" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archiwum", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archiwizuj", + "description": "Verb" + }, + "unArchive": { "message": "Usuń z archiwum" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Zarchiwizowane elementy pojawią się tutaj i zostaną wykluczone z wyników wyszukiwania i sugestii autouzupełniania." }, - "itemSentToArchive": { + "itemWasSentToArchive": { "message": "Element został przeniesiony do archiwum" }, - "itemRemovedFromArchive": { + "itemWasUnarchived": { "message": "Element został usunięty z archiwum" }, "archiveItem": { "message": "Archiwizuj element" }, "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "message": "Zarchiwizowane elementy są wykluczone z wyników wyszukiwania i sugestii autouzupełniania. Czy na pewno chcesz archiwizować element?" + }, + "zipPostalCodeLabel": { + "message": "Kod pocztowy" + }, + "cardNumberLabel": { + "message": "Numer karty" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/pt_BR/messages.json b/apps/desktop/src/locales/pt_BR/messages.json index 4b28ca4918a..288f8e42616 100644 --- a/apps/desktop/src/locales/pt_BR/messages.json +++ b/apps/desktop/src/locales/pt_BR/messages.json @@ -6,7 +6,7 @@ "message": "Filtros" }, "allItems": { - "message": "Todos os Itens" + "message": "Todos os itens" }, "favorites": { "message": "Favoritos" @@ -24,10 +24,10 @@ "message": "Identidade" }, "typeNote": { - "message": "Notas" + "message": "Anotação" }, "typeSecureNote": { - "message": "Nota Segura" + "message": "Anotação segura" }, "typeSshKey": { "message": "Chave SSH" @@ -36,16 +36,16 @@ "message": "Pastas" }, "collections": { - "message": "Coleções" + "message": "Conjuntos" }, "searchVault": { - "message": "Pesquisar no Cofre" + "message": "Buscar no cofre" }, "resetSearch": { - "message": "Redefinir pesquisa" + "message": "Apagar busca" }, "addItem": { - "message": "Adicionar Item" + "message": "Adicionar item" }, "shared": { "message": "Compartilhado" @@ -54,7 +54,7 @@ "message": "Compartilhar" }, "moveToOrganization": { - "message": "Mover para a organização" + "message": "Mover para organização" }, "movedItemToOrg": { "message": "$ITEMNAME$ movido para $ORGNAME$", @@ -69,8 +69,11 @@ } } }, + "noEditPermissions": { + "message": "Você não tem permissão para editar este item" + }, "welcomeBack": { - "message": "Bem vindo de volta" + "message": "Boas-vindas de volta" }, "moveToOrgDesc": { "message": "Escolha uma organização para a qual deseja mover este item. Mudar para uma organização transfere a propriedade do item para essa organização. Você não será mais o proprietário direto deste item depois que ele for movido." @@ -79,7 +82,7 @@ "message": "Anexos" }, "viewItem": { - "message": "Ver Item" + "message": "Ver item" }, "name": { "message": "Nome" @@ -101,37 +104,37 @@ "message": "Novo URI" }, "username": { - "message": "Nome de Usuário" + "message": "Nome de usuário" }, "password": { "message": "Senha" }, "passphrase": { - "message": "Frase Secreta" + "message": "Frase secreta" }, "editItem": { - "message": "Editar Item" + "message": "Editar item" }, "emailAddress": { "message": "Endereço de e-mail" }, "verificationCodeTotp": { - "message": "Código de Verificação (TOTP)" + "message": "Código de verificação (TOTP)" }, "website": { "message": "Site" }, "notes": { - "message": "Notas" + "message": "Anotações" }, "customFields": { - "message": "Campos Personalizados" + "message": "Campos personalizados" }, "launch": { "message": "Abrir" }, "copyValue": { - "message": "Copiar Valor", + "message": "Copiar valor", "description": "Copy value to clipboard" }, "minimizeOnCopyToClipboard": { @@ -141,14 +144,14 @@ "message": "Minimizar ao copiar dados de um item para a área de transferência." }, "toggleVisibility": { - "message": "Alternar Visibilidade" + "message": "Habilitar visibilidade" }, "toggleCollapse": { - "message": "Alternar colapso", + "message": "Guardar/mostrar", "description": "Toggling an expand/collapse state." }, "cardholderName": { - "message": "Titular do Cartão" + "message": "Nome do titular do cartão" }, "number": { "message": "Número" @@ -160,22 +163,22 @@ "message": "Vencimento" }, "securityCode": { - "message": "Código de Segurança" + "message": "Código de segurança" }, "identityName": { - "message": "Nome de Identidade" + "message": "Nome da identidade" }, "company": { "message": "Empresa" }, "ssn": { - "message": "Número de Segurança Social" + "message": "Cadastro de Pessoas Físicas (CPF)" }, "passportNumber": { - "message": "Número do Passaporte" + "message": "Número do passaporte" }, "licenseNumber": { - "message": "Número da Licença" + "message": "Número da licença" }, "email": { "message": "E-mail" @@ -202,19 +205,19 @@ "message": "ED25519" }, "sshKeyAlgorithmRSA2048": { - "message": "RSA 2048-Bit" + "message": "RSA de 2048 bits" }, "sshKeyAlgorithmRSA3072": { - "message": "RSA 3072-Bit" + "message": "RSA de 3072 bits" }, "sshKeyAlgorithmRSA4096": { - "message": "RSA 4096-Bit" + "message": "RSA de 4096 bits" }, "sshKeyGenerated": { "message": "Uma nova chave SSH foi gerada" }, "sshKeyWrongPassword": { - "message": "Sua senha está incorreta." + "message": "A senha que você digitou está incorreta." }, "importSshKey": { "message": "Importar" @@ -223,31 +226,31 @@ "message": "Confirme a senha" }, "enterSshKeyPasswordDesc": { - "message": "Digite a senha para a chave SSH." + "message": "Digite a senha da chave SSH." }, "enterSshKeyPassword": { - "message": "Insira sua senha" + "message": "Digite a senha" }, "sshAgentUnlockRequired": { - "message": "Por favor, desbloqueie seu cofre para aprovar a solicitação de chave SSH." + "message": "Desbloqueie seu cofre para aprovar a solicitação de chave SSH." }, "sshAgentUnlockTimeout": { - "message": "Solicitação de chave SSH expirada." + "message": "A solicitação da chave SSH expirou." }, "enableSshAgent": { - "message": "Habilitar SSH agent" + "message": "Ativar agente SSH" }, "enableSshAgentDesc": { - "message": "Permitir que o agente SSH assine solicitações SSH diretamente do seu cofre Bitwarden." + "message": "Ative o agente SSH para assinar solicitações SSH diretamente do seu cofre Bitwarden." }, "enableSshAgentHelp": { "message": "O agente SSH é um serviço direcionado a desenvolvedores que permite que você assine solicitações SSH diretamente do seu cofre do Bitwarden." }, "sshAgentPromptBehavior": { - "message": "Solicitar autorização ao usar o agente SSH" + "message": "Pedir autorização ao usar o agente SSH" }, "sshAgentPromptBehaviorDesc": { - "message": "Defina como lidar com as solicitações de autorização do agente-SSH." + "message": "Escolha como lidar com as solicitações de autorização do agente SSH." }, "sshAgentPromptBehaviorHelp": { "message": "Lembrar das autorizações de SSH" @@ -259,13 +262,13 @@ "message": "Nunca" }, "sshAgentPromptBehaviorRememberUntilLock": { - "message": "Lembrar até o repositório estar trancado" + "message": "Lembrar até que o cofre seja bloqueado" }, "premiumRequired": { - "message": "Requer Assinatura Premium" + "message": "Requer Premium" }, "premiumRequiredDesc": { - "message": "Uma conta premium é necessária para usar esse recurso." + "message": "Um plano Premium é necessário para usar esse recurso." }, "errorOccurred": { "message": "Ocorreu um erro." @@ -277,10 +280,10 @@ "message": "Erro ao descriptografar" }, "couldNotDecryptVaultItemsBelow": { - "message": "O Bitwarden não pode descriptografar o(s) item(ns) do cofre listado abaixo." + "message": "O Bitwarden não pôde descriptografar o(s) item(ns) listados abaixo do cofre." }, "contactCSToAvoidDataLossPart1": { - "message": "Contato com o cliente feito com sucesso", + "message": "Contate o costumer success", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "contactCSToAvoidDataLossPart2": { @@ -324,7 +327,7 @@ "message": "Dezembro" }, "ex": { - "message": "ex.", + "message": "p. ex.", "description": "Short abbreviation for 'example'." }, "title": { @@ -340,16 +343,16 @@ "message": "Sra" }, "mx": { - "message": "Mx" + "message": "Sre" }, "dr": { "message": "Dr" }, "expirationMonth": { - "message": "Mês de Vencimento" + "message": "Mês de vencimento" }, "expirationYear": { - "message": "Ano de Vencimento" + "message": "Ano de vencimento" }, "select": { "message": "Selecionar" @@ -361,13 +364,13 @@ "message": "Tipo" }, "firstName": { - "message": "Primeiro Nome" + "message": "Primeiro nome" }, "middleName": { - "message": "Nome do Meio" + "message": "Nome do meio" }, "lastName": { - "message": "Último Nome" + "message": "Sobrenome" }, "fullName": { "message": "Nome completo" @@ -382,13 +385,13 @@ "message": "Endereço 3" }, "cityTown": { - "message": "Cidade / Localidade" + "message": "Cidade ou localidade" }, "stateProvince": { - "message": "Estado / Província" + "message": "Estado ou província" }, "zipPostalCode": { - "message": "CEP / Código Postal" + "message": "CEP / Código postal" }, "country": { "message": "País" @@ -400,19 +403,19 @@ "message": "Cancelar" }, "delete": { - "message": "Excluir" + "message": "Apagar" }, "favorite": { - "message": "Favorito" + "message": "Favoritar" }, "edit": { "message": "Editar" }, "authenticatorKeyTotp": { - "message": "Chave de Autenticação (TOTP)" + "message": "Chave do autenticador (TOTP)" }, "authenticatorKey": { - "message": "Chave de autenticação" + "message": "Chave do autenticador" }, "autofillOptions": { "message": "Opções de preenchimento automático" @@ -437,7 +440,7 @@ "message": "Adicionar site" }, "deleteWebsite": { - "message": "Deletar site" + "message": "Apagar site" }, "owner": { "message": "Proprietário" @@ -449,13 +452,13 @@ "message": "Editar campo" }, "permanentlyDeleteAttachmentConfirmation": { - "message": "Tem certeza de que deseja deletar permanentemente esse anexo?" + "message": "Tem certeza que quer apagar este anexo para sempre?" }, "fieldType": { "message": "Tipo do campo" }, "fieldLabel": { - "message": "Rótulo de campo" + "message": "Rótulo do campo" }, "add": { "message": "Adicionar" @@ -467,25 +470,25 @@ "message": "Use campos ocultos para dados sensíveis como senhas" }, "checkBoxHelpText": { - "message": "Use caixas de seleção caso deseje preencher automaticamente as caixas de seleção de um formulário, como um email de lembrete" + "message": "Use caixas de seleção se gostaria de preencher automaticamente a caixa de seleção de um formulário, como um lembrar e-mail" }, "linkedHelpText": { - "message": "Use um campo vinculado quando você estiver experienciando problemas de preenchimento automático para um site específico." + "message": "Use um campo vinculado quando você estiver experienciando problemas com o preenchimento automático em um site específico." }, "linkedLabelHelpText": { - "message": "Digite o ID html, nome, rótulo acessível (aria-label), ou espaço reservado do campo." + "message": "Digite o ID html, nome, aria-label, ou placeholder do campo." }, "folder": { "message": "Pasta" }, "newCustomField": { - "message": "Novo Campo Personalizado" + "message": "Novo campo personalizado" }, "value": { "message": "Valor" }, "dragToSort": { - "message": "Arrastar para ordenar" + "message": "Arraste para ordenar" }, "cfTypeText": { "message": "Texto" @@ -511,69 +514,69 @@ "message": "Remover" }, "nameRequired": { - "message": "Requer o nome." + "message": "O nome é necessário." }, "addedItem": { "message": "Item adicionado" }, "editedItem": { - "message": "Item editado" + "message": "Item salvo" }, "deleteItem": { - "message": "Excluir Item" + "message": "Apagar item" }, "deleteFolder": { - "message": "Excluir Pasta" + "message": "Apagar pasta" }, "deleteAttachment": { - "message": "Excluir Anexo" + "message": "Apagar anexo" }, "deleteItemConfirmation": { "message": "Você realmente deseja enviar para a lixeira?" }, "deletedItem": { - "message": "Item excluído" + "message": "Item enviado para a lixeira" }, "overwritePasswordConfirmation": { "message": "Você tem certeza que deseja substituir a senha atual?" }, "overwriteUsername": { - "message": "Sobrescrever usuário" + "message": "Sobrescrever nome de usuário" }, "overwriteUsernameConfirmation": { - "message": "Tem certeza que deseja substituir o usuário atual?" + "message": "Tem certeza que quer substituir o nome de usuário atual?" }, "noneFolder": { - "message": "Nenhuma Pasta", + "message": "Sem pasta", "description": "This is the folder for uncategorized items" }, "addFolder": { - "message": "Adicionar Pasta" + "message": "Adicionar pasta" }, "editFolder": { - "message": "Editar Pasta" + "message": "Editar pasta" }, "regeneratePassword": { - "message": "Gerar Nova Senha" + "message": "Regerar senha" }, "copyPassword": { - "message": "Copiar Senha" + "message": "Copiar senha" }, "regenerateSshKey": { "message": "Regerar chave SSH" }, "copySshPrivateKey": { - "message": "Copiar chave SSH privada" + "message": "Copiar chave privada da chave SSH" }, "copyPassphrase": { - "message": "Copiar senha", + "message": "Copiar frase secreta", "description": "Copy passphrase to clipboard" }, "copyUri": { "message": "Copiar URI" }, "copyVerificationCodeTotp": { - "message": "Copiar Código de Verificação (TOTP)" + "message": "Copiar código de verificação (TOTP)" }, "copyFieldCipherName": { "message": "Copiar $FIELD$, $CIPHERNAME$", @@ -593,7 +596,7 @@ "message": "Comprimento" }, "passwordMinLength": { - "message": "Tamanho mínimo da senha" + "message": "Comprimento mínimo da senha" }, "uppercase": { "message": "Maiúsculas (A-Z)", @@ -649,7 +652,7 @@ "message": "Separador de palavras" }, "capitalize": { - "message": "Iniciais em Maiúsculas", + "message": "Iniciais maiúsculas", "description": "Make the first letter of a word uppercase." }, "includeNumber": { @@ -659,14 +662,14 @@ "message": "Fechar" }, "minNumbers": { - "message": "Números Mínimos" + "message": "Mínimo de números" }, "minSpecial": { - "message": "Especiais Mínimos", + "message": "Mínimo de caracteres especiais", "description": "Minimum Special Characters" }, "ambiguous": { - "message": "Evitar Caracteres Ambíguos", + "message": "Evitar caracteres ambíguos", "description": "deprecated. Use avoidAmbiguous instead." }, "avoidAmbiguous": { @@ -674,120 +677,123 @@ "description": "Label for the avoid ambiguous characters checkbox." }, "generatorPolicyInEffect": { - "message": "Os requisitos de política empresarial foram aplicados às suas opções de gerador.", + "message": "Os requisitos da política empresarial foram aplicados às opções do seu gerador.", "description": "Indicates that a policy limits the credential generator screen." }, "searchCollection": { - "message": "Pesquisar coleção" + "message": "Buscar no conjunto" }, "searchFolder": { - "message": "Pesquisar pasta" + "message": "Buscar na pasta" }, "searchFavorites": { - "message": "Pesquisar favoritos" + "message": "Buscar nos favoritos" }, "searchType": { - "message": "Pesquisar tipo", + "message": "Buscar tipo", "description": "Search item type" }, "newAttachment": { - "message": "Adicionar Novo Anexo" + "message": "Adicionar novo anexo" }, "deletedAttachment": { - "message": "Anexo excluído" + "message": "Anexo apagado" }, "deleteAttachmentConfirmation": { - "message": "Tem certeza que deseja excluir esse anexo?" + "message": "Tem certeza que quer apagar esse anexo?" }, "attachmentSaved": { - "message": "O anexo foi salvo." + "message": "Anexo salvo" }, "addAttachment": { "message": "Adicionar anexo" }, "maxFileSizeSansPunctuation": { - "message": "Tamanho máximo do arquivo é 500 MB" + "message": "O tamanho máximo do arquivo é de 500 MB" }, "file": { "message": "Arquivo" }, "selectFile": { - "message": "Selecione um arquivo." + "message": "Selecione um arquivo" }, "maxFileSize": { "message": "O tamanho máximo do arquivo é de 500 MB." }, "legacyEncryptionUnsupported": { - "message": "A criptografia legada não é mais suportada. Por favor, contate o suporte para recuperar a sua conta." + "message": "A criptografia legada não é mais suportada. Entre em contato com o suporte para recuperar a sua conta." }, "editedFolder": { - "message": "Pasta editada" + "message": "Pasta salva" }, "addedFolder": { "message": "Pasta adicionada" }, "deleteFolderConfirmation": { - "message": "Você tem certeza que deseja excluir esta pasta?" + "message": "Tem certeza que deseja apagar esta pasta?" }, "deletedFolder": { - "message": "Pasta excluída" + "message": "Pasta apagada" }, "loginOrCreateNewAccount": { - "message": "Inicie a sessão ou crie uma nova conta para acessar seu cofre seguro." + "message": "Conecte-se ou crie uma nova conta para acessar seu cofre seguro." }, "createAccount": { - "message": "Criar Conta" + "message": "Criar conta" }, "newToBitwarden": { "message": "Novo no Bitwarden?" }, "setAStrongPassword": { - "message": "Defina uma senha forte" + "message": "Configure uma senha forte" }, "finishCreatingYourAccountBySettingAPassword": { - "message": "Termine de criar a sua conta definindo uma senha" + "message": "Termine de criar a sua conta configurando uma senha" }, "logIn": { - "message": "Iniciar sessão" + "message": "Conectar-se" }, "logInToBitwarden": { - "message": "Inicie a sessão no Bitwarden" + "message": "Conecte-se ao Bitwarden" }, "enterTheCodeSentToYourEmail": { - "message": "Digite o código enviado por e-mail" + "message": "Digite o código enviado ao seu e-mail" }, "enterTheCodeFromYourAuthenticatorApp": { - "message": "Insira o código do seu aplicativo autenticador" + "message": "Digite o código do seu aplicativo autenticador" }, "pressYourYubiKeyToAuthenticate": { - "message": "Pressione seu YubiKey para autenticar" + "message": "Pressione sua YubiKey para autenticar-se" }, "logInWithPasskey": { - "message": "Iniciar sessão com a chave de acesso" + "message": "Conectar-se com chave de acesso" }, "loginWithDevice": { - "message": "Fazer login com dispositivo" + "message": "Conectar-se com dispositivo" }, "useSingleSignOn": { - "message": "Usar login único" + "message": "Usar autenticação única" + }, + "yourOrganizationRequiresSingleSignOn": { + "message": "A sua organização requer o uso da autenticação única." }, "submit": { "message": "Enviar" }, "masterPass": { - "message": "Senha Mestra" + "message": "Senha principal" }, "masterPassDesc": { - "message": "A senha mestra é a senha que você usa para acessar o seu cofre. É muito importante que você não esqueça sua senha mestra. Não há maneira de recuperar a senha caso você se esqueça." + "message": "A senha principal é a senha que você usa para acessar o seu cofre. É muito importante que você não esqueça sua senha principal. Não há maneira de recuperar a senha caso você se esqueça." }, "masterPassHintDesc": { - "message": "Uma dica de senha mestra pode ajudá-lo(a) a lembrá-lo(a) caso você esqueça." + "message": "Uma dica para a senha principal pode ajudá-lo(a) a lembrá-la caso você esqueça." }, "reTypeMasterPass": { - "message": "Digite Novamente a Senha Mestra" + "message": "Digite novamente a senha principal" }, "masterPassHint": { - "message": "Dica da Senha Mestra (opcional)" + "message": "Dica da senha principal (opcional)" }, "masterPassHintText": { "message": "Se você esquecer sua senha, a dica da senha pode ser enviada ao seu e-mail. $CURRENT$/$MAXIMUM$ caracteres máximos.", @@ -803,16 +809,16 @@ } }, "masterPassword": { - "message": "Senha mestra" + "message": "Senha principal" }, "masterPassImportant": { - "message": "Sua senha mestra não pode ser recuperada se você a esquecer!" + "message": "Sua senha principal não pode ser recuperada se você esquecê-la!" }, "confirmMasterPassword": { - "message": "Confirme a senha mestra" + "message": "Confirme a senha principal" }, "masterPassHintLabel": { - "message": "Dica da senha mestra" + "message": "Dica da senha principal" }, "passwordStrengthScore": { "message": "Pontuação de força da senha $SCORE$", @@ -827,7 +833,7 @@ "message": "Juntar-se à organização" }, "joinOrganizationName": { - "message": "Entrar em $ORGANIZATIONNAME$", + "message": "Juntar-se à $ORGANIZATIONNAME$", "placeholders": { "organizationName": { "content": "$1", @@ -836,25 +842,25 @@ } }, "finishJoiningThisOrganizationBySettingAMasterPassword": { - "message": "Termine de juntar-se nessa organização definindo uma senha mestra." + "message": "Termine de juntar-se à esta organização configurando uma senha principal." }, "settings": { "message": "Configurações" }, "accountEmail": { - "message": "Email da conta" + "message": "E-mail da conta" }, "requestHint": { - "message": "Pedir dica" + "message": "Solicitar dica" }, "requestPasswordHint": { - "message": "Dica da senha mestra" + "message": "Solicitar dica da senha" }, "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou": { "message": "Digite o endereço de e-mail da sua conta e sua dica da senha será enviada para você" }, "getMasterPasswordHint": { - "message": "Obter dica da senha mestra" + "message": "Receber dica da senha principal" }, "emailRequired": { "message": "O endereço de e-mail é obrigatório." @@ -863,13 +869,13 @@ "message": "Endereço de e-mail inválido." }, "masterPasswordRequired": { - "message": "A senha mestra é obrigatória." + "message": "A senha principal é obrigatória." }, "confirmMasterPasswordRequired": { - "message": "É necessário redigitar a senha mestra." + "message": "É necessário redigitar a senha principal." }, "masterPasswordMinlength": { - "message": "A senha mestra deve ter pelo menos $VALUE$ caracteres.", + "message": "A senha principal deve ter pelo menos $VALUE$ caracteres.", "description": "The Master Password must be at least a specific number of characters long.", "placeholders": { "value": { @@ -879,31 +885,31 @@ } }, "youSuccessfullyLoggedIn": { - "message": "Você logou na sua conta com sucesso" + "message": "Você conectou-se à sua conta com sucesso" }, "youMayCloseThisWindow": { "message": "Você pode fechar esta janela" }, "masterPassDoesntMatch": { - "message": "A confirmação da senha mestra não corresponde." + "message": "A confirmação da senha principal não corresponde." }, "newAccountCreated": { - "message": "A sua nova conta foi criada! Agora você pode iniciar a sessão." + "message": "A sua nova conta foi criada! Agora você pode se conectar." }, "newAccountCreated2": { "message": "Sua nova conta foi criada!" }, "youHaveBeenLoggedIn": { - "message": "Você está conectado!" + "message": "Você foi conectado!" }, "masterPassSent": { - "message": "Enviamos um e-mail com a dica da sua senha mestra." + "message": "Enviamos um e-mail com a dica da sua senha principal." }, "unexpectedError": { "message": "Ocorreu um erro inesperado." }, "itemInformation": { - "message": "Informação do Item" + "message": "Informações do item" }, "noItemsInList": { "message": "Não há itens para listar." @@ -918,19 +924,19 @@ "message": "Código enviado" }, "verificationCode": { - "message": "Código de Verificação" + "message": "Código de verificação" }, "confirmIdentity": { "message": "Confirme a sua identidade para continuar." }, "verificationCodeRequired": { - "message": "Requer o código de verificação." + "message": "O código de verificação é necessário." }, "webauthnCancelOrTimeout": { - "message": "A autenticação foi cancelada ou demorou muito. Por favor tente novamente." + "message": "A autenticação foi cancelada ou demorou muito. Tente novamente." }, "openInNewTab": { - "message": "Abrir numa nova aba" + "message": "Abrir em uma nova aba" }, "invalidVerificationCode": { "message": "Código de verificação inválido" @@ -958,16 +964,16 @@ "message": "Use seu código de recuperação" }, "insertU2f": { - "message": "Insira a sua chave de segurança na porta USB do seu computador. Se ele tiver um botão, toque nele." + "message": "Insira a sua chave de segurança na porta USB do seu computador. Se ela tiver um botão, toque nele." }, "recoveryCodeTitle": { - "message": "Código de Recuperação" + "message": "Código de recuperação" }, "authenticatorAppTitle": { - "message": "Aplicativo de Autenticação" + "message": "Aplicativo autenticador" }, "authenticatorAppDescV2": { - "message": "Insira um código gerado por um aplicativo autenticador como o Bitwarden Authenticator.", + "message": "Digite um código gerado por um aplicativo autenticador como o Bitwarden Authenticator.", "description": "'Bitwarden Authenticator' is a product name and should not be translated." }, "yubiKeyTitleV2": { @@ -977,7 +983,7 @@ "message": "Utilize uma YubiKey para acessar a sua conta. Funciona com YubiKey 4, 4 Nano, 4C, e dispositivos NEO." }, "duoDescV2": { - "message": "Insira um código gerado pelo Duo Security.", + "message": "Digite um código gerado pelo Duo Security.", "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "duoOrganizationDesc": { @@ -991,7 +997,7 @@ "message": "Não reconhecemos este dispositivo. Digite o código enviado por e-mail para verificar a sua identidade." }, "continueLoggingIn": { - "message": "Continue acessando" + "message": "Continuar acessando" }, "webAuthnTitle": { "message": "WebAuthn FIDO2" @@ -1006,19 +1012,19 @@ "message": "Digite o código enviado para seu e-mail." }, "loginUnavailable": { - "message": "Sessão Indisponível" + "message": "Autenticação indisponível" }, "noTwoStepProviders": { - "message": "Esta conta tem a autenticação por duas etapas ativado, no entanto, nenhum dos provedores de início de sessão em duas etapas configurados são suportados por este dispositivo." + "message": "Esta conta tem a autenticação em duas etapas ativada, no entanto, nenhum dos provedores de autenticação em duas etapas configurados são suportados por este dispositivo." }, "noTwoStepProviders2": { - "message": "Por favor inclua provedores adicionais que são melhor suportados entre dispositivos (como um aplicativo de autenticação)." + "message": "Adicione provedores adicionais que são melhor suportados entre dispositivos (como um aplicativo autenticador)." }, "twoStepOptions": { - "message": "Opções de Login em Duas Etapas" + "message": "Opções de autenticação em duas etapas" }, "selectTwoStepLoginMethod": { - "message": "Selecionar método de login em duas etapas" + "message": "Selecionar método de autenticação em duas etapas" }, "selfHostedEnvironment": { "message": "Ambiente auto-hospedado" @@ -1027,44 +1033,47 @@ "message": "Especifique a URL de base da sua instalação local do Bitwarden. Exemplo: https://bitwarden.company.com" }, "selfHostedCustomEnvHeader": { - "message": "Para usuários avançados. Você pode especificar a URL de base de cada serviço independentemente." + "message": "Para configuração avançada, você pode especificar a URL de base de cada serviço independentemente." }, "selfHostedEnvFormInvalid": { - "message": "Você deve adicionar um URL do servidor de base ou pelo menos um ambiente personalizado." + "message": "Você deve adicionar um URL de base de um servidor ou pelo menos um ambiente personalizado." + }, + "selfHostedEnvMustUseHttps": { + "message": "URLs devem usar HTTPS." }, "customEnvironment": { - "message": "Ambiente Personalizado" + "message": "Ambiente personalizado" }, "baseUrl": { - "message": "URL do Servidor" + "message": "URL do servidor" }, "authenticationTimeout": { "message": "Tempo de autenticação esgotado" }, "authenticationSessionTimedOut": { - "message": "A sessão de autenticação expirou. Por favor, reinicie o processo de login." + "message": "A sessão de autenticação expirou. Reinicie o processo de autenticação." }, "selfHostBaseUrl": { - "message": "URL do servidor auto-host", + "message": "URL do servidor auto-hospedado", "description": "Label for field requesting a self-hosted integration service URL" }, "apiUrl": { - "message": "URL do Servidor da API" + "message": "URL do servidor da API" }, "webVaultUrl": { - "message": "URL do Servidor do Cofre Web" + "message": "URL do servidor do cofre web" }, "identityUrl": { - "message": "URL do Servidor de Identidade" + "message": "URL do servidor de identidade" }, "notificationsUrl": { "message": "URL do servidor de notificações" }, "iconsUrl": { - "message": "URL do Servidor de Ícones" + "message": "URL do servidor de ícones" }, "environmentSaved": { - "message": "As URLs de ambiente foram salvas" + "message": "URLs de ambiente salvos" }, "ok": { "message": "Ok" @@ -1079,16 +1088,16 @@ "message": "Localização" }, "overwritePassword": { - "message": "Substituir Senha" + "message": "Substituir senha" }, "learnMore": { - "message": "Saber mais" + "message": "Saiba mais" }, "featureUnavailable": { - "message": "Recurso Indisponível" + "message": "Recurso indisponível" }, "loggedOut": { - "message": "Sessão encerrada" + "message": "Desconectado" }, "loggedOutDesc": { "message": "Você foi desconectado de sua conta." @@ -1097,13 +1106,13 @@ "message": "A sua sessão expirou." }, "restartRegistration": { - "message": "Reiniciar registro" + "message": "Reiniciar cadastro" }, "expiredLink": { "message": "Link expirado" }, "pleaseRestartRegistrationOrTryLoggingIn": { - "message": "Por favor, reinicie o registro ou tente fazer login." + "message": "Reinicie o cadastro ou tente conectar-se." }, "youMayAlreadyHaveAnAccount": { "message": "Você pode já ter uma conta" @@ -1112,13 +1121,13 @@ "message": "Você tem certeza que deseja sair?" }, "logOut": { - "message": "Encerrar a Sessão" + "message": "Desconectar" }, "addNewLogin": { - "message": "Adicionar Nova Credencial" + "message": "Nova credencial" }, "addNewItem": { - "message": "Adicionar Novo Item" + "message": "Novo item" }, "view": { "message": "Ver" @@ -1133,19 +1142,19 @@ "message": "Bloquear cofre" }, "passwordGenerator": { - "message": "Gerador de Senha" + "message": "Gerador de senhas" }, "contactUs": { - "message": "Fale Conosco" + "message": "Contate-nos" }, "helpAndFeedback": { - "message": "Ajuda e feedback" + "message": "Ajuda e retorno" }, "getHelp": { - "message": "Obter Ajuda" + "message": "Receber ajuda" }, "fileBugReport": { - "message": "Relate um bug" + "message": "Relatar um bug" }, "blog": { "message": "Blog" @@ -1154,42 +1163,42 @@ "message": "Siga-nos" }, "syncVault": { - "message": "Sincronizar Cofre" + "message": "Sincronizar cofre" }, "changeMasterPass": { - "message": "Alterar Senha Mestra" + "message": "Alterar senha principal" }, "continueToWebApp": { "message": "Continuar no aplicativo web?" }, "changeMasterPasswordOnWebConfirmation": { - "message": "Você pode alterar a sua senha mestra no aplicativo web Bitwarden." + "message": "Você pode alterar a sua senha principal no aplicativo web Bitwarden." }, "fingerprintPhrase": { "message": "Frase biométrica", "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": "A sua frase biométrica", + "message": "A frase biométrica da sua conta", "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." }, "goToWebVault": { - "message": "Ir para o Cofre Web" + "message": "Ir para o cofre web" }, "getMobileApp": { - "message": "Obter o aplicativo para celular" + "message": "Baixar o app para celular" }, "getBrowserExtension": { - "message": "Obter a extensão de navegador" + "message": "Baixar a extensão para navegador" }, "syncingComplete": { - "message": "Sincronização completada" + "message": "Sincronização concluída" }, "syncingFailed": { - "message": "A sincronização falhou" + "message": "Falha na sincronização" }, "yourVaultIsLocked": { - "message": "Seu cofre está trancado. Verifique sua identidade para continuar." + "message": "O seu cofre está bloqueado. Verifique a sua identidade para continuar." }, "yourAccountIsLocked": { "message": "Sua conta está bloqueada" @@ -1198,16 +1207,16 @@ "message": "ou" }, "unlockWithBiometrics": { - "message": "Desbloquear com a biometria" + "message": "Desbloquear com biometria" }, "unlockWithMasterPassword": { - "message": "Desbloquear com a senha mestra" + "message": "Desbloquear com senha principal" }, "unlock": { "message": "Desbloquear" }, "loggedInAsOn": { - "message": "Entrou como $EMAIL$ em $HOSTNAME$.", + "message": "Conectado como $EMAIL$ em $HOSTNAME$.", "placeholders": { "email": { "content": "$1", @@ -1220,10 +1229,19 @@ } }, "invalidMasterPassword": { - "message": "Senha mestra inválida" + "message": "Senha principal inválida" + }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Senha principal inválida. Confirme que seu e-mail está correto e sua conta foi criada em $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } }, "twoStepLoginConfirmation": { - "message": "A autenticação em duas etapas torna sua conta mais segura, exigindo que você verifique o seu login com outro dispositivo, como uma chave de segurança, um aplicativo de autenticação, SMS, chamada telefônica ou e-mail. A autenticação em duas etapas pode ser ativada no cofre web em bitwarden.com. Você deseja visitar o site agora?" + "message": "A autenticação em duas etapas torna sua conta mais segura, exigindo que você verifique a sua autenticação com outro dispositivo, como uma chave de segurança, um aplicativo autenticador, SMS, chamada telefônica ou e-mail. A autenticação em duas etapas pode ser ativada no cofre web em bitwarden.com. Você deseja visitar o site agora?" }, "twoStepLogin": { "message": "Autenticação em duas etapas" @@ -1232,16 +1250,16 @@ "message": "Tempo limite do cofre" }, "vaultTimeout": { - "message": "Tempo Limite do Cofre" + "message": "Tempo limite do cofre" }, "vaultTimeout1": { - "message": "Tempo esgotado" + "message": "Tempo limite" }, "vaultTimeoutAction1": { - "message": "Tempo limite de ação" + "message": "Ação do tempo limite" }, "vaultTimeoutDesc": { - "message": "Escolha quando o tempo limite do seu cofre irá se esgotar e execute a ação selecionada." + "message": "Escolha quando o seu cofre executará a ação do tempo limite do cofre." }, "immediately": { "message": "Imediatamente" @@ -1277,16 +1295,16 @@ "message": "4 horas" }, "onIdle": { - "message": "Quando o sistema está inativo" + "message": "Na inatividade do sistema" }, "onSleep": { - "message": "Quando o sistema hibernar" + "message": "Na hibernação do sistema" }, "onLocked": { - "message": "Quando o Sistema estiver Bloqueado" + "message": "No bloqueio do sistema" }, "onRestart": { - "message": "Ao Reiniciar" + "message": "No reinício" }, "never": { "message": "Nunca" @@ -1303,64 +1321,64 @@ "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Mostrar ícones de sites e obter URLs de alteração de senha" }, "enableMinToTray": { "message": "Minimizar para ícone da bandeja" }, "enableMinToTrayDesc": { - "message": "Ao minimizar a janela, mostra um ícone na bandeja do sistema." + "message": "Ao minimizar a janela, mostrar um ícone na bandeja do sistema." }, "enableMinToMenuBar": { "message": "Minimizar para a barra de menu" }, "enableMinToMenuBarDesc": { - "message": "Ao minimizar a janela, mostra um ícone na barra de menus." + "message": "Ao minimizar a janela, mostrar um ícone na barra de menu." }, "enableCloseToTray": { - "message": "Fechar para a área de notificações" + "message": "Fechar para ícone da bandeja" }, "enableCloseToTrayDesc": { - "message": "Ao fechar a janela, mostra um ícone na bandeja do sistema." + "message": "Ao fechar a janela, mostrar um ícone na bandeja do sistema." }, "enableCloseToMenuBar": { "message": "Fechar para barra de menu" }, "enableCloseToMenuBarDesc": { - "message": "Ao fechar a janela, mostra um ícone na barra de menu." + "message": "Ao fechar a janela, mostrar um ícone na barra de menu." }, "enableTray": { - "message": "Ativar Ícone de Bandeja" + "message": "Ativar icone da bandeja" }, "enableTrayDesc": { "message": "Sempre mostrar um ícone na bandeja do sistema." }, "startToTray": { - "message": "Iniciar para o Ícone da Bandeja" + "message": "Abrir no ícone da bandeja" }, "startToTrayDesc": { - "message": "Quando o aplicativo for iniciado, apenas mostrar um ícone na bandeja do sistema." + "message": "Ao abrir o aplicativo, apenas mostrar um ícone na bandeja do sistema." }, "startToMenuBar": { - "message": "Iniciar na barra de menu" + "message": "Abrir na barra de menu" }, "startToMenuBarDesc": { - "message": "Quando o aplicativo for iniciado, apenas mostrar um ícone na barra de menu." + "message": "Ao abrir o aplicativo, apenas mostrar um ícone na barra de menu." }, "openAtLogin": { - "message": "Iniciar automaticamente ao iniciar sessão" + "message": "Iniciar automaticamente com o usuário" }, "openAtLoginDesc": { - "message": "Inicie o aplicativo Bitwarden Desktop automaticamente no login." + "message": "Inicie o aplicativo Bitwarden Desktop automaticamente junto com o usuário." }, "alwaysShowDock": { "message": "Exibir sempre na Dock" }, "alwaysShowDockDesc": { - "message": "Mostrar o ícone do Bitwarden na Dock, mesmo quando minimizado para a barra de menu." + "message": "Mostrar o ícone do Bitwarden na Dock, mesmo quando minimizado na barra de menu." }, "confirmTrayTitle": { - "message": "Confirmar desativação da bandeja" + "message": "Confirmar ocultar da bandeja" }, "confirmTrayDesc": { "message": "Desativar esta configuração também desativará todas as outras configurações relacionadas à bandeja." @@ -1390,7 +1408,7 @@ "description": "Copy to clipboard" }, "checkForUpdates": { - "message": "Verificar por atualizações…" + "message": "Conferir se há atualizações…" }, "version": { "message": "Versão $VERSION_NUM$", @@ -1402,7 +1420,7 @@ } }, "restartToUpdate": { - "message": "Reiniciar para Atualizar" + "message": "Reiniciar para atualizar" }, "restartToUpdateDesc": { "message": "A versão $VERSION_NUM$ está pronta para ser instalada. Você deve reiniciar o Bitwarden para completar a instalação. Você quer reiniciar e atualizar agora?", @@ -1414,7 +1432,7 @@ } }, "updateAvailable": { - "message": "Atualização Disponível" + "message": "Atualização disponível" }, "updateAvailableDesc": { "message": "Uma atualização foi encontrada. Você quer baixá-la agora?" @@ -1423,7 +1441,7 @@ "message": "Reiniciar" }, "later": { - "message": "Mais Tarde" + "message": "Mais tarde" }, "noUpdatesAvailable": { "message": "Não há atualizações disponíveis no momento. Você está usando a versão mais recente." @@ -1435,48 +1453,48 @@ "message": "Desconhecido" }, "copyUsername": { - "message": "Copiar Nome de Usuário" + "message": "Copiar nome de usuário" }, "copyNumber": { - "message": "Copiar Número", + "message": "Copiar número", "description": "Copy credit card number" }, "copyEmail": { "message": "Copiar e-mail" }, "copySecurityCode": { - "message": "Copiar Código de Segurança", + "message": "Copiar código de segurança", "description": "Copy credit card security code (CVV)" }, "cardNumber": { "message": "número do cartão" }, "premiumMembership": { - "message": "Assinatura Premium" + "message": "Plano Premium" }, "premiumManage": { - "message": "Gerenciar Plano" + "message": "Gerenciar plano" }, "premiumManageAlert": { - "message": "Você pode gerenciar a sua assinatura premium no cofre web em bitwarden.com. Você deseja visitar o site agora?" + "message": "Você pode gerenciar o seu plano no cofre web do bitwarden.com. Você deseja visitar o site agora?" }, "premiumRefresh": { - "message": "Atualizar Assinatura" + "message": "Recarregar plano" }, "premiumNotCurrentMember": { - "message": "Você não possui uma assinatura Premium." + "message": "Você não é um membro Premium atualmente." }, "premiumSignUpAndGet": { - "message": "Registe-se para uma assinatura premium e obtenha:" + "message": "Inscreva-se para um plano Premium e receba:" }, "premiumSignUpStorage": { - "message": "1 GB de armazenamento de arquivos encriptados." + "message": "1 GB de armazenamento criptografado para anexos de arquivos." }, "premiumSignUpTwoStepOptions": { - "message": "Opções de login em duas etapas como YubiKey e Duo." + "message": "Opções proprietárias de autenticação em duas etapas como YubiKey e Duo." }, "premiumSignUpReports": { - "message": "Higiene de senha, saúde da conta, e relatórios sobre violação de dados para manter o seu cofre seguro." + "message": "Higiene de senha, saúde da conta, e relatórios de brechas de dados para manter o seu cofre seguro." }, "premiumSignUpTotp": { "message": "Gerador de códigos de verificação TOTP (2FA) para credenciais no seu cofre." @@ -1485,22 +1503,22 @@ "message": "Prioridade no suporte ao cliente." }, "premiumSignUpFuture": { - "message": "Todos os recursos premium no futuro. Mais em breve!" + "message": "Todos os recursos Premium no futuro. Mais em breve!" }, "premiumPurchase": { "message": "Comprar Premium" }, "premiumPurchaseAlertV2": { - "message": "Você pode comprar Premium nas configurações de sua conta no aplicativo web do Bitwarden." + "message": "Você pode comprar o Premium nas configurações da sua conta no aplicativo web do Bitwarden." }, "premiumCurrentMember": { - "message": "Você é um membro premium!" + "message": "Você é um membro Premium!" }, "premiumCurrentMemberThanks": { "message": "Obrigado por apoiar o Bitwarden." }, "premiumPrice": { - "message": "Tudo por apenas $PRICE$ /ano!", + "message": "Tudo por apenas $PRICE$ por ano!", "placeholders": { "price": { "content": "$1", @@ -1509,7 +1527,7 @@ } }, "refreshComplete": { - "message": "Atualização completada" + "message": "Recarregamento concluído" }, "passwordHistory": { "message": "Histórico de senhas" @@ -1521,7 +1539,7 @@ "message": "Limpar histórico do gerador" }, "cleargGeneratorHistoryDescription": { - "message": "Se continuar, todas as entradas serão permanentemente excluídas do histórico do gerador. Tem certeza que deseja continuar?" + "message": "Se continuar, todos os itens serão apagados para sempre do histórico do gerador. Tem certeza que deseja continuar?" }, "clear": { "message": "Limpar", @@ -1537,7 +1555,7 @@ "message": "Nada para mostrar" }, "nothingGeneratedRecently": { - "message": "Você não gerou uma senha recentemente" + "message": "Você não gerou nada recentemente" }, "undo": { "message": "Desfazer" @@ -1554,7 +1572,7 @@ "description": "Paste from clipboard" }, "selectAll": { - "message": "Selecionar Tudo" + "message": "Selecionar tudo" }, "zoomIn": { "message": "Ampliar" @@ -1563,16 +1581,16 @@ "message": "Reduzir" }, "resetZoom": { - "message": "Redefinir Zoom" + "message": "Redefinir zoom" }, "toggleFullScreen": { - "message": "Alternar para Tela Cheia" + "message": "Habilitar tela cheia" }, "reload": { "message": "Recarregar" }, "toggleDevTools": { - "message": "Alternar ferramentas de desenvolvedor" + "message": "Habilitar ferramentas de desenvolvedor" }, "minimize": { "message": "Minimizar", @@ -1582,7 +1600,7 @@ "message": "Zoom" }, "bringAllToFront": { - "message": "Trazer Tudo para a Frente", + "message": "Trazer tudo para a frente", "description": "Bring all windows to front (foreground)" }, "aboutBitwarden": { @@ -1592,16 +1610,16 @@ "message": "Serviços" }, "hideBitwarden": { - "message": "Ocultar o Bitwarden" + "message": "Ocultar Bitwarden" }, "hideOthers": { - "message": "Ocultar Outros" + "message": "Ocultar outros" }, "showAll": { - "message": "Mostrar Todos" + "message": "Mostrar tudo" }, "quitBitwarden": { - "message": "Sair do Bitwarden" + "message": "Fechar Bitwarden" }, "valueCopied": { "message": "$VALUE$ copiado(a)", @@ -1617,10 +1635,10 @@ "message": "Copiado com sucesso" }, "errorRefreshingAccessToken": { - "message": "Erro ao Atualizar Token" + "message": "Erro de atualização do token de acesso" }, "errorRefreshingAccessTokenDesc": { - "message": "Nenhum token de atualização ou chave de API foi encontrado. Tente sair e entrar novamente." + "message": "Nenhum token de recarregamento ou chave de API foi encontrado. Tente desconectar-se e conectar-se novamente." }, "help": { "message": "Ajuda" @@ -1629,7 +1647,7 @@ "message": "Janela" }, "checkPassword": { - "message": "Verifique se a senha foi exposta." + "message": "Confira se a senha foi exposta." }, "passwordExposed": { "message": "Esta senha foi exposta $VALUE$ vez(es) em brechas de dados. Você deve alterá-la.", @@ -1641,14 +1659,14 @@ } }, "passwordSafe": { - "message": "Esta senha não foi encontrada em brechas de dados conhecidas. Deve ser seguro de usar." + "message": "Esta senha não foi encontrada em brechas de dados conhecidas. Deve ser segura de usar." }, "baseDomain": { "message": "Domínio de base", "description": "Domain name. Ex. website.com" }, "domainName": { - "message": "Nome do Domínio", + "message": "Nome do domínio", "description": "Domain name. Ex. website.com" }, "host": { @@ -1666,7 +1684,7 @@ "description": "A programming term, also known as 'RegEx'." }, "matchDetection": { - "message": "Deteção de correspondência", + "message": "Detecção de correspondência", "description": "URI match detection for auto-fill." }, "defaultMatchDetection": { @@ -1674,7 +1692,7 @@ "description": "Default URI match detection for auto-fill." }, "toggleOptions": { - "message": "Alternar Opções" + "message": "Habilitar opções" }, "organization": { "message": "Organização", @@ -1691,10 +1709,10 @@ "description": "Text for a button that toggles the visibility of the window. Shows the window when it is hidden or hides the window if it is currently open." }, "hideToTray": { - "message": "Ocultar para a área de notificações" + "message": "Ocultar para a bandeja" }, "alwaysOnTop": { - "message": "Sempre no Topo", + "message": "Sempre acima", "description": "Application window should always stay on top of other windows" }, "dateUpdated": { @@ -1728,41 +1746,41 @@ "message": "Esta senha será usada para exportar e importar este arquivo" }, "accountRestrictedOptionDescription": { - "message": "Use sua chave criptográfica da conta, derivada do nome de usuário e Senha Mestra da sua conta, para criptografar a exportação e restringir importação para apenas a conta atual do Bitwarden." + "message": "Use a chave de criptografia da sua conta, derivada do nome de usuário e senha principal da sua conta, para criptografar a exportação e restringir a importação para apenas a conta atual do Bitwarden." }, "passwordProtected": { - "message": "Protegido por senha" + "message": "Protegida por senha" }, "passwordProtectedOptionDescription": { - "message": "Defina uma senha de arquivo para criptografar a exportação e importá-la para qualquer conta do Bitwarden usando a senha para descriptografia." + "message": "Configure uma senha de arquivo para criptografar a exportação e importá-la para qualquer conta do Bitwarden usando a senha para descriptografá-la." }, "exportTypeHeading": { "message": "Tipo da exportação" }, "accountRestricted": { - "message": "Conta restrita" + "message": "Restrita à conta" }, "restrictCardTypeImport": { - "message": "Não é possível importar os tipos de item cartão" + "message": "Não é possível importar itens do tipo de cartão" }, "restrictCardTypeImportDesc": { - "message": "Uma política definida por 1 ou mais organizações impedem que você importe cartões dos seus repositórios." + "message": "Uma política configurada por uma ou mais organizações impedem que você importe cartões em seus cofres." }, "filePasswordAndConfirmFilePasswordDoNotMatch": { - "message": "\"Senha do arquivo\" e \"Confirmação de senha\" não correspondem." + "message": "\"Senha do arquivo\" e \"Confirmar senha do arquivo\" não correspondem." }, "done": { - "message": "Concluído" + "message": "Pronto" }, "warning": { "message": "AVISO", "description": "WARNING (should stay in capitalized letters if the language permits)" }, "confirmVaultExport": { - "message": "Confirmar Exportação do Cofre" + "message": "Confirmar exportação do cofre" }, "exportWarningDesc": { - "message": "Esta exportação contém os dados do seu cofre em um formato não criptografado. Você não deve armazenar ou enviar o arquivo exportado por canais inseguros (como e-mail). Exclua o arquivo imediatamente após terminar de usá-lo." + "message": "Esta exportação contém os dados do seu cofre em um formato não criptografado. Você não deve armazenar ou enviar o arquivo exportado por canais inseguros (como e-mail). Apague o arquivo imediatamente após terminar de usá-lo." }, "encExportKeyWarningDesc": { "message": "Esta exportação criptografa seus dados usando a chave de criptografia da sua conta. Se você rotacionar a chave de criptografia da sua conta, você deve exportar novamente, já que você não será capaz de descriptografar este arquivo de exportação." @@ -1771,16 +1789,16 @@ "message": "As chaves de criptografia de conta são únicas para cada conta de usuário do Bitwarden, então você não pode importar uma exportação criptografada para uma conta diferente." }, "noOrganizationsList": { - "message": "Você não pertence a nenhuma organização. Organizações permitem-lhe compartilhar itens em segurança com outros usuários." + "message": "Você não faz parte de uma organização. Organizações permitem-lhe compartilhar itens com segurança com outros usuários." }, "noCollectionsInList": { - "message": "Não há coleções para listar." + "message": "Não há conjuntos para listar." }, "ownership": { "message": "Propriedade" }, "whoOwnsThisItem": { - "message": "Quem possui este item?" + "message": "Quem é o proprietário deste item?" }, "strong": { "message": "Forte", @@ -1795,20 +1813,20 @@ "description": "ex. A weak password. Scale: Weak -> Good -> Strong" }, "weakMasterPassword": { - "message": "Senha mestra fraca" + "message": "Senha principal fraca" }, "weakMasterPasswordDesc": { - "message": "A senha mestra que você selecionou está fraca. Você deve usar uma senha mestra forte (ou uma frase-passe) para proteger a sua conta Bitwarden adequadamente. Tem certeza que deseja usar esta senha mestra?" + "message": "A senha principal que você escolheu é fraca. Você deve usar uma senha principal forte (ou uma frase secreta) para proteger a sua conta Bitwarden adequadamente. Tem certeza que deseja usar esta senha principal?" }, "pin": { "message": "PIN", "description": "PIN code. Ex. The short code (often numeric) that you use to unlock a device." }, "unlockWithPin": { - "message": "Desbloquear com o PIN" + "message": "Desbloquear com PIN" }, "setYourPinCode": { - "message": "Defina o seu código PIN para desbloquear o Bitwarden. Suas configurações de PIN serão redefinidas se alguma vez você encerrar completamente toda a sessão do aplicativo." + "message": "Configure o seu código PIN para desbloquear o Bitwarden. Suas configurações de PIN serão redefinidas se você desconectar-se totalmente do aplicativo." }, "pinRequired": { "message": "O código PIN é necessário." @@ -1823,7 +1841,7 @@ "message": "Desbloquear com o Windows Hello" }, "unlockWithPolkit": { - "message": "Desbloquear com autenticação de sistema" + "message": "Desbloquear com a autenticação do sistema" }, "windowsHelloConsentMessage": { "message": "Verifique para o Bitwarden." @@ -1832,64 +1850,70 @@ "message": "Desbloquear com o Touch ID" }, "additionalTouchIdSettings": { - "message": "Configurações adicionais de Touch ID" + "message": "Configurações adicionais do Touch ID" }, "touchIdConsentMessage": { "message": "desbloquear o seu cofre" }, "autoPromptTouchId": { - "message": "Pedir pelo Touch ID ao iniciar" + "message": "Pedir o Touch ID ao abrir" }, "lockWithMasterPassOnRestart1": { - "message": "Bloquear com senha mestra ao reiniciar" + "message": "Bloquear com a senha principal ao reiniciar" + }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Exigir senha principal ou PIN ao reiniciar o app" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Exigir senha principal ao reiniciar o app" }, "deleteAccount": { - "message": "Excluir conta" + "message": "Apagar conta" }, "deleteAccountDesc": { - "message": "Prossiga abaixo para excluir a sua conta e todos os dados associados." + "message": "Prossiga abaixo para apagar sua conta e todos os dados do cofre." }, "deleteAccountWarning": { - "message": "A exclusão de sua conta é permanente. Não poderá ser desfeito." + "message": "O apagamento de sua conta é permanente. Não pode ser desfeito." }, "cannotDeleteAccount": { - "message": "Não é possível excluir conta" + "message": "Não é possível apagar a conta" }, "cannotDeleteAccountDesc": { "message": "Esta ação não pode ser concluída porque sua conta pertence a uma organização. Entre em contato com o administrador da sua organização para obter mais detalhes." }, "accountDeleted": { - "message": "Conta excluída" + "message": "Conta apagada" }, "accountDeletedDesc": { - "message": "A sua conta foi fechada e todos os dados associados foram excluídos." + "message": "A sua conta foi fechada e todos os dados associados foram apagados." }, "preferences": { "message": "Preferências" }, "enableMenuBar": { - "message": "Ativar Ícone da Barra de Menu" + "message": "Ativar ícone da barra de menu" }, "enableMenuBarDesc": { "message": "Sempre mostrar um ícone na barra de menu." }, "hideToMenuBar": { - "message": "Ocultar para a Barra de Menu" + "message": "Ocultar na barra de menu" }, "selectOneCollection": { - "message": "Você deve selecionar pelo menos uma coleção." + "message": "Você deve selecionar pelo menos um conjunto." }, "premiumUpdated": { - "message": "Você atualizou para premium." + "message": "Você fez upgrade para o Premium." }, "restore": { "message": "Restaurar" }, "premiumManageAlertAppStore": { - "message": "Você pode gerenciar sua assinatura da App Store. Você quer visitar a App Store agora?" + "message": "Você pode gerenciar sua assinatura pela App Store. Você quer visitar a App Store agora?" }, "legal": { - "message": "Aspectos Legais", + "message": "Jurídico", "description": "Noun. As in 'legal documents', like our terms of service and privacy policy." }, "termsOfService": { @@ -1899,28 +1923,28 @@ "message": "Política de Privacidade" }, "unsavedChangesConfirmation": { - "message": "Você tem certeza que deseja sair? Se sair agora, as suas informações atuais não serão salvas." + "message": "Tem certeza que quer sair? Se sair agora, as suas informações atuais não serão salvas." }, "unsavedChangesTitle": { - "message": "Alterações não Salvas" + "message": "Alterações não salvas" }, "clone": { "message": "Clonar" }, "passwordGeneratorPolicyInEffect": { - "message": "Uma ou mais políticas da organização estão afetando as suas configurações do gerador." + "message": "Uma ou mais políticas da organização estão afetando as configurações do seu gerador." }, "vaultTimeoutAction": { - "message": "Ação de Tempo Limite do Cofre" + "message": "Ação do tempo limite do cofre" }, "vaultTimeoutActionLockDesc": { - "message": "Um cofre bloqueado requer que você reinsira a sua senha mestra para entrar novamente." + "message": "A senha principal ou outro método de desbloqueio é necessário para acessar seu cofre novamente." }, "vaultTimeoutActionLogOutDesc": { - "message": "Uma sessão encerrada com o cofre requer que você autentique-se novamente para acessá-lo de novo." + "message": "Reautenticação é necessária para acessar seu cofre novamente." }, "unlockMethodNeededToChangeTimeoutActionDesc": { - "message": "Configure um método de desbloqueio para alterar o tempo limite do cofre." + "message": "Configure um método de desbloqueio para alterar a ação do tempo limite do cofre." }, "lock": { "message": "Bloquear", @@ -1931,45 +1955,45 @@ "description": "Noun: a special folder to hold deleted items" }, "searchTrash": { - "message": "Pesquisar na lixeira" + "message": "Buscar na lixeira" }, "permanentlyDeleteItem": { - "message": "Excluir o Item Permanentemente" + "message": "Apagar item para sempre" }, "permanentlyDeleteItemConfirmation": { - "message": "Você tem certeza que deseja excluir permanentemente esse item?" + "message": "Tem certeza que quer apagar este item para sempre?" }, "permanentlyDeletedItem": { - "message": "Item Permanentemente Excluído" + "message": "Item apagado para sempre" }, "restoredItem": { - "message": "Item Restaurado" + "message": "Item restaurado" }, "permanentlyDelete": { - "message": "Excluir Permanentemente" + "message": "Apagar para sempre" }, "vaultTimeoutLogOutConfirmation": { - "message": "Sair irá remover todo o acesso ao seu cofre e requer autenticação online após o período de tempo limite. Tem certeza de que deseja usar esta configuração?" + "message": "Ao desconectar-se, todo o seu acesso ao cofre será removido e será necessário autenticação on-line após o período do tempo limite. Tem certeza que quer usar esta configuração?" }, "vaultTimeoutLogOutConfirmationTitle": { - "message": "Confirmação de Ação de Tempo Limite" + "message": "Confirmação de ação do tempo limite" }, "enterpriseSingleSignOn": { - "message": "Iniciar Sessão Empresarial Única" + "message": "Autenticação única empresarial" }, "setMasterPassword": { - "message": "Definir Senha Mestra" + "message": "Configurar senha principal" }, "orgPermissionsUpdatedMustSetPassword": { - "message": "As permissões da sua organização foram atualizadas, exigindo que você defina uma senha mestra.", + "message": "As permissões da sua organização foram atualizadas, exigindo que você configure uma senha principal.", "description": "Used as a card title description on the set password page to explain why the user is there" }, "orgRequiresYouToSetPassword": { - "message": "Sua organização requer que você defina uma senha mestra.", + "message": "Sua organização requer que você configure uma senha principal.", "description": "Used as a card title description on the set password page to explain why the user is there" }, "cardMetrics": { - "message": "fora do $TOTAL$", + "message": "dos $TOTAL$", "placeholders": { "total": { "content": "$1", @@ -1981,7 +2005,7 @@ "message": "Detalhes do cartão" }, "cardBrandDetails": { - "message": "$BRAND$ Detalhes", + "message": "Detalhes da $BRAND$", "placeholders": { "brand": { "content": "$1", @@ -1990,19 +2014,19 @@ } }, "learnMoreAboutAuthenticators": { - "message": "Aprenda mais sobre autenticadores" + "message": "Saiba mais sobre autenticadores" }, "copyTOTP": { - "message": "Copiar a chave de autenticação (TOTP)" + "message": "Copiar chave do autenticador (TOTP)" }, "totpHelperTitle": { - "message": "Tornar a verificação em 2 etapas mais simples" + "message": "Torne a verificação em 2 etapas mais simples" }, "totpHelper": { - "message": "Bitwarden pode armazenar e preencher códigos de verificação de 2 etapas. Copie e cole a chave nesse campo." + "message": "O Bitwarden pode armazenar e preencher códigos de verificação de 2 etapas. Copie e cole a chave nesse campo." }, "totpHelperWithCapture": { - "message": "Bitwarden pode armazenar e preencher códigos de verificação de 2 etapas. Selecione o ícone da câmera e tire uma captura de tela do código QR de autenticação desse site ou copie e cole a chave nesse campo." + "message": "O Bitwarden pode armazenar e preencher códigos de verificação de 2 etapas. Selecione o ícone da câmera e tire uma captura de tela do código QR do autenticador nesse site ou copie e cole a chave nesse campo." }, "premium": { "message": "Premium", @@ -2012,7 +2036,7 @@ "message": "Organizações gratuitas não podem utilizar anexos" }, "singleFieldNeedsAttention": { - "message": "1 campo precisa da sua atenção." + "message": "Um campo precisa da sua atenção." }, "multipleFieldsNeedAttention": { "message": "$COUNT$ campos precisam da sua atenção.", @@ -2024,26 +2048,26 @@ } }, "cardExpiredTitle": { - "message": "Cartão expirado" + "message": "Cartão vencido" }, "cardExpiredMessage": { - "message": "Se você fez uma renovação recente, atualize as informações do cartão" + "message": "Se você o renovou, atualize as informações do cartão" }, "verificationRequired": { "message": "Verificação necessária", "description": "Default title for the user verification dialog." }, "currentMasterPass": { - "message": "Senha mestra atual" + "message": "Senha principal atual" }, "newMasterPass": { - "message": "Nova Senha Mestra" + "message": "Nova senha principal" }, "confirmNewMasterPass": { - "message": "Confirme a Nova Senha Mestra" + "message": "Confirmar nova senha principal" }, "masterPasswordPolicyInEffect": { - "message": "Uma ou mais políticas da organização exigem que a sua senha mestra cumpra aos seguintes requisitos:" + "message": "Uma ou mais políticas da organização exigem que a sua senha principal cumpra aos seguintes requisitos:" }, "policyInEffectMinComplexity": { "message": "Pontuação mínima de complexidade de $SCORE$", @@ -2082,13 +2106,13 @@ } }, "masterPasswordPolicyRequirementsNotMet": { - "message": "A sua nova senha mestra não cumpre aos requisitos da política." + "message": "A sua nova senha principal não cumpre aos requisitos da política." }, "receiveMarketingEmailsV2": { - "message": "Obtenha conselhos, novidades, e oportunidades de pesquisa do Bitwarden em sua caixa de entrada." + "message": "Receba conselhos, novidades, e oportunidades de pesquisa do Bitwarden em sua caixa de entrada." }, "unsubscribe": { - "message": "Cancelar subscrição" + "message": "Desinscreva-se" }, "atAnyTime": { "message": "a qualquer momento." @@ -2106,55 +2130,52 @@ "message": "Os Termos de Serviço e a Política de Privacidade não foram aceitos." }, "enableBrowserIntegration": { - "message": "Ativar integração com o navegador" + "message": "Permitir integração com o navegador" }, "enableBrowserIntegrationDesc1": { - "message": "Usado para permitir desbloqueio biométrico em navegadores que não são Safari." + "message": "Usado para permitir o desbloqueio biométrico em navegadores que não são o Safari." }, "enableDuckDuckGoBrowserIntegration": { - "message": "Permitir integração ao navegador DuckDuckGo" + "message": "Permitir integração com o navegador DuckDuckGo" }, "enableDuckDuckGoBrowserIntegrationDesc": { "message": "Use o seu cofre do Bitwarden ao navegar com DuckDuckGo." }, "browserIntegrationUnsupportedTitle": { - "message": "Integração com o navegador não suportado" + "message": "Integração com o navegador não suportada" }, "browserIntegrationErrorTitle": { - "message": "Erro ao ativar a integração do navegador" + "message": "Erro ao ativar a integração com o navegador" }, "browserIntegrationErrorDesc": { - "message": "Ocorreu um erro ao permitir a integração do navegador." - }, - "browserIntegrationMasOnlyDesc": { - "message": "Infelizmente, por ora, a integração do navegador só é suportada na versão da Mac App Store." + "message": "Ocorreu um erro ao ativar a integração com o navegador." }, "browserIntegrationWindowsStoreDesc": { - "message": "Infelizmente, a integração do navegador não é suportada na versão da Microsoft Store." + "message": "Infelizmente, a integração com o navegador não é suportada na versão da Microsoft Store no momento." }, "browserIntegrationLinuxDesc": { - "message": "Infelizmente, a integração do navegador não é suportada na versão linux." + "message": "Infelizmente, a integração do navegador não é suportada na versão linux no momento." }, "enableBrowserIntegrationFingerprint": { "message": "Exigir verificação para integração com o navegador" }, "enableBrowserIntegrationFingerprintDesc": { - "message": "Ative uma camada adicional de segurança, exigindo validação de frase de impressão digital ao estabelecer uma ligação entre o computador e o navegador. Quando ativado, isto requer intervenção do usuário e verificação cada vez que uma conexão é estabelecida." + "message": "Adicione uma camada adicional de segurança, exigindo a validação da frase biométrica ao estabelecer uma ligação entre o computador e o navegador. Requer intervenção do usuário e verificação cada vez que uma conexão é estabelecida." }, "enableHardwareAcceleration": { - "message": "Utilizar aceleração de hardware" + "message": "Usar aceleração de hardware" }, "enableHardwareAccelerationDesc": { - "message": "Por padrão esta configuração está ativada. Desligar apenas se tiver problemas gráficos. Reiniciar é necessário." + "message": "Por padrão esta configuração está ativada. Desative apenas se tiver problemas gráficos. Reiniciar é necessário." }, "approve": { "message": "Aprovar" }, "verifyBrowserTitle": { - "message": "Verificar conexão do navegador" + "message": "Verifique a conexão com o navegador" }, "verifyBrowserDesc": { - "message": "Por favor, certifique-se que a impressão digital mostrada é idêntica à impressão digital exibida na extensão do navegador." + "message": "Certifique-se que a frase biométrica mostrada é idêntica à exibida na extensão do navegador." }, "verifyNativeMessagingConnectionTitle": { "message": "$APPID$ quer se conectar ao Bitwarden", @@ -2166,43 +2187,43 @@ } }, "verifyNativeMessagingConnectionDesc": { - "message": "Gostaria de aprovar este pedido?" + "message": "Gostaria de aprovar esta solicitação?" }, "verifyNativeMessagingConnectionWarning": { "message": "Se não iniciou esta solicitação, não a aprove." }, "biometricsNotEnabledTitle": { - "message": "Biometria não ativada" + "message": "Biometria não configurada" }, "biometricsNotEnabledDesc": { - "message": "A biometria do navegador exige que a biometria do desktop seja configurada primeiro nas configurações." + "message": "A biometria do navegador exige que a biometria do computador seja configurada primeiro nas configurações." }, "biometricsManualSetupTitle": { "message": "Configuração automática não disponível" }, "biometricsManualSetupDesc": { - "message": "Devido ao método de instalação, o suporte a dados biométricos não pôde ser ativado automaticamente. Você gostaria de abrir a documentação sobre como fazer isso manualmente?" + "message": "Devido ao método de instalação, o suporte à biometria não pôde ser ativado automaticamente. Você gostaria de abrir a documentação sobre como fazer isso manualmente?" }, "personalOwnershipSubmitError": { - "message": "Devido a uma Política Empresarial, você está restrito de salvar itens para seu cofre pessoal. Altere a opção de Propriedade para uma organização e escolha entre as Coleções disponíveis." + "message": "Devido a uma política empresarial, você não pode salvar itens no seu cofre pessoal. Altere a opção de propriedade para uma organização e escolha entre os conjuntos disponíveis." }, "yourNewPasswordCannotBeTheSameAsYourCurrentPassword": { - "message": "Sua nova senha não pode ser a mesma que a sua atual." + "message": "Sua senha nova não pode ser a mesma que a sua atual." }, "hintEqualsPassword": { - "message": "Sua dica de senha não pode ser o mesmo que sua senha." + "message": "A dica da sua senha não pode ser a mesma que a sua senha." }, "personalOwnershipPolicyInEffect": { "message": "Uma política de organização está afetando suas opções de propriedade." }, "personalOwnershipPolicyInEffectImports": { - "message": "A política da organização bloqueou a importação de itens para o seu cofre." + "message": "Uma política da organização bloqueou a importação de itens em seu cofre individual." }, "personalDetails": { "message": "Detalhes pessoais" }, "identification": { - "message": "Identificação" + "message": "Identidade" }, "contactInfo": { "message": "Informações de contato" @@ -2218,7 +2239,7 @@ "message": "Texto" }, "searchSends": { - "message": "Pesquisar Sends", + "message": "Buscar nos Sends", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editSend": { @@ -2226,46 +2247,46 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "myVault": { - "message": "Meu Cofre" + "message": "Meu cofre" }, "text": { "message": "Texto" }, "deletionDate": { - "message": "Data de Exclusão" + "message": "Data de apagamento" }, "deletionDateDesc": { - "message": "O Send será eliminado permanentemente na data e hora especificadas.", + "message": "O Send será apagado para sempre no horário especificado.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "expirationDate": { - "message": "Data de Expiração" + "message": "Data de validade" }, "expirationDateDesc": { - "message": "Se definido, o acesso a este Send expirará na data e hora especificadas.", + "message": "Se configurado, o acesso a este Send acabará no horário especificado.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "maxAccessCount": { - "message": "Contagem Máxima de Acessos", + "message": "Número máximo de acessos", "description": "This text will be displayed after a Send has been accessed the maximum amount of times." }, "maxAccessCountDesc": { - "message": "Se atribuído, usuários não poderão mais acessar este Send assim que o número máximo de acessos for atingido.", + "message": "Se configurado, usuários não poderão mais acessar este Send assim que o número máximo de acessos for atingido.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "currentAccessCount": { - "message": "Contagem Atual de Acessos" + "message": "Número atual de acessos" }, "disableSend": { - "message": "Desative este envio para que ninguém possa acessá-lo.", + "message": "Desative este Send para que ninguém possa acessá-lo.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendPasswordDesc": { - "message": "Opcionalmente exigir uma senha para os usuários acessarem este Send.", + "message": "Opcionalmente exija uma senha para os usuários acessarem este Send.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendNotesDesc": { - "message": "Notas privadas sobre este Send.", + "message": "Anotações privadas sobre este Send.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendLink": { @@ -2273,7 +2294,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendLinkLabel": { - "message": "Enviar link", + "message": "Link do Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "textHiddenByDefault": { @@ -2281,26 +2302,26 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "createdSend": { - "message": "Envio adicionado", + "message": "Send adicionado", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editedSend": { - "message": "Envio salvo", + "message": "Send salvo", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "deletedSend": { - "message": "Enviar excluído", + "message": "Send apagado", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "newPassword": { - "message": "Nova senha" + "message": "Senha nova" }, "whatTypeOfSend": { "message": "Que tipo de Send é este?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "createSend": { - "message": "Novo envio", + "message": "Novo Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTextDesc": { @@ -2325,7 +2346,7 @@ "message": "Personalizado" }, "deleteSendConfirmation": { - "message": "Você tem certeza que deseja excluir este Send?", + "message": "Tem certeza que quer apagar este Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "copySendLinkToClipboard": { @@ -2336,11 +2357,11 @@ "message": "Copiar o link para compartilhar este Send para minha área de transferência ao salvar." }, "sendDisabled": { - "message": "Envio removido", + "message": "Send removido", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendDisabledWarning": { - "message": "Devido a uma política corporativa, você só pode excluir um Send existente.", + "message": "Devido a uma política corporativa, você só pode apagar um Send existente.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "copyLink": { @@ -2362,10 +2383,10 @@ "message": "Número máximo de acessos atingido" }, "expired": { - "message": "Expirado" + "message": "Vencido" }, "pendingDeletion": { - "message": "Exclusão pendente" + "message": "Apagamento pendente" }, "webAuthnAuthenticate": { "message": "Autenticar WebAuthn" @@ -2392,64 +2413,64 @@ "message": "Você precisa verificar o seu e-mail para usar este recurso." }, "passwordPrompt": { - "message": "Solicitação nova de senha mestra" + "message": "Resolicitar senha principal" }, "passwordConfirmation": { - "message": "Confirmação de senha mestra" + "message": "Confirmação de senha principal" }, "passwordConfirmationDesc": { - "message": "Esta ação está protegida. Para continuar, por favor, reinsira a sua senha mestra para verificar sua identidade." + "message": "Esta ação está protegida. Redigite a sua senha principal para verificar sua identidade." }, "masterPasswordSuccessfullySet": { - "message": "Senha mestra definida com sucesso" + "message": "Senha principal configurada com sucesso" }, "updatedMasterPassword": { - "message": "Senha mestra atualizada" + "message": "Senha principal atualizada" }, "updateMasterPassword": { - "message": "Atualizar Senha Mestra" + "message": "Atualizar senha principal" }, "updateMasterPasswordWarning": { - "message": "Sua Senha Mestra foi alterada recentemente por um administrador de sua organização. Para acessar o cofre, você precisa atualizá-la agora. O processo desconectará você da sessão atual, exigindo que você inicie a sessão novamente. Sessões ativas em outros dispositivos podem continuar ativas por até uma hora." + "message": "Sua senha principal foi alterada recentemente por um administrador de sua organização. Para acessar o cofre, você precisa atualizá-la agora. O processo desconectará você da sessão atual, exigindo que você conecte-se novamente. Sessões ativas em outros dispositivos podem continuar ativas por até uma hora." }, "updateWeakMasterPasswordWarning": { - "message": "A sua senha mestra não atende a uma ou mais das políticas da sua organização. Para acessar o cofre, você deve atualizar a sua senha mestra agora. O processo desconectará você da sessão atual, exigindo que você inicie a sessão novamente. Sessões ativas em outros dispositivos podem continuar ativas por até uma hora." + "message": "A sua senha principal não atende a uma ou mais das políticas da sua organização. Para acessar o cofre, você deve atualizar a sua senha principal agora. O processo desconectará você da sessão atual, exigindo que você se conecte novamente. Sessões ativas em outros dispositivos podem continuar ativas por até uma hora." }, "changePasswordWarning": { - "message": "Após mudar a sua senha, será necessário entrar novamente com a sua nova senha. Sessões ativas em outros dispositivos serão encerradas em até uma hora." + "message": "Após mudar a sua senha, será necessário conectar-se novamente com a sua nova senha. Sessões ativas em outros dispositivos serão desconectadas em até uma hora." }, "accountRecoveryUpdateMasterPasswordSubtitle": { - "message": "Mude a sua senha mestra para completar a recuperação de conta." + "message": "Altere a sua senha principal para concluir a recuperação da conta." }, "updateMasterPasswordSubtitle": { - "message": "Sua senha mestra não corresponde aos requerimentos da organização. Mude a sua senha mestra para continuar." + "message": "Sua senha principal não corresponde aos requisitos da organização. Mude a sua senha principal para continuar." }, "tdeDisabledMasterPasswordRequired": { - "message": "Sua organização desativou a criptografia confiável do dispositivo. Por favor, defina uma senha mestra para acessar o seu cofre." + "message": "Sua organização desativou a criptografia de dispositivo confiado. Configure uma senha principal para acessar o seu cofre." }, "tryAgain": { "message": "Tentar novamente" }, "verificationRequiredForActionSetPinToContinue": { - "message": "Verificação necessária para esta ação. Defina um PIN para continuar." + "message": "Verificação necessária para esta ação. Configure um PIN para continuar." }, "setPin": { - "message": "Definir PIN" + "message": "Configurar PIN" }, "verifyWithBiometrics": { - "message": "Verificiar com biometria" + "message": "Verificar com biometria" }, "awaitingConfirmation": { "message": "Aguardando confirmação" }, "couldNotCompleteBiometrics": { - "message": "Não foi possível completar a biometria." + "message": "Não foi possível concluir a biometria." }, "needADifferentMethod": { "message": "Precisa de um método diferente?" }, "useMasterPassword": { - "message": "Usar a senha mestra" + "message": "Usar senha principal" }, "usePin": { "message": "Usar PIN" @@ -2458,7 +2479,7 @@ "message": "Usar biometria" }, "enterVerificationCodeSentToEmail": { - "message": "Digite o código de verificação que foi enviado para o seu e-mail." + "message": "Digite o código de verificação enviado para o seu e-mail." }, "resendCode": { "message": "Reenviar código" @@ -2483,7 +2504,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "As políticas da sua organização estão afetando seu cofre tempo limite. Tempo limite máximo permitido para cofre é $HOURS$ hora(s) e $MINUTES$ minuto(s). A ação de tempo limite do seu cofre é definida como $ACTION$.", + "message": "As políticas da sua organização estão afetando o tempo limite do seu cofre. O máximo permitido do tempo limite do cofre é $HOURS$ hora(s) e $MINUTES$ minuto(s). A ação de tempo limite do seu cofre está configurada para $ACTION$.", "placeholders": { "hours": { "content": "$1", @@ -2500,7 +2521,7 @@ } }, "vaultTimeoutActionPolicyInEffect": { - "message": "As políticas da sua organização definiram a ação tempo limite do seu cofre para $ACTION$.", + "message": "As políticas da sua organização configuraram a ação do tempo limite do seu cofre para $ACTION$.", "placeholders": { "action": { "content": "$1", @@ -2512,10 +2533,10 @@ "message": "O tempo limite do seu cofre excede as restrições definidas por sua organização." }, "vaultTimeoutPolicyAffectingOptions": { - "message": "Requisitos de políticas corporativas foram adicionadas as suas opções de tempo limite" + "message": "Os requisitos das políticas corporativas foram aplicados às suas opções de tempo limite" }, "vaultTimeoutPolicyInEffect": { - "message": "As políticas da sua organização definiram o seu tempo limite máximo permitido no cofre para $HOURS$ hora(s) e $MINUTES$ minuto(s).", + "message": "As políticas da sua organização configuraram o seu máximo permitido do tempo limite do cofre para $HOURS$ hora(s) e $MINUTES$ minuto(s).", "placeholders": { "hours": { "content": "$1", @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "O mínimo do tempo limite personalizado é de 1 minuto." + }, "inviteAccepted": { "message": "Convite aceito" }, @@ -2547,34 +2571,34 @@ "message": "Inscrição automática" }, "resetPasswordAutoEnrollInviteWarning": { - "message": "Esta organização possui uma política empresarial que irá inscrevê-lo automaticamente na redefinição de senha. A inscrição permitirá que os administradores da organização alterem sua senha mestra." + "message": "Esta organização possui uma política empresarial que irá inscrevê-lo automaticamente na redefinição de senha. A inscrição permitirá que os administradores da organização alterem sua senha principal." }, "vaultExportDisabled": { - "message": "Exportação de Cofre Desativada" + "message": "Exportação de cofre removida" }, "personalVaultExportPolicyInEffect": { - "message": "Uma ou mais políticas da organização impdem que você exporte seu cofre pessoal." + "message": "Uma ou mais políticas da organização impedem que você exporte seu cofre pessoal." }, "addAccount": { - "message": "Adicionar Conta" + "message": "Adicionar conta" }, "removeMasterPassword": { - "message": "Remover Senha Mestra" + "message": "Remover senha principal" }, "removedMasterPassword": { - "message": "Senha mestra removida." + "message": "Senha principal removida" }, "removeMasterPasswordForOrganizationUserKeyConnector": { - "message": "Uma senha mestra não é mais necessária para membros da seguinte organização. Por favor, confirme o domínio abaixo com o administrador da sua organização." + "message": "Uma senha principal não é mais necessária para membros da seguinte organização. Confirme o domínio abaixo com o administrador da sua organização." }, "organizationName": { "message": "Nome da organização" }, "keyConnectorDomain": { - "message": "Chave de conexão do domínio" + "message": "Domínio do Key Connector" }, "leaveOrganization": { - "message": "Sair da Organização" + "message": "Sair da organização" }, "leaveOrganizationConfirmation": { "message": "Você tem certeza que deseja sair desta organização?" @@ -2583,25 +2607,25 @@ "message": "Você saiu da organização." }, "ssoKeyConnectorError": { - "message": "Erro de Key Connector: certifique-se de que a Key Connector está disponível e funcionando corretamente." + "message": "Erro do Key Connector: certifique-se de que o Key Connector está disponível e funcionando corretamente." }, "lockAllVaults": { - "message": "Bloquear Todos os Cofres" + "message": "Bloquear todos os cofres" }, "accountLimitReached": { - "message": "Não mais do que 5 contas podem estar logadas ao mesmo tempo." + "message": "Não mais do que 5 contas podem estar conectadas ao mesmo tempo." }, "accountPreferences": { "message": "Preferências" }, "appPreferences": { - "message": "Configurações do Aplicativo (Todas as Contas)" + "message": "Configurações do aplicativo (todas as contas)" }, "accountSwitcherLimitReached": { - "message": "Limite de Contas atingido. Saia de uma conta para adicionar outra." + "message": "Limite de contas atingido. Desconecte uma conta para adicionar outra." }, "settingsTitle": { - "message": "Configurações do Aplicativo para $EMAIL$", + "message": "Configurações do aplicativo para $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -2610,7 +2634,7 @@ } }, "switchAccount": { - "message": "Trocar de Conta" + "message": "Trocar de conta" }, "alreadyHaveAccount": { "message": "Já tem uma conta?" @@ -2619,13 +2643,13 @@ "message": "Opções" }, "sessionTimeout": { - "message": "Sua sessão expirou. Por favor, volte e tente iniciar a sessão novamente." + "message": "Sua sessão expirou. Volte e tente se conectar novamente." }, "exportingPersonalVaultTitle": { - "message": "Exportação do Cofre Pessoal" + "message": "Exportando cofre individual" }, "exportingIndividualVaultDescription": { - "message": "Apenas os itens individuais do cofre associados a $EMAIL$ serão exportados. Os itens do cofre da organização não serão incluídos. Apenas as informações de item do cofre serão exportadas e não incluirão anexos associados.", + "message": "Apenas os itens do cofre individual associados a $EMAIL$ serão exportados. Os itens do cofre de organizações não serão incluídos. Apenas as informações dos itens do cofre serão exportadas e não incluirão anexos associados.", "placeholders": { "email": { "content": "$1", @@ -2634,7 +2658,7 @@ } }, "exportingIndividualVaultWithAttachmentsDescription": { - "message": "Apenas os itens individuais do cofre, incluindo anexos associados ao $EMAIL$ serão exportados. Os itens do cofre da organização não serão incluídos", + "message": "Apenas os itens do cofre individual, incluindo anexos associados com $EMAIL$ serão exportados. Os itens do cofre de organizações não serão incluídos", "placeholders": { "email": { "content": "$1", @@ -2646,7 +2670,7 @@ "message": "Exportando cofre da organização" }, "exportingOrganizationVaultDesc": { - "message": "Apenas o cofre da organização associado com $ORGANIZATION$ será exportado. Itens do cofre pessoal e itens de outras organizações não serão incluídos.", + "message": "Apenas o cofre da organização associado a $ORGANIZATION$ será exportado. Itens de cofres individuais e de outras organizações não serão incluídos.", "placeholders": { "organization": { "content": "$1", @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Apenas o cofre da organização associado com $ORGANIZATION$ será exportado.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Apenas o cofre da organização associado com $ORGANIZATION$ será exportado. Os itens dos meus conjuntos não serão incluídos.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Bloqueado" }, @@ -2668,16 +2710,16 @@ "description": "Short for 'credential generator'." }, "whatWouldYouLikeToGenerate": { - "message": "O que você gostaria de gerar?" + "message": "O que gostaria de gerar?" }, "passwordType": { - "message": "Tipo de senha" + "message": "Tipo da senha" }, "regenerateUsername": { - "message": "Gerar nome de usuário novamente" + "message": "Regerar nome de usuário" }, "generateUsername": { - "message": "Gerar usuário" + "message": "Gerar nome de usuário" }, "generateEmail": { "message": "Gerar e-mail" @@ -2686,16 +2728,16 @@ "message": "Gerador de nome de usuário" }, "generatePassword": { - "message": "Gerar Senha" + "message": "Gerar senha" }, "generatePassphrase": { "message": "Gerar frase secreta" }, "passwordGenerated": { - "message": "Gerador de senhas" + "message": "Senha gerada" }, "passphraseGenerated": { - "message": "Gerador de palavra-passe" + "message": "Frase secreta gerada" }, "usernameGenerated": { "message": "Nome de usuário gerado" @@ -2718,7 +2760,7 @@ } }, "passwordLengthRecommendationHint": { - "message": "", + "message": " Utilize $RECOMMENDED$ ou mais caracteres para gerar um senha forte.", "description": "Appended to `spinboxBoundariesHint` to recommend a length to the user. This must include any language-specific 'sentence' separator characters (e.g. a space in english).", "placeholders": { "recommended": { @@ -2728,7 +2770,7 @@ } }, "passphraseNumWordsRecommendationHint": { - "message": " Utilize as palavras $RECOMMENDED$ ou mais para gerar uma frase secreta forte.", + "message": " Utilize $RECOMMENDED$ ou mais palavras para gerar uma frase secreta forte.", "description": "Appended to `spinboxBoundariesHint` to recommend a number of words to the user. This must include any language-specific 'sentence' separator characters (e.g. a space in english).", "placeholders": { "recommended": { @@ -2738,32 +2780,32 @@ } }, "usernameType": { - "message": "Tipo de usuário" + "message": "Tipo do nome de usuário" }, "plusAddressedEmail": { - "message": "Mais e-mail endereçado", + "message": "E-mail endereçado com +", "description": "Username generator option that appends a random sub-address to the username. For example: address+subaddress@email.com" }, "plusAddressedEmailDesc": { - "message": "Use os recursos de subendereçamento do seu provedor de e-mail." + "message": "Use os recursos de sub-endereçamento do seu provedor de e-mail." }, "catchallEmail": { "message": "E-mail pega-tudo" }, "catchallEmailDesc": { - "message": "Use o catch-all configurado no seu domínio." + "message": "Use a caixa de entrada pega-tudo configurada no seu domínio." }, "useThisEmail": { "message": "Usar este e-mail" }, "useThisPassword": { - "message": "Use esta senha" + "message": "Usar esta senha" }, "useThisPassphrase": { - "message": "Use essa palavra-passe" + "message": "Usar esta frase secreta" }, "useThisUsername": { - "message": "Use este nome de usuário" + "message": "Usar este nome de usuário" }, "random": { "message": "Aleatório" @@ -2778,16 +2820,16 @@ "message": "Serviço" }, "allVaults": { - "message": "Todos os Cofres" + "message": "Todos os cofres" }, "searchOrganization": { - "message": "Pesquisar Organização" + "message": "Buscar na organização" }, "searchMyVault": { - "message": "Pesquisar no Meu Cofre" + "message": "Buscar no meu cofre" }, "forwardedEmail": { - "message": "Alias de E-mail Encaminhado" + "message": "Alias de encaminhamento de e-mail" }, "forwardedEmailDesc": { "message": "Gere um alias de e-mail com um serviço externo de encaminhamento." @@ -2797,11 +2839,11 @@ "description": "Labels the domain name email forwarder service option" }, "forwarderDomainNameHint": { - "message": "Escolha um domínio que seja suportado pelo serviço selecionado", + "message": "Escolha um domínio suportado pelo serviço selecionado", "description": "Guidance provided for email forwarding services that support multiple email domains." }, "forwarderError": { - "message": "Erro $SERVICENAME$: $ERRORMESSAGE$", + "message": "Erro do $SERVICENAME$: $ERRORMESSAGE$", "description": "Reports an error returned by a forwarding service to the user.", "placeholders": { "servicename": { @@ -2829,7 +2871,7 @@ } }, "forwaderInvalidToken": { - "message": "Token de API $SERVICENAME$ inválido", + "message": "Token de API do $SERVICENAME$ inválido", "description": "Displayed when the user's API token is empty or rejected by the forwarding service.", "placeholders": { "servicename": { @@ -2839,7 +2881,7 @@ } }, "forwaderInvalidTokenWithMessage": { - "message": "Token de API $SERVICENAME$ inválido: $ERRORMESSAGE$", + "message": "Token de API da $SERVICENAME$ inválido: $ERRORMESSAGE$", "description": "Displayed when the user's API token is rejected by the forwarding service with an error message.", "placeholders": { "servicename": { @@ -2877,7 +2919,7 @@ } }, "forwarderNoAccountId": { - "message": "Não foi possível obter a máscara do ID da conta de email $SERVICENAME$.", + "message": "Não é possível obter o ID da conta de e-mail mascarado do $SERVICENAME$.", "description": "Displayed when the forwarding service fails to return an account ID.", "placeholders": { "servicename": { @@ -2887,7 +2929,7 @@ } }, "forwarderNoDomain": { - "message": "Domínio $SERVICENAME$ inválido.", + "message": "Domínio inválido do $SERVICENAME$.", "description": "Displayed when the domain is empty or domain authorization failed at the forwarding service.", "placeholders": { "servicename": { @@ -2897,7 +2939,7 @@ } }, "forwarderNoUrl": { - "message": "URL $SERVICENAME$ inválida.", + "message": "URL inválido do $SERVICENAME$.", "description": "Displayed when the url of the forwarding service wasn't supplied.", "placeholders": { "servicename": { @@ -2907,7 +2949,7 @@ } }, "forwarderUnknownError": { - "message": "Ocorreu um erro $SERVICENAME$ desconhecido.", + "message": "Ocorreu um erro desconhecido do $SERVICENAME$.", "description": "Displayed when the forwarding service failed due to an unknown error.", "placeholders": { "servicename": { @@ -2927,41 +2969,41 @@ } }, "hostname": { - "message": "Nome do host", + "message": "Nome do servidor", "description": "Part of a URL." }, "apiAccessToken": { - "message": "Token de acesso API" + "message": "Token de acesso à API" }, "apiKey": { - "message": "Chave de API" + "message": "Chave da API" }, "premiumSubcriptionRequired": { "message": "Assinatura Premium necessária" }, "organizationIsDisabled": { - "message": "Organização está desabilitada." + "message": "Organização suspensa" }, "disabledOrganizationFilterError": { - "message": "Itens em Organizações Desativadas não podem ser acessados. Entre em contato com o proprietário da sua Organização para obter ajuda." + "message": "Itens em organizações suspensas não podem ser acessados. Contate o proprietário da sua organização para obter ajuda." }, "neverLockWarning": { - "message": "Você tem certeza que deseja usar a opção \"Nunca\"? Definir suas opções de bloqueio para \"Nunca\" armazena a chave de criptografia do seu cofre no seu dispositivo. Se você usar esta opção, deve garantir que mantém seu dispositivo devidamente protegido." + "message": "Você tem certeza que deseja usar a opção \"Nunca\"? Ao usar o \"Nunca\", a chave de criptografia do seu cofre é armazenada no seu dispositivo. Se você usar esta opção, deve garantir que mantém seu dispositivo devidamente protegido." }, "vault": { "message": "Cofre" }, "loginWithMasterPassword": { - "message": "‘Login’ com senha ‘master’" + "message": "Conectar-se com senha principal" }, "rememberEmail": { - "message": "Lembrar e-mail" + "message": "Lembrar do e-mail" }, "newAroundHere": { "message": "Novo por aqui?" }, "loggingInTo": { - "message": "Fazendo login em $DOMAIN$", + "message": "Conectando-se a $DOMAIN$", "placeholders": { "domain": { "content": "$1", @@ -2970,13 +3012,13 @@ } }, "logInWithAnotherDevice": { - "message": "Fazer login com outro dispositivo" + "message": "Conectar-se com outro dispositivo" }, "loginInitiated": { - "message": "Login iniciado" + "message": "Autenticação iniciada" }, "logInRequestSent": { - "message": "Pedido enviado" + "message": "Solicitação enviada" }, "notificationSentDevice": { "message": "Uma notificação foi enviada para seu dispositivo." @@ -2985,40 +3027,40 @@ "message": "Uma notificação foi enviada para seu dispositivo" }, "notificationSentDevicePart1": { - "message": "Desbloqueie o Bitwarden no seu dispositivo ou na " + "message": "Desbloqueie o Bitwarden no seu dispositivo ou no " }, "notificationSentDeviceAnchor": { "message": "aplicativo web" }, "notificationSentDevicePart2": { - "message": "Certifique-se de que a frase de biometria corresponde à frase abaixo antes de aprovar." + "message": "Certifique-se de que a frase biométrica corresponde à frase abaixo antes de aprovar." }, "needAnotherOptionV1": { "message": "Precisa de outra opção?" }, "fingerprintMatchInfo": { - "message": "Por favor, certifique-se de que o seu cofre esteja desbloqueado e a frase de identificação corresponda ao outro dispositivo." + "message": "Certifique-se de que o seu cofre esteja desbloqueado e a frase biométrica corresponda ao outro dispositivo." }, "fingerprintPhraseHeader": { - "message": "Frase de identificação" + "message": "Frase biométrica" }, "youWillBeNotifiedOnceTheRequestIsApproved": { - "message": "Você será notificado assim que a requisição for aprovada" + "message": "Você será notificado assim que a solicitação for aprovada" }, "needAnotherOption": { - "message": "Login com dispositivo deve ser habilitado nas configurações do aplicativo móvel do Bitwarden. Necessita de outra opção?" + "message": "A autenticação com dispositivo deve ser ativada nas configurações do aplicativo móvel do Bitwarden. Precisa de outra opção?" }, "viewAllLogInOptions": { - "message": "Ver todas as opções de login" + "message": "Ver todas as opções de autenticação" }, "viewAllLoginOptions": { - "message": "Ver todas as opções de login" + "message": "Ver todas as opções de autenticação" }, "resendNotification": { "message": "Reenviar notificação" }, "toggleCharacterCount": { - "message": "Ativar/Desativar contagem de caracteres", + "message": "Habilitar contagem de caracteres", "description": "'Character count' describes a feature that displays a number next to each character of the password." }, "accessAttemptBy": { @@ -3031,7 +3073,7 @@ } }, "loginRequestApprovedForEmailOnDevice": { - "message": "Solicitação de ‘login’ aprovada para $EMAIL$ em $DEVICE$", + "message": "Solicitação de autenticação aprovada para $EMAIL$ em $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3044,10 +3086,10 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "Você negou uma tentativa de 'login' por outro dispositivo. Se foi você, tente logar com outro dispositivo novamente." + "message": "Você negou uma tentativa de autenticação por outro dispositivo. Se foi você, tente conectar-se com o dispositivo novamente." }, "webApp": { - "message": "Aplicativo da Web" + "message": "Aplicativo web" }, "mobile": { "message": "Móvel", @@ -3072,13 +3114,13 @@ "message": "Servidor" }, "loginRequest": { - "message": "Solicitação de 'Login'" + "message": "Solicitação de autenticação" }, "deviceType": { - "message": "Tipo de dispositivo" + "message": "Tipo do dispositivo" }, "ipAddress": { - "message": "Endereço IP" + "message": "Endereço de IP" }, "time": { "message": "Horário" @@ -3102,10 +3144,10 @@ } }, "loginRequestHasAlreadyExpired": { - "message": "O pedido de login já expirou." + "message": "A solicitação de autenticação já expirou." }, "thisRequestIsNoLongerValid": { - "message": "Este pedido não é mais válido." + "message": "Esta solicitação não é mais válida." }, "confirmAccessAttempt": { "message": "Confirmar tentativa de acesso para $EMAIL$", @@ -3126,40 +3168,40 @@ "message": "Criando conta em" }, "checkYourEmail": { - "message": "Verifique seu e-mail" + "message": "Confira seu e-mail" }, "followTheLinkInTheEmailSentTo": { - "message": "Siga o link no e-mail enviado para" + "message": "Abra o link no e-mail enviado para" }, "andContinueCreatingYourAccount": { "message": "e continue criando a sua conta." }, "noEmail": { - "message": "Sem e-mail?" + "message": "Nenhum e-mail?" }, "goBack": { - "message": "Voltar" + "message": "Volte" }, "toEditYourEmailAddress": { "message": "para editar o seu endereço de e-mail." }, "exposedMasterPassword": { - "message": "Senha Mestra comprometida" + "message": "Senha principal comprometida" }, "exposedMasterPasswordDesc": { "message": "Senha encontrada em um vazamento de dados. Use uma senha única para proteger sua conta. Tem certeza de que deseja usar uma senha já exposta?" }, "weakAndExposedMasterPassword": { - "message": "Senha Mestra Fraca e Comprometida" + "message": "Senha principal fraca e comprometida" }, "weakAndBreachedMasterPasswordDesc": { "message": "Senha fraca identificada e encontrada em um vazamento de dados. Use uma senha forte e única para proteger a sua conta. Tem certeza de que deseja usar essa senha?" }, "checkForBreaches": { - "message": "Verificar vazamentos de dados conhecidos para esta senha" + "message": "Conferir vazamentos de dados conhecidos por esta senha" }, "loggedInExclamation": { - "message": "Sessão Iniciada!" + "message": "Conectado!" }, "important": { "message": "Importante:" @@ -3168,16 +3210,16 @@ "message": "Acessando" }, "accessTokenUnableToBeDecrypted": { - "message": "Você foi desconectado porque seu token de acesso não pôde ser descriptografado. Por favor, faça o login novamente para resolver esse problema." + "message": "Você foi desconectado porque seu token de acesso não pôde ser descriptografado. Conecte-se novamente para resolver esse problema." }, "refreshTokenSecureStorageRetrievalFailure": { - "message": "Você foi desconectado porque seu token de atualização não pôde ser recuperado. Por favor, faça o login novamente para resolver esse problema." + "message": "Você foi desconectado porque seu token de recarregamento não pôde ser recuperado. Conecte-se novamente para resolver esse problema." }, "masterPasswordHint": { - "message": "A sua senha mestra não pode ser recuperada se você esquecê-la!" + "message": "A sua senha principal não pode ser recuperada se você esquecê-la!" }, "characterMinimum": { - "message": "$LENGTH$ caracteres mínimos", + "message": "Mínimo de $LENGTH$ caracteres", "placeholders": { "length": { "content": "$1", @@ -3186,19 +3228,19 @@ } }, "windowsBiometricUpdateWarning": { - "message": "O Bitwarden recomenda a atualização das suas configurações de biometria para exigir a sua senha mestra (ou PIN) no primeiro desbloqueio. Gostaria de atualizar suas configurações agora?" + "message": "O Bitwarden recomenda a atualização das suas configurações de biometria para exigir a sua senha principal (ou PIN) no primeiro desbloqueio. Gostaria de atualizar suas configurações agora?" }, "windowsBiometricUpdateWarningTitle": { - "message": "Atualização de configurações recomendadas" + "message": "Atualização recomendada das configurações" }, "rememberThisDeviceToMakeFutureLoginsSeamless": { - "message": "Lembrar deste dispositivo para permanecer conectado" + "message": "Lembrar deste dispositivo para tornar futuras autenticações simples" }, "deviceApprovalRequired": { "message": "Aprovação do dispositivo necessária. Selecione uma opção de aprovação abaixo:" }, "deviceApprovalRequiredV2": { - "message": "É necessária a aprovação do dispositivo" + "message": "Aprovação do dispositivo necessária" }, "selectAnApprovalOptionBelow": { "message": "Selecione uma opção de aprovação abaixo" @@ -3216,19 +3258,19 @@ "message": "Solicitar aprovação do administrador" }, "unableToCompleteLogin": { - "message": "Incapaz de completar o login" + "message": "Incapaz de concluir a autenticação" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { - "message": "Você precisar entrar em um dispositivo confiável ou solicitar ao administrador que lhe atribua uma senha." + "message": "Você precisa se conectar com um dispositivo confiado ou solicitar ao administrador que lhe atribua uma senha." }, "region": { "message": "Região" }, "ssoIdentifierRequired": { - "message": "Identificador SSO da organização é necessário." + "message": "O identificador de SSO da organização é necessário." }, "eu": { - "message": "Europa", + "message": "União Europeia", "description": "European Union" }, "selfHostedServer": { @@ -3244,25 +3286,25 @@ "message": "Aprovação do administrador necessária" }, "adminApprovalRequestSentToAdmins": { - "message": "Seu pedido foi enviado para seu administrador." + "message": "Sua solicitação foi enviada para seu administrador." }, "troubleLoggingIn": { - "message": "Problemas em efetuar login?" + "message": "Problemas para acessar?" }, "loginApproved": { - "message": "Login aprovado" + "message": "Autenticação aprovada" }, "userEmailMissing": { "message": "E-mail do usuário ausente" }, "activeUserEmailNotFoundLoggingYouOut": { - "message": "E-mail de usuário ativo não encontrado. Desconectando." + "message": "E-mail do usuário ativo não encontrado. Desconectando você." }, "deviceTrusted": { - "message": "Dispositivo confiável" + "message": "Dispositivo confiado" }, "trustOrganization": { - "message": "Organização confiável" + "message": "Confiar organização" }, "trust": { "message": "Confiar" @@ -3271,19 +3313,19 @@ "message": "Não confiar" }, "organizationNotTrusted": { - "message": "Organização não confiável" + "message": "Organização não foi confiada" }, "emergencyAccessTrustWarning": { - "message": "Para a segurança da sua conta, apenas confirme se você permitiu o acesso de emergência a esse usuário e se a impressão digital dele coincide com a que é exibida em sua conta" + "message": "Para a segurança da sua conta, apenas confirme se você permitiu o acesso de emergência a esse usuário e se a frase biométrica dele coincide com a que é exibida na conta deles" }, "orgTrustWarning": { - "message": "Para a segurança da sua conta, prossiga apenas se você for um membro dessa organização, tem uma recuperação de conta ativa e a impressão digital exibida abaixo corresponde com a impressão digital da organização." + "message": "Para a segurança da sua conta, prossiga apenas se você for um membro dessa organização, tem uma recuperação de conta ativa e a frase biométrica exibida abaixo corresponde com a da organização." }, "orgTrustWarning1": { - "message": "Esta organização tem uma política empresarial que lhe inscreverá na recuperação de conta. A matrícula permitirá que os administradores da organização alterem sua senha. Prossiga somente se você reconhecer esta organização e se a frase biométrica exibida abaixo corresponde com a impressão digital da organização." + "message": "Esta organização tem uma política empresarial que lhe inscreverá na recuperação de conta. A inscrição permitirá que os administradores da organização alterem sua senha. Prossiga somente se você reconhecer esta organização e se a frase biométrica exibida abaixo corresponde com a da organização." }, "trustUser": { - "message": "Usuário confiável" + "message": "Confiar no usuário" }, "inputRequired": { "message": "Entrada necessária." @@ -3292,7 +3334,7 @@ "message": "obrigatório" }, "search": { - "message": "Pesquisar" + "message": "Buscar" }, "inputMinLength": { "message": "A entrada deve ter pelo menos $COUNT$ caracteres.", @@ -3331,7 +3373,7 @@ } }, "inputMaxValue": { - "message": "O valor de entrada não deve exceder $MAX$.", + "message": "O valor de entrada deve não exceder $MAX$.", "placeholders": { "max": { "content": "$1", @@ -3365,16 +3407,16 @@ "message": "-- Digite para filtrar --" }, "multiSelectLoading": { - "message": "Carrgando Opções..." + "message": "Carrgando opções..." }, "multiSelectNotFound": { "message": "Nenhum item encontrado" }, "multiSelectClearAll": { - "message": "Limpar todos" + "message": "Limpar tudo" }, "plusNMore": { - "message": "+ $QUANTITY$ mais", + "message": "+ mais $QUANTITY$", "placeholders": { "quantity": { "content": "$1", @@ -3386,7 +3428,7 @@ "message": "Submenu" }, "toggleSideNavigation": { - "message": "Ativar/desativar navegação lateral" + "message": "Habilitar navegação lateral" }, "skipToContent": { "message": "Ir para o conteúdo" @@ -3395,13 +3437,13 @@ "message": "Chave de acesso" }, "passkeyNotCopied": { - "message": "A senha não será copiada" + "message": "A chave de acesso não será copiada" }, "passkeyNotCopiedAlert": { - "message": "A senha não será copiada para o item clonado. Deseja continuar clonando este item?" + "message": "A chave de acesso não será copiada para o item clonado. Deseja continuar clonando este item?" }, "aliasDomain": { - "message": "Alias do domínio" + "message": "Domínio de alias" }, "importData": { "message": "Importar dados", @@ -3411,7 +3453,7 @@ "message": "Erro ao importar" }, "importErrorDesc": { - "message": "Houve um problema com os dados que você tentou importar. Por favor, resolva os erros listados abaixo em seu arquivo de origem e tente novamente." + "message": "Houve um problema com os dados que você tentou importar. Resolva os erros listados abaixo em seu arquivo de origem e tente novamente." }, "resolveTheErrorsBelowAndTryAgain": { "message": "Resolva os erros abaixo e tente novamente." @@ -3450,19 +3492,19 @@ "message": "A autenticação em duas etapas do Duo é necessária para sua conta." }, "duoTwoFactorRequiredPageSubtitle": { - "message": "A autenticação de dois fatores é necessária para sua conta. Siga os passos abaixo para conseguir entrar." + "message": "A autenticação de duas etapas do Duo é necessária para sua conta. Siga os passos abaixo para conseguir se conectar." }, "followTheStepsBelowToFinishLoggingIn": { - "message": "Siga os passos abaixo para finalizar o login." + "message": "Siga os passos abaixo para terminar de se conectar." }, "followTheStepsBelowToFinishLoggingInWithSecurityKey": { - "message": "Siga os passos abaixo para finalizar o login com a sua chave de segurança." + "message": "Siga os passos abaixo para finalizar a autenticação com a sua chave de segurança." }, "launchDuo": { - "message": "Iniciar o Duo no navegador" + "message": "Abrir o Duo no navegador" }, "importFormatError": { - "message": "Os dados não estão formatados corretamente. Por favor, verifique o seu arquivo de importação e tente novamente." + "message": "Os dados não estão formatados corretamente. Verifique o seu arquivo de importação e tente novamente." }, "importNothingError": { "message": "Nada foi importado." @@ -3471,7 +3513,7 @@ "message": "Erro ao descriptografar o arquivo exportado. Sua chave de criptografia não corresponde à chave de criptografia usada para exportar os dados." }, "invalidFilePassword": { - "message": "Senha do arquivo inválida, por favor informe a senha utilizada quando criou o arquivo de exportação." + "message": "Senha do arquivo inválida, informe a senha utilizada quando criou o arquivo de exportação." }, "destination": { "message": "Destino" @@ -3483,13 +3525,13 @@ "message": "Selecione uma pasta" }, "selectImportCollection": { - "message": "Selecione uma coleção" + "message": "Selecione um conjunto" }, "importTargetHintCollection": { - "message": "Selecione esta opção caso queira importar os conteúdos de arquivos movidos para a coleção" + "message": "Selecione esta opção caso queira importar os conteúdos do arquivo para um conjunto" }, "importTargetHintFolder": { - "message": "Selecione esta opção caso queira importar os conteúdos de arquivos movidos para a pasta" + "message": "Selecione esta opção caso queira importar os conteúdos do arquivo para uma pasta" }, "importUnassignedItemsError": { "message": "Arquivo contém itens não atribuídos." @@ -3501,7 +3543,7 @@ "message": "Selecione o arquivo de importação" }, "chooseFile": { - "message": "Selecionar Arquivo" + "message": "Selecionar arquivo" }, "noFileChosen": { "message": "Nenhum arquivo escolhido" @@ -3510,7 +3552,7 @@ "message": "ou copie/cole o conteúdo do arquivo de importação" }, "instructionsFor": { - "message": "$NAME$ instruções", + "message": "Instruções para $NAME$", "description": "The title for the import tool instructions.", "placeholders": { "name": { @@ -3523,7 +3565,7 @@ "message": "Confirmar importação do cofre" }, "confirmVaultImportDesc": { - "message": "Este arquivo é protegido por senha. Por favor, digite a senha do arquivo para importar os dados." + "message": "Este arquivo é protegido por senha. Digite a senha do arquivo para importar os dados." }, "confirmFilePassword": { "message": "Confirmar senha do arquivo" @@ -3538,7 +3580,7 @@ "message": "Nenhum dado do LastPass encontrado" }, "incorrectUsernameOrPassword": { - "message": "Nome de usuário ou senha incorretos" + "message": "Nome do usuário ou senha incorretos" }, "incorrectPassword": { "message": "Senha incorreta" @@ -3556,19 +3598,19 @@ "message": "Incluir pastas compartilhadas" }, "lastPassEmail": { - "message": "LastPass Email" + "message": "E-mail do LastPass" }, "importingYourAccount": { "message": "Importando sua conta..." }, "lastPassMFARequired": { - "message": "Requer autenticação multifatores do LastPass" + "message": "Requer autenticação de múltiplos fatores do LastPass" }, "lastPassMFADesc": { - "message": "Digite sua senha única do app de autenticação" + "message": "Digite sua senha única do app autenticador" }, "lastPassOOBDesc": { - "message": "Aprove a solicitação de login no seu aplicativo de autenticação ou insira código unico." + "message": "Aprove a solicitação de autenticação no seu aplicativo autenticador ou digite um código único." }, "passcode": { "message": "Código" @@ -3580,16 +3622,16 @@ "message": "Autenticação do LastPass necessária" }, "awaitingSSO": { - "message": "Aguardando autenticação SSO" + "message": "Aguardando autenticação do SSO" }, "awaitingSSODesc": { - "message": "Por favor, continue a iniciar a sessão usando as credenciais da sua empresa." + "message": "Continue conectando-se usando as credenciais da sua empresa." }, "importDirectlyFromBrowser": { - "message": "Import directly from browser" + "message": "Importar diretamente do navegador" }, "browserProfile": { - "message": "Browser Profile" + "message": "Perfil do navegador" }, "seeDetailedInstructions": { "message": "Veja instruções detalhadas no nosso site de ajuda em", @@ -3605,21 +3647,21 @@ "message": "Tente novamente ou procure um e-mail do LastPass para verificar que é você." }, "collection": { - "message": "Coleção" + "message": "Conjunto" }, "lastPassYubikeyDesc": { - "message": "Insira a YubiKey associada com a sua conta do LastPass na porta USB do seu computador, e depois toque no botão dele." + "message": "Insira a YubiKey associada com a sua conta do LastPass na porta USB do seu computador, e depois toque no botão dela." }, "commonImportFormats": { "message": "Formatos comuns", "description": "Label indicating the most common import formats" }, "uriMatchDefaultStrategyHint": { - "message": "Detecção de correspondência de URI é como o Bitwarden identifica sugestões de autopreenchimento.", + "message": "Detecção de correspondência de URI é como o Bitwarden identifica sugestões de preenchimento automático.", "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": { - "message": "\"Expressão regular\" é uma opção avançada com maior risco de exposição de credenciais.", + "message": "\"Expressão regular\" é uma opção avançada com maior risco de exposição das credenciais.", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, "startsWithAdvancedOptionWarning": { @@ -3627,7 +3669,7 @@ "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { - "message": "Mais sobre detecção de correspondências", + "message": "Mais sobre a detecção de correspondência", "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { @@ -3635,7 +3677,7 @@ "description": "Advanced option placeholder for uri option component" }, "warningCapitalized": { - "message": "Aviso", + "message": "Atenção", "description": "Warning (should maintain locale-relevant capitalization)" }, "success": { @@ -3651,19 +3693,19 @@ "message": "Ativar aceleração de hardware e reiniciar" }, "removePasskey": { - "message": "Remover senha" + "message": "Remover chave de acesso" }, "passkeyRemoved": { "message": "Chave de acesso removida" }, "errorAssigningTargetCollection": { - "message": "Erro ao atribuir coleção de destino." + "message": "Erro ao atribuir conjunto de destino." }, "errorAssigningTargetFolder": { "message": "Erro ao atribuir pasta de destino." }, "viewItemsIn": { - "message": "Visualizar itens em $NAME$", + "message": "Ver itens em $NAME$", "description": "Button to view the contents of a folder or collection", "placeholders": { "name": { @@ -3697,26 +3739,26 @@ } }, "data": { - "message": "Dado" + "message": "Dados" }, "fileSends": { - "message": "Arquivos enviados" + "message": "Sends de arquivo" }, "textSends": { - "message": "Texto enviado" + "message": "Sends de texto" }, "ssoError": { - "message": "Nenhuma porta livre foi encontrada para o cliente final." + "message": "Nenhuma porta livre foi encontrada para a autenticação de SSO." }, "securePasswordGenerated": { "message": "Senha segura gerada! Não esqueça de também atualizar a sua senha no site." }, "useGeneratorHelpTextPartOne": { - "message": "Usar o gerador", + "message": "Use o gerador", "description": "This will be used as part of a larger sentence, broken up to include the generator icon. The full sentence will read 'Use the generator [GENERATOR_ICON] to create a strong unique password'" }, "useGeneratorHelpTextPartTwo": { - "message": "ppara criar uma senha única e segura", + "message": "para criar uma senha única e segura", "description": "This will be used as part of a larger sentence, broken up to include the generator icon. The full sentence will read 'Use the generator [GENERATOR_ICON] to create a strong unique password'" }, "biometricsStatusHelptextUnlockNeeded": { @@ -3726,13 +3768,13 @@ "message": "O desbloqueio por biometria está indisponível no momento." }, "biometricsStatusHelptextAutoSetupNeeded": { - "message": "O desbloqueio por biometria está indisponível devido a algum erro na configuração dos arquivos de sistema." + "message": "O desbloqueio por biometria está indisponível devido a algum erro na configuração dos arquivos do sistema." }, "biometricsStatusHelptextManualSetupNeeded": { - "message": "O desbloqueio por biometria está indisponível devido a algum erro na configuração dos arquivos de sistema." + "message": "O desbloqueio por biometria está indisponível devido a algum erro na configuração dos arquivos do sistema." }, "biometricsStatusHelptextNotEnabledLocally": { - "message": "O desbloqueio por biometria está indisponível, pois a opção não foi ativada no aplicativo de desktop de $EMAIL$.", + "message": "O desbloqueio por biometria está indisponível, pois a opção não foi ativada no aplicativo de computador de $EMAIL$.", "placeholders": { "email": { "content": "$1", @@ -3750,7 +3792,7 @@ "message": "Nome do item" }, "loginCredentials": { - "message": "Credenciais de login" + "message": "Credenciais de acesso" }, "additionalOptions": { "message": "Opções adicionais" @@ -3762,7 +3804,7 @@ "message": "Última edição" }, "upload": { - "message": "Fazer upload" + "message": "Enviar" }, "authorize": { "message": "Autorizar" @@ -3777,7 +3819,7 @@ "message": "Aviso: Encaminhamento de Agente" }, "agentForwardingWarningText": { - "message": "Este pedido vem de um dispositivo remoto em que você está logado" + "message": "Esta solicitação vem de um dispositivo remoto em qual você está conectado" }, "sshkeyApprovalMessageInfix": { "message": "está solicitando acesso para" @@ -3789,13 +3831,13 @@ "message": "autenticar em um servidor" }, "sshActionSign": { - "message": "assine uma mensagem" + "message": "assinar uma mensagem" }, "sshActionGitSign": { - "message": "assine um commit no git" + "message": "assinar uma commit no git" }, "unknownApplication": { - "message": "Uma aplicação" + "message": "Um aplicativo" }, "invalidSshKey": { "message": "A chave SSH é inválida" @@ -3816,31 +3858,31 @@ "message": "Permitir captura de tela" }, "allowScreenshotsDesc": { - "message": "Permitir que a aplicação do Bitwarden seja capturada em capturas de tela e visualizada em sessões remotas no computador. Desabilitar isso impedirá acesso em algumas telas externas." + "message": "Permitir que o aplicativo do Bitwarden seja capturado em capturas de tela e visualizado em sessões remotas no computador. Desativar isso impede o acesso em algumas telas externas." }, "confirmWindowStillVisibleTitle": { "message": "Confirmar janela ainda visível" }, "confirmWindowStillVisibleContent": { - "message": "Por favor, confirme que a janela ainda está visível." + "message": "Confirme que a janela ainda está visível." }, "updateBrowserOrDisableFingerprintDialogTitle": { "message": "Atualização de extensão necessária" }, "updateBrowserOrDisableFingerprintDialogMessage": { - "message": "A extensão de navegador que você está usando está desatualizada. Atualize-a ou desabilite a validação de integração de impressão digital do navegador nas configurações do aplicativo da área de trabalho." + "message": "A extensão de navegador que você está usando está desatualizada. Atualize-a ou desative a validação da frase biométrica da integração do navegador nas configurações do aplicativo de computador." }, "changeAtRiskPassword": { - "message": "Alterar senhas vulneráveis" + "message": "Alterar senhas em risco" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "message": "Esta credencial está em risco e está sem um site. Adicione um site e altere a senha para segurança melhor." }, "missingWebsite": { - "message": "Missing website" + "message": "Site ausente" }, "cannotRemoveViewOnlyCollections": { - "message": "Você não pode remover coleções com permissões de Apenas visualização: $COLLECTIONS$", + "message": "Você não pode remover conjuntos com permissões de Apenas ver: $COLLECTIONS$", "placeholders": { "collections": { "content": "$1", @@ -3858,38 +3900,38 @@ "message": "Nome da pasta" }, "folderHintText": { - "message": "Aninhe uma pasta adicionando o nome da pasta pai seguido de uma \"/\". Exemplo: Social/Fóruns" + "message": "Agrupe uma pasta adicionando o nome da pasta mãe seguido de uma \"/\". Exemplo: Social/Fóruns" }, "sendsTitleNoItems": { - "message": "Enviar informações sensíveis com segurança", + "message": "Envie informações sensíveis com segurança", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsBodyNoItems": { - "message": "Compartilhe dados e arquivos com segurança com qualquer pessoa ou plataforma. Suas informações permanecerão com criptografia de ponta a ponta, limitando exposições.", + "message": "Compartilhe dados e arquivos com segurança com qualquer pessoa, em qualquer plataforma. Suas informações permanecerão criptografadas de ponta a ponta, limitando a exposição.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "generatorNudgeTitle": { - "message": "Criação rápida de senhas" + "message": "Crie senhas de forma rápida" }, "generatorNudgeBodyOne": { - "message": "Crie facilmente senhas únicas e fortes clicando em", + "message": "Crie senhas únicas e fortes com facilidade clicando em", "description": "Two part message", "example": "Easily create strong and unique passwords by clicking on {icon} to help you keep your logins secure." }, "generatorNudgeBodyTwo": { - "message": "para ajudá-lo a manter seus logins seguros.", + "message": "para ajudá-lo a manter suas credenciais seguras.", "description": "Two part message", "example": "Easily create strong and unique passwords by clicking on {icon} to help you keep your logins secure." }, "generatorNudgeBodyAria": { - "message": "Crie facilmente senhas únicas e fortes clicando no botão Gerador de senhas para ajudá-lo a manter seus logins seguros.", + "message": "Crie senhas únicas e fortes com facilidade clicando no botão do Gerador de senhas para ajudá-lo a manter suas credenciais seguras.", "description": "Aria label for the body content of the generator nudge" }, "newLoginNudgeTitle": { "message": "Economize tempo com o preenchimento automático" }, "newLoginNudgeBodyOne": { - "message": "Incluir em", + "message": "Inclua um", "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." }, @@ -3899,67 +3941,67 @@ "example": "Include a Website so this login appears as an autofill suggestion." }, "newLoginNudgeBodyTwo": { - "message": "assim este login aparece como uma sugestão de preenchimento automático.", + "message": "para que esta credencial apareça como uma sugestão de preenchimento automático.", "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." }, "newCardNudgeTitle": { - "message": "Pagamento online simplificado" + "message": "Pagamento on-line simplificado" }, "newCardNudgeBody": { "message": "Preencha automaticamente formulários de pagamento com cartões de forma segura e precisa." }, "newIdentityNudgeTitle": { - "message": "Criação de contas simplificada" + "message": "Simplifique a criação de contas" }, "newIdentityNudgeBody": { - "message": "Preencha automaticamente formulários longos de registro ou contato de forma rápida." + "message": "Preencha automaticamente formulários longos de cadastro ou contato de forma rápida." }, "newNoteNudgeTitle": { "message": "Mantenha seus dados sensíveis seguros" }, "newNoteNudgeBody": { - "message": "Com notas, armazena com segurança dados sensíveis como detalhes de informações bancárias ou de seguro." + "message": "Com anotações, armazene com segurança dados sensíveis como detalhes de informações bancárias ou de seguro." }, "newSshNudgeTitle": { "message": "Acesso SSH amigável para desenvolvedores" }, "newSshNudgeBodyOne": { - "message": "Armazena suas chaves e conecta com o agente SSH para uma autenticação rápida e criptografada.", + "message": "Armazene suas chaves e conecte com o agente SSH para uma autenticação rápida e criptografada.", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "newSshNudgeBodyTwo": { - "message": "Aprenda mais sobre o agente SSH", + "message": "Saiba mais sobre o agente SSH", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "aboutThisSetting": { - "message": "About this setting" + "message": "Sobre esta configuração" }, "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." + "message": "O Bitwarden usará URIs de credenciais salvas para identificar qual ícone ou URL de mudança de senha deverá ser usado para melhorar sua experiência. Nenhuma informação é coletada ou salva quando você utiliza este serviço." }, "assignToCollections": { - "message": "Atribuído a coleções" + "message": "Atribuir a conjuntos" }, "assignToTheseCollections": { - "message": "Atribuído a essas coleções" + "message": "Atribuir a estes conjuntos" }, "bulkCollectionAssignmentDialogDescriptionSingular": { - "message": "Apenas membros da organização com acesso a essas coleções poderão ver o item." + "message": "Apenas membros da organização com acesso a estes conjuntos poderão ver o item." }, "bulkCollectionAssignmentDialogDescriptionPlural": { - "message": "Apenas membros da organização com acesso a essas coleções poderão ver os itens." + "message": "Apenas membros da organização com acesso a estes conjuntos poderão ver os itens." }, "noCollectionsAssigned": { - "message": "Nenhuma coleção foi atribuída" + "message": "Nenhum conjunto foi atribuído" }, "assign": { "message": "Atribuir" }, "bulkCollectionAssignmentDialogDescription": { - "message": "Apenas membros da organização com acesso a essas coleções poderão ver os itens." + "message": "Apenas membros da organização com acesso a estes conjuntos poderão ver os itens." }, "bulkCollectionAssignmentWarning": { "message": "Você selecionou $TOTAL_COUNT$ itens. Você não pode atualizar $READONLY_COUNT$ dos itens, pois você não tem as permissões de edição.", @@ -3974,10 +4016,10 @@ } }, "selectCollectionsToAssign": { - "message": "Selecionar coleções para atribuir" + "message": "Selecione conjuntos a atribuir" }, "personalItemsTransferWarning": { - "message": "$PERSONAL_ITEMS_COUNT$ serão permanentemente transferidas para a organização selecionada. Você deixará de ser o proprietário desses itens.", + "message": "$PERSONAL_ITEMS_COUNT$ serão permanentemente transferidos para a organização selecionada. Você deixará de ser o proprietário desses itens.", "placeholders": { "personal_items_count": { "content": "$1", @@ -3986,7 +4028,7 @@ } }, "personalItemsWithOrgTransferWarning": { - "message": "$PERSONAL_ITEMS_COUNT$ serão permanentemente transferidas para $ORG$. Você deixará de ser o proprietário desses itens.", + "message": "$PERSONAL_ITEMS_COUNT$ serão permanentemente transferidos para $ORG$. Você deixará de ser o proprietário desses itens.", "placeholders": { "personal_items_count": { "content": "$1", @@ -3999,10 +4041,10 @@ } }, "personalItemTransferWarningSingular": { - "message": "1 item será permanentemente transferido para a organização selecionada. Você deixará de ser o proprietário desse item." + "message": "Um item será permanentemente transferido para a organização selecionada. Você deixará de ser o proprietário desse item." }, "personalItemWithOrgTransferWarningSingular": { - "message": "1 item será permanentemente transferido para $ORG$. Você deixará de ser o proprietário desse item.", + "message": "Um item será permanentemente transferido para $ORG$. Você deixará de ser o proprietário desse item.", "placeholders": { "org": { "content": "$1", @@ -4011,7 +4053,7 @@ } }, "successfullyAssignedCollections": { - "message": "Coleções atribuídas com sucesso" + "message": "Conjuntos atribuídos com sucesso" }, "nothingSelected": { "message": "Você não selecionou nada." @@ -4080,59 +4122,106 @@ "showLess": { "message": "Mostrar menos" }, - "enableAutotype": { - "message": "Habilitar Autotype" - }, "enableAutotypeDescription": { - "message": "Bitwarden não valida localizações de entrada, tenha certeza de estar na janela e campo corretos antes de utilizar o atalho." + "message": "O Bitwarden não valida localizações de entrada, tenha certeza de estar na janela e campo corretos antes de utilizar o atalho." + }, + "typeShortcut": { + "message": "Atalho de digitação" + }, + "editAutotypeShortcutDescription": { + "message": "Inclua um ou dois dos seguintes modificadores: Ctrl, Alt, Win, ou Shift, e uma letra." + }, + "invalidShortcut": { + "message": "Atalho inválido" }, "moreBreadcrumbs": { "message": "Mais trilhas", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." }, "next": { - "message": "Next" + "message": "Avançar" }, "confirmKeyConnectorDomain": { - "message": "Confirm Key Connector domain" + "message": "Confirmar domínio do Key Connector" }, "confirm": { - "message": "Confirm" + "message": "Confirmar" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Ativar atalho de digitação automática (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { - "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." + "enableAutotypeShortcutDescription": { + "message": "Certifique-se que está no campo correto antes de usar o atalho para evitar preencher dados no lugar errado." }, "editShortcut": { - "message": "Edit shortcut" + "message": "Editar atalho" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Arquivo", + "description": "Noun" }, - "unarchive": { - "message": "Unarchive" + "archiveVerb": { + "message": "Arquivar", + "description": "Verb" + }, + "unArchive": { + "message": "Desarquivar" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Itens no arquivo" }, "noItemsInArchive": { - "message": "No items in archive" + "message": "Nenhum item no arquivo" }, "noItemsInArchiveDesc": { - "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + "message": "Os itens arquivados aparecerão aqui e serão excluídos dos resultados gerais de busca e das sugestões de preenchimento automático." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "O item foi enviado para o arquivo" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "O item foi desarquivado" }, "archiveItem": { - "message": "Archive item" + "message": "Arquivar item" }, "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "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?" + }, + "zipPostalCodeLabel": { + "message": "CEP / Código postal" + }, + "cardNumberLabel": { + "message": "Número do cartão" + }, + "upgradeNow": { + "message": "Faça upgrade agora" + }, + "builtInAuthenticator": { + "message": "Autenticador integrado" + }, + "secureFileStorage": { + "message": "Armazenamento seguro de arquivos" + }, + "emergencyAccess": { + "message": "Acesso de emergência" + }, + "breachMonitoring": { + "message": "Monitoramento de vazamentos" + }, + "andMoreFeatures": { + "message": "E mais!" + }, + "planDescPremium": { + "message": "Segurança on-line completa" + }, + "upgradeToPremium": { + "message": "Faça upgrade para o Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Ação do tempo limite" + }, + "sessionTimeoutHeader": { + "message": "Tempo limite da sessão" } } diff --git a/apps/desktop/src/locales/pt_PT/messages.json b/apps/desktop/src/locales/pt_PT/messages.json index 49573dcd647..de0427ddab0 100644 --- a/apps/desktop/src/locales/pt_PT/messages.json +++ b/apps/desktop/src/locales/pt_PT/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "Não tem permissão para editar este item" + }, "welcomeBack": { "message": "Bem-vindo de volta" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Utilizar início de sessão único" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "A sua organização exige o início de sessão único." + }, "submit": { "message": "Submeter" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Deve adicionar o URL do servidor de base ou pelo menos um ambiente personalizado." }, + "selfHostedEnvMustUseHttps": { + "message": "Os URLs devem usar HTTPS." + }, "customEnvironment": { "message": "Ambiente personalizado" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Palavra-passe mestra inválida" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Palavra-passe mestra inválida. Confirme se o seu e-mail está correto e se a sua conta foi criada em $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "A verificação de dois passos torna a sua conta mais segura, exigindo que verifique o seu início de sessão com outro dispositivo, como uma chave de segurança, aplicação de autenticação, SMS, chamada telefónica ou e-mail. A verificação de dois passos pode ser configurada em bitwarden.com. Pretende visitar o site agora?" }, @@ -1283,7 +1301,7 @@ "message": "Na suspensão do sistema" }, "onLocked": { - "message": "No bloqueio do sistema" + "message": "Ao bloquear o sistema" }, "onRestart": { "message": "Ao reiniciar" @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Bloquear com a palavra-passe mestra ao reiniciar" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Exigir a palavra-passe mestra ou PIN ao reiniciar a app" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Exigir a palavra-passe mestra ao reiniciar a app" + }, "deleteAccount": { "message": "Eliminar conta" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Ocorreu um erro ao ativar a integração do navegador." }, - "browserIntegrationMasOnlyDesc": { - "message": "Infelizmente, a integração do navegador só é suportada na versão da Mac App Store por enquanto." - }, "browserIntegrationWindowsStoreDesc": { "message": "Infelizmente, a integração do navegador não é atualmente suportada na versão da Microsoft Store." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "O tempo limite mínimo personalizado é de 1 minuto." + }, "inviteAccepted": { "message": "Convite aceite" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Apenas o cofre da organização associado a $ORGANIZATION$ será exportado.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Apenas o cofre da organização associado a $ORGANIZATION$ será exportado. As coleções dos meus itens não serão incluídas.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Bloqueado" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Mostrar menos" }, - "enableAutotype": { - "message": "Ativar digitação automática" - }, "enableAutotypeDescription": { "message": "O Bitwarden não valida a introdução de localizações. Certifique-se de que está na janela e no campo corretos antes de utilizar o atalho." }, + "typeShortcut": { + "message": "Introduzir atalho" + }, + "editAutotypeShortcutDescription": { + "message": "Inclua um ou dois dos seguintes modificadores: Ctrl, Alt, Win, ou Shift, e uma letra." + }, + "invalidShortcut": { + "message": "Atalho inválido" + }, "moreBreadcrumbs": { "message": "Mais da navegação estrutural", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirmar" }, - "enableAutotypeTransitionKey": { - "message": "Ativar o atalho de introdução automática" + "enableAutotypeShortcutPreview": { + "message": "Ativar o atalho de digitação automática (Pré-visualização da funcionalidade)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Certifique-se de que está no campo correto antes de utilizar o atalho para evitar preencher dados no local errado." }, "editShortcut": { "message": "Editar atalho" }, - "archive": { - "message": "Arquivar" + "archiveNoun": { + "message": "Arquivo", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Arquivar", + "description": "Verb" + }, + "unArchive": { "message": "Desarquivar" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Os itens arquivados aparecerão aqui e serão excluídos dos resultados gerais da pesquisa e das sugestões de preenchimento automático." }, - "itemSentToArchive": { - "message": "Item movido para o arquivo" + "itemWasSentToArchive": { + "message": "O item foi movido para o arquivo" }, - "itemRemovedFromArchive": { - "message": "Item removido do arquivo" + "itemWasUnarchived": { + "message": "O item foi desarquivado" }, "archiveItem": { "message": "Arquivar item" }, "archiveItemConfirmDesc": { "message": "Os itens arquivados são excluídos dos resultados gerais da pesquisa e das sugestões de preenchimento automático. Tem a certeza de que pretende arquivar este item?" + }, + "zipPostalCodeLabel": { + "message": "Código postal" + }, + "cardNumberLabel": { + "message": "Número do cartão" + }, + "upgradeNow": { + "message": "Atualizar agora" + }, + "builtInAuthenticator": { + "message": "Autenticador incorporado" + }, + "secureFileStorage": { + "message": "Armazenamento seguro de ficheiros" + }, + "emergencyAccess": { + "message": "Acesso de emergência" + }, + "breachMonitoring": { + "message": "Monitorização de violações" + }, + "andMoreFeatures": { + "message": "E muito mais!" + }, + "planDescPremium": { + "message": "Segurança total online" + }, + "upgradeToPremium": { + "message": "Atualizar para o Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Ação de tempo limite" + }, + "sessionTimeoutHeader": { + "message": "Tempo limite da sessão" } } diff --git a/apps/desktop/src/locales/ro/messages.json b/apps/desktop/src/locales/ro/messages.json index 802afc3ef22..a72ce3547e9 100644 --- a/apps/desktop/src/locales/ro/messages.json +++ b/apps/desktop/src/locales/ro/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Trimitere" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Mediu personalizat" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Parolă principală incorectă" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Autentificarea în două etape vă face contul mai sigur, cerându-vă să vă verificați autentificarea cu un alt dispozitiv, cum ar fi o cheie de securitate, o aplicație de autentificare, un SMS, un apel telefonic sau un e-mail. Autentificarea în două etape poate fi configurată pe seiful web bitwarden.com. Doriți să vizitați site-ul web acum?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Ștergere cont" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Din păcate, integrarea browserului este acceptată numai în versiunea Mac App Store pentru moment." - }, "browserIntegrationWindowsStoreDesc": { "message": "Din păcate, integrarea browserului nu este susținută în prezent în versiunea Microsoft Store." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Blocat" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/ru/messages.json b/apps/desktop/src/locales/ru/messages.json index 292564be5f3..914bb603630 100644 --- a/apps/desktop/src/locales/ru/messages.json +++ b/apps/desktop/src/locales/ru/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "У вас нет разрешения на редактирование этого элемента" + }, "welcomeBack": { "message": "С возвращением" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Использовать единый вход" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Отправить" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Вы должны добавить либо базовый URL сервера, либо хотя бы одно пользовательское окружение." }, + "selfHostedEnvMustUseHttps": { + "message": "URL должны использовать HTTPS." + }, "customEnvironment": { "message": "Пользовательское окружение" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Неверный мастер-пароль" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Неверный мастер-пароль. Подтвердите, что ваш адрес email указан верно и ваш аккаунт был создан на $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Двухэтапная аутентификация делает аккаунт более защищенным, поскольку требуется подтверждение входа при помощи другого устройства, например, ключа безопасности, приложения-аутентификатора, SMS, телефонного звонка или электронной почты. Двухэтапная аутентификация включается на bitwarden.com. Перейти на сайт сейчас?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Блокировать мастер-паролем при перезапуске" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Требовать мастер-пароль или PIN при перезапуске приложения" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Требовать мастер-пароль при перезапуске приложения" + }, "deleteAccount": { "message": "Удалить аккаунт" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Произошла ошибка при включении интеграции с браузером." }, - "browserIntegrationMasOnlyDesc": { - "message": "К сожалению, интеграция браузера пока поддерживается только в версии Mac App Store." - }, "browserIntegrationWindowsStoreDesc": { "message": "К сожалению, интеграция браузера в версии для Microsoft Store в настоящее время не поддерживается." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Минимальный пользовательский тайм-аут составляет 1 минуту." + }, "inviteAccepted": { "message": "Приглашение принято" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Будет экспортировано только хранилище организации, связанное с $ORGANIZATION$.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Будет экспортировано только хранилище организации, связанное с $ORGANIZATION$. Коллекции Мои элементы включены не будут.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Заблокировано" }, @@ -3834,7 +3876,7 @@ "message": "Изменить пароль, подверженный риску" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "Этот логин находится под угрозой и у него отсутствует веб-сайт. Добавьте веб-сайт и смените пароль для большей безопасности." + "message": "Этот логин подвержен риску и у него отсутствует веб-сайт. Добавьте веб-сайт и смените пароль для большей безопасности." }, "missingWebsite": { "message": "Отсутствует сайт" @@ -4080,14 +4122,20 @@ "showLess": { "message": "Меньше" }, - "enableAutotype": { - "message": "Включить автоввод" - }, "enableAutotypeDescription": { "message": "Bitwarden не проверяет местоположение ввода, поэтому, прежде чем использовать ярлык, убедитесь, что вы находитесь в нужном окне и поле." }, + "typeShortcut": { + "message": "Введите сочетание клавиш" + }, + "editAutotypeShortcutDescription": { + "message": "Включите один или два из следующих модификаторов: Ctrl, Alt, Win или Shift и букву." + }, + "invalidShortcut": { + "message": "Недопустимое сочетание клавиш" + }, "moreBreadcrumbs": { - "message": "Больше хлебных крошек", + "message": "Дополнительная навигация", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." }, "next": { @@ -4099,19 +4147,24 @@ "confirm": { "message": "Подтвердить" }, - "enableAutotypeTransitionKey": { - "message": "Включить ярлык автоввода" + "enableAutotypeShortcutPreview": { + "message": "Включить сочетание клавиш ввода (предварительная версия функции)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Прежде чем использовать ярлык, убедитесь, что вы поставили курсор в нужное поле, чтобы избежать ввода данных в неправильное место." }, "editShortcut": { "message": "Изменить ярлык" }, - "archive": { - "message": "Архив" + "archiveNoun": { + "message": "Архив", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Архивировать", + "description": "Verb" + }, + "unArchive": { "message": "Разархивировать" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Архивированные элементы появятся здесь и будут исключены из общих результатов поиска и предложений автозаполнения." }, - "itemSentToArchive": { - "message": "Элемент отправлен в архив" + "itemWasSentToArchive": { + "message": "Элемент был отправлен в архив" }, - "itemRemovedFromArchive": { - "message": "Элемент удален из архива" + "itemWasUnarchived": { + "message": "Элемент был разархивирован" }, "archiveItem": { "message": "Архивировать элемент" }, "archiveItemConfirmDesc": { "message": "Архивированные элементы исключены из общих результатов поиска и предложений автозаполнения. Вы уверены, что хотите архивировать этот элемент?" + }, + "zipPostalCodeLabel": { + "message": "Почтовый индекс" + }, + "cardNumberLabel": { + "message": "Номер карты" + }, + "upgradeNow": { + "message": "Изменить сейчас" + }, + "builtInAuthenticator": { + "message": "Встроенный аутентификатор" + }, + "secureFileStorage": { + "message": "Защищенное хранилище файлов" + }, + "emergencyAccess": { + "message": "Экстренный доступ" + }, + "breachMonitoring": { + "message": "Мониторинг нарушений" + }, + "andMoreFeatures": { + "message": "И многое другое!" + }, + "planDescPremium": { + "message": "Полная онлайн-защищенность" + }, + "upgradeToPremium": { + "message": "Обновить до Премиум" + }, + "sessionTimeoutSettingsAction": { + "message": "Тайм-аут действия" + }, + "sessionTimeoutHeader": { + "message": "Тайм-аут сеанса" } } diff --git a/apps/desktop/src/locales/si/messages.json b/apps/desktop/src/locales/si/messages.json index 8d4072e1da2..a83b2cbf536 100644 --- a/apps/desktop/src/locales/si/messages.json +++ b/apps/desktop/src/locales/si/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/sk/messages.json b/apps/desktop/src/locales/sk/messages.json index 566b9b8210a..0b14b961bbb 100644 --- a/apps/desktop/src/locales/sk/messages.json +++ b/apps/desktop/src/locales/sk/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "Na úpravu tejto položky nemáte oprávnenie" + }, "welcomeBack": { "message": "Vitajte späť" }, @@ -131,7 +134,7 @@ "message": "Spustiť" }, "copyValue": { - "message": "Skopírovať hodnotu", + "message": "Kopírovať hodnotu", "description": "Copy value to clipboard" }, "minimizeOnCopyToClipboard": { @@ -144,7 +147,7 @@ "message": "Prepnúť viditeľnosť" }, "toggleCollapse": { - "message": "Prepnúť zloženie", + "message": "Prepnúť zbalenie", "description": "Toggling an expand/collapse state." }, "cardholderName": { @@ -364,7 +367,7 @@ "message": "Krstné meno" }, "middleName": { - "message": "Druhé meno" + "message": "Stredné meno" }, "lastName": { "message": "Priezvisko" @@ -409,7 +412,7 @@ "message": "Upraviť" }, "authenticatorKeyTotp": { - "message": "Kľúč overovateľa (TOTP)" + "message": "Overovací kľúč (TOTP)" }, "authenticatorKey": { "message": "Overovací kľúč" @@ -514,10 +517,10 @@ "message": "Meno je povinné." }, "addedItem": { - "message": "Položka pridaná" + "message": "Položka bola pridaná" }, "editedItem": { - "message": "Položka upravená" + "message": "Položka bola uložená" }, "deleteItem": { "message": "Odstrániť položku" @@ -557,7 +560,7 @@ "message": "Vygenerovať nové heslo" }, "copyPassword": { - "message": "Skopírovať heslo" + "message": "Kopírovať heslo" }, "regenerateSshKey": { "message": "Generovať nový kľúč SSH" @@ -659,10 +662,10 @@ "message": "Zavrieť" }, "minNumbers": { - "message": "Minimálny počet číslic" + "message": "Minimum číslic" }, "minSpecial": { - "message": "Minimum špeciálnych", + "message": "Minimum špeciálnych znakov", "description": "Minimum Special Characters" }, "ambiguous": { @@ -687,20 +690,20 @@ "message": "Hľadať v obľúbených" }, "searchType": { - "message": "Search type", + "message": "Typ vyhľadávania", "description": "Search item type" }, "newAttachment": { "message": "Pridať novú prílohu" }, "deletedAttachment": { - "message": "Príloha odstránená" + "message": "Príloha bola odstránená" }, "deleteAttachmentConfirmation": { "message": "Naozaj chcete odstrániť prílohu?" }, "attachmentSaved": { - "message": "Príloha bola uložená." + "message": "Príloha bola uložená" }, "addAttachment": { "message": "Priložiť prílohu" @@ -712,7 +715,7 @@ "message": "Súbor" }, "selectFile": { - "message": "Vybrať súbor." + "message": "Vyberte súbor" }, "maxFileSize": { "message": "Maximálna veľkosť súboru je 500 MB." @@ -721,16 +724,16 @@ "message": "Staršie šifrovanie už nie je podporované. Ak chcete obnoviť svoj účet, obráťte sa na podporu." }, "editedFolder": { - "message": "Priečinok upravený" + "message": "Priečinok bol upravený" }, "addedFolder": { - "message": "Priečinok pridaný" + "message": "Priečinok bol pridaný" }, "deleteFolderConfirmation": { "message": "Naozaj chcete odstrániť tento priečinok?" }, "deletedFolder": { - "message": "Priečinok odstránený" + "message": "Priečinok bol odstránený" }, "loginOrCreateNewAccount": { "message": "Prihláste sa alebo si vytvorte nový účet, aby ste mohli pristupovať k vášmu bezpečnému trezoru." @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Použiť jednotné prihlásenie" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Vaša organizácia vyžaduje jednotné prihlasovanie." + }, "submit": { "message": "Potvrdiť" }, @@ -955,7 +961,7 @@ "description": "Select another two-step login method" }, "useYourRecoveryCode": { - "message": "Použiť obnovovací kód" + "message": "Použiť kód na obnovenie" }, "insertU2f": { "message": "Vložte váš bezpečnostný kľúč do USB portu počítača. Ak má tlačidlo, stlačte ho." @@ -981,7 +987,7 @@ "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "duoOrganizationDesc": { - "message": "Overiť sa prostredníctvom Duo Security vašej organizácie použitím Duo Mobile aplikácie, SMS, telefonátu alebo U2F bezpečnostným kľúčom.", + "message": "Overenie pomocou Duo Security pre vašu organizáciu pomocou aplikácie Duo Mobile, SMS, telefonického hovoru alebo bezpečnostného kľúča U2F.", "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "verifyYourIdentity": { @@ -997,7 +1003,7 @@ "message": "FIDO2 WebAuthn" }, "webAuthnDesc": { - "message": "Použiť akýkoľvek WebAuthn bezpečnostný kľúč pre prístup k vášmu účtu." + "message": "Použite akýkoľvek kompatibilný bezpečnostný kľúč WebAuthn na prístup k svojmu účtu." }, "emailTitle": { "message": "E-mail" @@ -1006,7 +1012,7 @@ "message": "Zadajte kód zaslaný na váš e-mail." }, "loginUnavailable": { - "message": "Prihlásenie nedostupné" + "message": "Prihlásenie nie je dostupné" }, "noTwoStepProviders": { "message": "Tento účet má povolené dvojstupňové prihlásenie, ale žiadny z nakonfigurovaných poskytovateľov nie je podporovaný na tomto zariadení." @@ -1021,7 +1027,7 @@ "message": "Vyberte metódu dvojstupňového prihlásenia" }, "selfHostedEnvironment": { - "message": "Prevádzkované vo vlastnom prostredí" + "message": "Prostredie s vlastným hostingom" }, "selfHostedBaseUrlHint": { "message": "Zadajte základnú URL adresu lokálne hosťovanej inštalácie Bitwarden. Napríklad: https://bitwarden.company.com" @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Musíte pridať buď základnú adresu URL servera, alebo aspoň jedno vlastné prostredie." }, + "selfHostedEnvMustUseHttps": { + "message": "Adresy URL musia používať HTTPS." + }, "customEnvironment": { "message": "Vlastné prostredie" }, @@ -1058,13 +1067,13 @@ "message": "URL servera identít" }, "notificationsUrl": { - "message": "URL adresa servera pre oznámenia" + "message": "URL servera pre upozornenia" }, "iconsUrl": { "message": "URL servera ikon" }, "environmentSaved": { - "message": "URL prostredia boli uložené." + "message": "URL adresy prostredia boli uložené" }, "ok": { "message": "Ok" @@ -1115,10 +1124,10 @@ "message": "Odhlásiť sa" }, "addNewLogin": { - "message": "Pridať nové prihlasovacie údaje" + "message": "Nové prihlasovacie údaje" }, "addNewItem": { - "message": "Pridať novú položku" + "message": "Nová položka" }, "view": { "message": "Zobraziť" @@ -1154,7 +1163,7 @@ "message": "Sledujte nás" }, "syncVault": { - "message": "Synchronizovať trezor teraz" + "message": "Synchronizovať trezor" }, "changeMasterPass": { "message": "Zmeniť hlavné heslo" @@ -1166,7 +1175,7 @@ "message": "Hlavné heslo si môžete zmeniť vo webovej aplikácii Bitwarden." }, "fingerprintPhrase": { - "message": "Fráza odtlačku", + "message": "Jedinečný identifikátor", "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": { @@ -1177,13 +1186,13 @@ "message": "Prejsť do webového trezora" }, "getMobileApp": { - "message": "Získajte mobilnú aplikáciu" + "message": "Získať mobilnú aplikáciu" }, "getBrowserExtension": { "message": "Získať rozšírenie pre prehliadač" }, "syncingComplete": { - "message": "Synchronizácia dokončená" + "message": "Synchronizácia bola dokončená" }, "syncingFailed": { "message": "Synchronizácia zlyhala" @@ -1222,8 +1231,17 @@ "invalidMasterPassword": { "message": "Neplatné hlavné heslo" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Neplatné hlavné heslo. Potvrďte, že váš e-mail je správny a účet bol vytvorený na $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { - "message": "Dvojstupňové prihlasovanie robí váš účet bezpečnejším vďaka vyžadovaniu bezpečnostného kódu z overovacej aplikácie vždy, keď sa prihlásite. Dvojstupňové prihlasovanie môžete povoliť vo webovom trezore bitwarden.com. Chcete teraz navštíviť túto stránku?" + "message": "Dvojstupňové prihlásenie zvyšuje bezpečnosť vášho účtu tým, že vyžaduje overenie prihlásenia pomocou iného zariadenia, napríklad bezpečnostného kľúča, overovacej aplikácie, SMS, telefonického hovoru alebo e-mailu. Dvojstupňové prihlásenie môžete nastaviť na bitwarden.com. Chcete stránku navštíviť teraz?" }, "twoStepLogin": { "message": "Dvojstupňové prihlásenie" @@ -1330,7 +1348,7 @@ "message": "Namiesto zatvorenia okna zobraziť ikonu na paneli úloh." }, "enableTray": { - "message": "Povoliť ikonu na systémovej lište" + "message": "Povoliť ikonu na paneli úloh" }, "enableTrayDesc": { "message": "Vždy zobraziť ikonu na systémovej lište." @@ -1402,7 +1420,7 @@ } }, "restartToUpdate": { - "message": "Reštartovať pre dokončenie aktualizácie" + "message": "Reštartovať na dokončenie aktualizácie" }, "restartToUpdateDesc": { "message": "Verzia $VERSION_NUM$ je pripravená na inštaláciu. Je nutné reštartovať aplikáciu, aby sa inštalácia mohla dokončiť. Chcete ju reštartovať a aktualizovať teraz?", @@ -1467,7 +1485,7 @@ "message": "Momentálne nie ste prémiovým členom." }, "premiumSignUpAndGet": { - "message": "Zaregistrujte sa pre prémiové členstvo a získajte:" + "message": "Zaregistrujte sa na prémiové členstvo a získajte:" }, "premiumSignUpStorage": { "message": "1 GB šifrovaného úložiska." @@ -1509,7 +1527,7 @@ } }, "refreshComplete": { - "message": "Obnova kompletná" + "message": "Obnova bola dokončená" }, "passwordHistory": { "message": "História hesla" @@ -1554,7 +1572,7 @@ "description": "Paste from clipboard" }, "selectAll": { - "message": "Označiť všetko" + "message": "Vybrať Všetko" }, "zoomIn": { "message": "Priblížiť" @@ -1563,7 +1581,7 @@ "message": "Oddialiť" }, "resetZoom": { - "message": "Obnoviť pôvodné zobrazenie" + "message": "Obnoviť priblíženie" }, "toggleFullScreen": { "message": "Prepnúť na celú obrazovku" @@ -1572,7 +1590,7 @@ "message": "Znovu načítať" }, "toggleDevTools": { - "message": "Prepnúť vývojárske nástroje" + "message": "Prepnúť nástroje pre vývojárov" }, "minimize": { "message": "Minimalizovať", @@ -1582,7 +1600,7 @@ "message": "Priblíženie" }, "bringAllToFront": { - "message": "Preniesť všetko dopredu", + "message": "Presunúť všetko dopredu", "description": "Bring all windows to front (foreground)" }, "aboutBitwarden": { @@ -1674,7 +1692,7 @@ "description": "Default URI match detection for auto-fill." }, "toggleOptions": { - "message": "Voľby prepínača" + "message": "Zobraziť/skryť možnosti" }, "organization": { "message": "Organizácia", @@ -1716,7 +1734,7 @@ "message": "Export trezoru" }, "fileFormat": { - "message": "Formát Súboru" + "message": "Formát súboru" }, "fileEncryptedExportWarningDesc": { "message": "Tento exportovaný súbor bude chránený heslom a na dešifrovanie bude potrebné heslo súboru." @@ -1838,11 +1856,17 @@ "message": "odomknúť svoj trezor" }, "autoPromptTouchId": { - "message": "Pri spustení požiadať o Touch ID" + "message": "Pri spustení aplikácie požiadať o Touch ID" }, "lockWithMasterPassOnRestart1": { "message": "Pri reštarte zamknúť hlavným heslom" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Pri reštarte aplikácie vyžadovať hlavné heslo alebo PIN" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Pri reštarte aplikácie vyžadovať hlavné heslo" + }, "deleteAccount": { "message": "Odstrániť účet" }, @@ -1880,7 +1904,7 @@ "message": "Musíte vybrať aspoň jednu zbierku." }, "premiumUpdated": { - "message": "Povýšili ste na prémium." + "message": "Upgradovali ste na Prémium." }, "restore": { "message": "Obnoviť" @@ -1940,10 +1964,10 @@ "message": "Naozaj chcete natrvalo odstrániť túto položku?" }, "permanentlyDeletedItem": { - "message": "Položka natrvalo odstránená" + "message": "Položka bola natrvalo odstránená" }, "restoredItem": { - "message": "Obnovená položka" + "message": "Položka bola obnovená" }, "permanentlyDelete": { "message": "Natrvalo odstrániť" @@ -2115,7 +2139,7 @@ "message": "Povoliť integráciu prehliadača DuckDuckGo" }, "enableDuckDuckGoBrowserIntegrationDesc": { - "message": "Používajte svoj trezor Bitwarden pri prehliadaní pomocou DuckDuckGo." + "message": "Používajte svoj trezor v Bitwardene pri prehliadaní pomocou DuckDuckGo." }, "browserIntegrationUnsupportedTitle": { "message": "Integrácia v prehliadači nie je podporovaná" @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Pri povoľovaní integrácie v prehliadači sa vyskytla chyba." }, - "browserIntegrationMasOnlyDesc": { - "message": "Bohužiaľ, integrácia v prehliadači je zatiaľ podporovaná iba vo verzii Mac App Store." - }, "browserIntegrationWindowsStoreDesc": { "message": "Bohužiaľ, integrácia v prehliadači zatiaľ nie je podporovaná, ak je aplikácia nainštalovaná cez Microsoft Store." }, @@ -2157,7 +2178,7 @@ "message": "Uistite sa, že zobrazený odtlačok prsta je identický s odtlačkom prsta zobrazeným v rozšírení prehliadača." }, "verifyNativeMessagingConnectionTitle": { - "message": "$APPID$ sa chce pripojiť k Bitwarden", + "message": "$APPID$ sa chce pripojiť k Bitwardenu", "placeholders": { "appid": { "content": "$1", @@ -2184,7 +2205,7 @@ "message": "Vzhľadom na spôsob inštalácie nebolo možné automaticky povoliť podporu biometrie. Chcete otvoriť dokumentáciu, ako to urobiť manuálne?" }, "personalOwnershipSubmitError": { - "message": "Z dôvodu podnikovej politiky máte obmedzené ukladanie položiek do osobného trezora. Zmeňte možnosť vlastníctvo na organizáciu a vyberte si z dostupných zbierok." + "message": "Z dôvodu podnikových pravidiel máte obmedzené ukladanie položiek do osobného trezora. Zmeňte možnosť vlastníctvo na organizáciu a vyberte si z dostupných zbierok." }, "yourNewPasswordCannotBeTheSameAsYourCurrentPassword": { "message": "Nové heslo nemôže byť rovnaké ako súčasné heslo." @@ -2281,15 +2302,15 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "createdSend": { - "message": "Send vytvorený", + "message": "Send bol vytvorený", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editedSend": { - "message": "Send upravený", + "message": "Send bol upravený", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "deletedSend": { - "message": "Send odstránený", + "message": "Send bol odstránený", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "newPassword": { @@ -2300,7 +2321,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "createSend": { - "message": "Vytvoriť Send", + "message": "Nový Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTextDesc": { @@ -2336,7 +2357,7 @@ "message": "Kopírovať odkaz na zdieľanie tohto Sendu do schránky počas ukladania." }, "sendDisabled": { - "message": "Funkcia Send zakázaná", + "message": "Send bol odstránený", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendDisabledWarning": { @@ -2404,7 +2425,7 @@ "message": "Hlavné heslo bolo úspešne nastavené" }, "updatedMasterPassword": { - "message": "Hlavné heslo aktualizované" + "message": "Hlavné heslo bolo aktualizované" }, "updateMasterPassword": { "message": "Aktualizovať hlavné heslo" @@ -2455,7 +2476,7 @@ "message": "Použiť PIN kód" }, "useBiometrics": { - "message": "Použiť biometrické údaje" + "message": "Použiť biometriu" }, "enterVerificationCodeSentToEmail": { "message": "Zadajte overovací kód, ktorý vám bol zaslaný na e-mail." @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimálny vlastný časový limit je 1 minúta." + }, "inviteAccepted": { "message": "Pozvánka prijatá" }, @@ -2550,7 +2574,7 @@ "message": "Táto organizácia má podnikovú politiku, ktorá vás automaticky zaregistruje na obnovenie hesla. Registrácia umožní správcom organizácie zmeniť vaše hlavné heslo." }, "vaultExportDisabled": { - "message": "Export trezoru je zakázaný" + "message": "Export trezoru bol odstránený" }, "personalVaultExportPolicyInEffect": { "message": "Jedna alebo viacero zásad organizácie vám bráni exportovať váš osobný trezor." @@ -2595,7 +2619,7 @@ "message": "Predvoľby" }, "appPreferences": { - "message": "Nastavenia aplikácie (Všetky účty)" + "message": "Nastavenia aplikácie (všetky účty)" }, "accountSwitcherLimitReached": { "message": "Dosiahnutý limit počtu účtov. Odhláste sa z účtu aby ste mohli pridať ďalší." @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Exportuje sa len trezor organizácie spojený s $ORGANIZATION$.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Exportuje sa len trezor organizácie spojený s $ORGANIZATION$. Moje zbierky položiek nebudú zahrnuté.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Zamknutý" }, @@ -2748,7 +2790,7 @@ "message": "Použiť možnosti subadresovania svojho poskytovateľa e-mailu." }, "catchallEmail": { - "message": "Catch-all Email" + "message": "E-mail Catch-all" }, "catchallEmailDesc": { "message": "Použiť doručenú poštu typu catch-all nastavenú na doméne." @@ -2801,7 +2843,7 @@ "description": "Guidance provided for email forwarding services that support multiple email domains." }, "forwarderError": { - "message": "$SERVICENAME$ chyba: $ERRORMESSAGE$", + "message": "Chyba $SERVICENAME$: $ERRORMESSAGE$", "description": "Reports an error returned by a forwarding service to the user.", "placeholders": { "servicename": { @@ -2917,7 +2959,7 @@ } }, "forwarderUnknownForwarder": { - "message": "Nepodporovaná služba: '$SERVICENAME$'.", + "message": "Neznáme presmerovanie: '$SERVICENAME$'.", "description": "Displayed when the forwarding service is not supported.", "placeholders": { "servicename": { @@ -2934,16 +2976,16 @@ "message": "Prístupový token API" }, "apiKey": { - "message": "API kľúč" + "message": "Kľúč API" }, "premiumSubcriptionRequired": { "message": "Vyžaduje sa predplatné Prémium" }, "organizationIsDisabled": { - "message": "Organizácia je vypnutá." + "message": "Organizácia je pozastavená" }, "disabledOrganizationFilterError": { - "message": "K položkám vo vypnutej organizácii nie je možné pristupovať. Požiadajte o pomoc vlastníka organizácie." + "message": "K položkám pozastavenej organizácii nie je možné pristupovať. Požiadajte o pomoc vlastníka organizácie." }, "neverLockWarning": { "message": "Ste si istí, že chcete použiť možnosť \"Nikdy\"? Táto predvoľba ukladá šifrovací kľúč od trezora priamo na zariadení. Ak použijete túto možnosť, mali by ste svoje zariadenie náležite zabezpečiť." @@ -3144,16 +3186,16 @@ "message": "na úpravu e-mailovej adresy." }, "exposedMasterPassword": { - "message": "Odhalené hlavné heslo" + "message": "Uniknuté hlavné heslo" }, "exposedMasterPasswordDesc": { - "message": "Nájdené heslo v uniknuných údajoch. Na ochranu svojho účtu používajte jedinečné heslo. Naozaj chcete používať odhalené heslo?" + "message": "Heslo bolo nájdené v uniknutých údajoch. Na ochranu svojho účtu používajte jedinečné heslo. Naozaj chcete používať uniknuté heslo?" }, "weakAndExposedMasterPassword": { - "message": "Slabé a odhalené hlavné heslo" + "message": "Slabé a uniknuté hlavné heslo" }, "weakAndBreachedMasterPasswordDesc": { - "message": "Nájdené slabé heslo v uniknuných údajoch. Na ochranu svojho účtu používajte silné a jedinečné heslo. Naozaj chcete používať toto heslo?" + "message": "Nájdené slabé heslo v uniknutých údajoch. Na ochranu svojho účtu používajte silné a jedinečné heslo. Naozaj chcete používať toto heslo?" }, "checkForBreaches": { "message": "Skontrolovať známe úniky údajov pre toto heslo" @@ -3177,7 +3219,7 @@ "message": "Vaše hlavné heslo sa nebude dať obnoviť, ak ho zabudnete!" }, "characterMinimum": { - "message": "Minimálny počet znakov $LENGTH$", + "message": "Minimálny počet znakov: $LENGTH$", "placeholders": { "length": { "content": "$1", @@ -3783,10 +3825,10 @@ "message": "žiada o prístup k" }, "sshkeyApprovalMessageSuffix": { - "message": "in order to" + "message": "aby bolo možné" }, "sshActionLogin": { - "message": "authenticate to a server" + "message": "overenie na serveri" }, "sshActionSign": { "message": "podpísať správu" @@ -4080,12 +4122,18 @@ "showLess": { "message": "Zobraziť menej" }, - "enableAutotype": { - "message": "Povoliť automatické vpisovanie" - }, "enableAutotypeDescription": { "message": "Bitwarden neoveruje miesto stupu, pred použitím skratky sa uistite, že ste v správnom okne a poli." }, + "typeShortcut": { + "message": "Zadajte klávesovú skratku" + }, + "editAutotypeShortcutDescription": { + "message": "Použite jeden alebo dva z nasledujúcich modifikátorov: Ctrl, Alt, Win, alebo Shift a písmeno." + }, + "invalidShortcut": { + "message": "Neplatná klávesová skratka" + }, "moreBreadcrumbs": { "message": "Viac", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Potvrdiť" }, - "enableAutotypeTransitionKey": { - "message": "Povoliť skratku automatického písania" + "enableAutotypeShortcutPreview": { + "message": "Povoliť skratku pre automatické vpisovanie (náhľad funkcie)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Pred použitím skratky sa uistite, že sa nachádzate v správnom poli, aby ste údaje nevyplnili na nesprávne miesto." }, "editShortcut": { "message": "Upraviť skratku" }, - "archive": { - "message": "Archivovať" + "archiveNoun": { + "message": "Archív", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archivovať", + "description": "Verb" + }, + "unArchive": { "message": "Zrušiť archiváciu" }, "itemsInArchive": { @@ -4123,10 +4176,10 @@ "noItemsInArchiveDesc": { "message": "Tu sa zobrazia archivované položky, ktoré budú vylúčené zo všeobecného vyhľadávania a z návrhov automatického vypĺňania." }, - "itemSentToArchive": { + "itemWasSentToArchive": { "message": "Položka bola archivovaná" }, - "itemRemovedFromArchive": { + "itemWasUnarchived": { "message": "Položka bola odobraná z archívu" }, "archiveItem": { @@ -4134,5 +4187,41 @@ }, "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?" + }, + "zipPostalCodeLabel": { + "message": "PSČ" + }, + "cardNumberLabel": { + "message": "Číslo karty" + }, + "upgradeNow": { + "message": "Upgradovať teraz" + }, + "builtInAuthenticator": { + "message": "Zabudovaný autentifikátor" + }, + "secureFileStorage": { + "message": "Bezpečné ukladanie súborov" + }, + "emergencyAccess": { + "message": "Núdzový prístup" + }, + "breachMonitoring": { + "message": "Sledovanie únikov" + }, + "andMoreFeatures": { + "message": "A ešte viac!" + }, + "planDescPremium": { + "message": "Úplné online zabezpečenie" + }, + "upgradeToPremium": { + "message": "Upgradovať na Prémium" + }, + "sessionTimeoutSettingsAction": { + "message": "Akcia pri vypršaní časového limitu" + }, + "sessionTimeoutHeader": { + "message": "Časový limit relácie" } } diff --git a/apps/desktop/src/locales/sl/messages.json b/apps/desktop/src/locales/sl/messages.json index 23a6df7ad95..353c6858afa 100644 --- a/apps/desktop/src/locales/sl/messages.json +++ b/apps/desktop/src/locales/sl/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Potrdi" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Okolje po meri" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Napačno glavno geslo" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Avtentikacija v dveh korakih naredi vaš račun bolj varen, saj od vas zahteva, da svojo prijavo preverite z drugo napravo, kot je varnostni ključ, aplikacija za preverjanje pristnosti, SMS, telefonski klic ali e-pošta. V spletnem trezorju bitwarden.com je lahko omogočite prijavo v dveh korakih. Ali želite spletno stran obiskati sedaj?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/sr/messages.json b/apps/desktop/src/locales/sr/messages.json index cd41aa9e4b9..1bc4a0ed016 100644 --- a/apps/desktop/src/locales/sr/messages.json +++ b/apps/desktop/src/locales/sr/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "Немате дозволу да уређујете ову ставку" + }, "welcomeBack": { "message": "Добродошли назад" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Употребити једнократну пријаву" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Ваша организација захтева јединствену пријаву." + }, "submit": { "message": "Пошаљи" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Морате додати или основни УРЛ сервера или бар једно прилагођено окружење." }, + "selfHostedEnvMustUseHttps": { + "message": "Везе морају да користе HTTPS." + }, "customEnvironment": { "message": "Прилагођено окружење" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Погрешна главна лозинка" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Неисправна главна лозинка. Потврдите да је адреса ваше е-поште исправна и да је ваш налог направљен на $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Пријава у два корака чини ваш налог сигурнијим захтевом да верификујете своје податке помоћу другог уређаја, као што су безбедносни кључ, апликација, СМС-а, телефонски позив или имејл. Пријављивање у два корака може се омогућити на веб сефу. Да ли желите да посетите веб страницу сада?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Закључајте са главном лозинком при поновном покретању" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Потражити главну лозинку или ПИН при поновном покретању" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Потражити главну лозинку при поновном покретању апликације" + }, "deleteAccount": { "message": "Брисање налога" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Дошло је до грешке при омогућавању интеграције прегледача." }, - "browserIntegrationMasOnlyDesc": { - "message": "Нажалост, интеграција прегледача за сада је подржана само у верзији Mac App Store." - }, "browserIntegrationWindowsStoreDesc": { "message": "Нажалост, интеграција прегледача није за сада подржана у Windows Store." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Минимално прилагођено временско ограничење је 1 минут." + }, "inviteAccepted": { "message": "Позив прихваћен" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Извешће се само сеф организације повезана са $ORGANIZATION$.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Извешће се само сеф организације повезан са $ORGANIZATION$. Колекције мојих предмета неће бити укључене.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Закључано" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Прикажи мање" }, - "enableAutotype": { - "message": "Упали ауто-унос" - }, "enableAutotypeDescription": { "message": "Bitwarden не потврђује локације уноса, будите сигурни да сте у добром прозору и поље пре употребе пречице." }, + "typeShortcut": { + "message": "Унети пречицу" + }, + "editAutotypeShortcutDescription": { + "message": "Укључите један или два следећа модификатора: Ctrl, Alt, Win, или Shift, и слово." + }, + "invalidShortcut": { + "message": "Неважећа пречица" + }, "moreBreadcrumbs": { "message": "Више мрвица", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Потврди" }, - "enableAutotypeTransitionKey": { - "message": "Омогућава пречицу за аутоматски унос" + "enableAutotypeShortcutPreview": { + "message": "Омогућите пречицу ауто-уноса (преглед функције)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Будите сигурни да сте у исправном пољу пре употребе пречице да бисте избегли попуњавање података на погрешно место." }, "editShortcut": { "message": "Уреди пречицу" }, - "archive": { - "message": "Архива" + "archiveNoun": { + "message": "Архива", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Архивирај", + "description": "Verb" + }, + "unArchive": { "message": "Врати из архиве" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Архивиране ставке ће се овде појавити и бити искључени из општих резултата претраге и сугестија о ауто-пуњењу." }, - "itemSentToArchive": { + "itemWasSentToArchive": { "message": "Ставка је послата у архиву" }, - "itemRemovedFromArchive": { - "message": "Ставка је уклоњена из архиве" + "itemWasUnarchived": { + "message": "Ставка враћена из архиве" }, "archiveItem": { "message": "Архивирај ставку" }, "archiveItemConfirmDesc": { "message": "Архивиране ставке су искључене из општих резултата претраге и предлога за ауто попуњавање. Јесте ли сигурни да желите да архивирате ову ставку?" + }, + "zipPostalCodeLabel": { + "message": "ZIP/Поштански број" + }, + "cardNumberLabel": { + "message": "Број картице" + }, + "upgradeNow": { + "message": "Надогради сада" + }, + "builtInAuthenticator": { + "message": "Уграђени аутентификатор" + }, + "secureFileStorage": { + "message": "Сигурно складиштење датотека" + }, + "emergencyAccess": { + "message": "Хитан приступ" + }, + "breachMonitoring": { + "message": "Праћење повreda безбедности" + }, + "andMoreFeatures": { + "message": "И још више!" + }, + "planDescPremium": { + "message": "Потпуна онлајн безбедност" + }, + "upgradeToPremium": { + "message": "Надоградите на Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/sv/messages.json b/apps/desktop/src/locales/sv/messages.json index a867fc28753..93d56419ae3 100644 --- a/apps/desktop/src/locales/sv/messages.json +++ b/apps/desktop/src/locales/sv/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "Du har inte behörighet att redigera detta objekt" + }, "welcomeBack": { "message": "Välkommen tillbaka" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Använd Single Sign-On" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Din organisation kräver single sign-on." + }, "submit": { "message": "Skicka" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Du måste lägga till antingen basserverns URL eller minst en anpassad miljö." }, + "selfHostedEnvMustUseHttps": { + "message": "Webbadresser måste använda HTTPS." + }, "customEnvironment": { "message": "Anpassad miljö" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Ogiltigt huvudlösenord" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Ogiltigt huvudlösenord. Bekräfta att din e-postadress är korrekt och ditt konto skapades på $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Tvåstegsverifiering gör ditt konto säkrare genom att kräva att du verifierar din inloggning med en annan enhet, t.ex. en säkerhetsnyckel, autentiseringsapp, SMS, telefonsamtal eller e-post. Tvåstegsverifiering kan aktiveras i Bitwardens webbvalv. Vill du besöka webbplatsen nu?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lås med huvudlösenord vid omstart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Kräv huvudlösenord eller PIN-kod vid omstart av appen" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Kräv huvudlösenord vid omstart av appen" + }, "deleteAccount": { "message": "Radera konto" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Ett fel uppstod vid aktivering av webbläsarintegration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Tyvärr stöds webbläsarintegration för tillfället endast i versionen från Mac App Store." - }, "browserIntegrationWindowsStoreDesc": { "message": "Tyvärr stöds webbläsarintegration för tillfället inte i versionen från Windows Store." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minsta anpassade tidsgräns är 1 minut." + }, "inviteAccepted": { "message": "Inbjudan accepterad" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Endast organisationsvalvet som är associerat med $ORGANIZATION$ kommer att exporteras.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Endast organisationsvalvet som associeras med $ORGANIZATION$ kommer att exporteras. Mina objektsamlingar kommer inte att inkluderas.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Låst" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Visa mindre" }, - "enableAutotype": { - "message": "Aktivera automatisk inmatning" - }, "enableAutotypeDescription": { "message": "Bitwarden validerar inte inmatningsplatser, så se till att du är i rätt fönster och fält innan du använder genvägen." }, + "typeShortcut": { + "message": "Inmatningsgenväg" + }, + "editAutotypeShortcutDescription": { + "message": "Inkludera en eller två av följande modifierare: Ctrl, Alt, Win, eller Skift och en bokstav." + }, + "invalidShortcut": { + "message": "Ogiltig genväg" + }, "moreBreadcrumbs": { "message": "Fler länkstigar", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,20 +4147,25 @@ "confirm": { "message": "Bekräfta" }, - "enableAutotypeTransitionKey": { - "message": "Aktivera genväg för automatisk inmatning" + "enableAutotypeShortcutPreview": { + "message": "Aktivera inmatningsgenväg (förhandsvisning av funktion)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Försäkra dig om att du befinner dig i rätt fält innan du använder genvägen för att undvika att fylla i data på fel ställe." }, "editShortcut": { "message": "Redigera genväg" }, - "archive": { - "message": "Arkivera" + "archiveNoun": { + "message": "Arkiv", + "description": "Noun" }, - "unarchive": { - "message": "Packa upp" + "archiveVerb": { + "message": "Arkivera", + "description": "Verb" + }, + "unArchive": { + "message": "Avarkivera" }, "itemsInArchive": { "message": "Objekt i arkivet" @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Arkiverade objekt kommer att visas här och kommer att uteslutas från allmänna sökresultat och förslag för autofyll." }, - "itemSentToArchive": { - "message": "Objekt skickat till arkiv" + "itemWasSentToArchive": { + "message": "Objektet skickades till arkivet" }, - "itemRemovedFromArchive": { - "message": "Objekt borttaget från arkiv" + "itemWasUnarchived": { + "message": "Objektet har avarkiverats" }, "archiveItem": { "message": "Arkivera objekt" }, "archiveItemConfirmDesc": { "message": "Arkiverade objekt är uteslutna från allmänna sökresultat och förslag för autofyll. Är du säker på att du vill arkivera detta objekt?" + }, + "zipPostalCodeLabel": { + "message": "Postnummer" + }, + "cardNumberLabel": { + "message": "Kortnummer" + }, + "upgradeNow": { + "message": "Uppgradera nu" + }, + "builtInAuthenticator": { + "message": "Inbyggd autenticator" + }, + "secureFileStorage": { + "message": "Säker fillagring" + }, + "emergencyAccess": { + "message": "Nödåtkomst" + }, + "breachMonitoring": { + "message": "Intrångsmonitorering" + }, + "andMoreFeatures": { + "message": "och mer!" + }, + "planDescPremium": { + "message": "Komplett säkerhet online" + }, + "upgradeToPremium": { + "message": "Uppgradera till Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Tidsgränsåtgärd" + }, + "sessionTimeoutHeader": { + "message": "Sessionstidsgräns" } } diff --git a/apps/desktop/src/locales/ta/messages.json b/apps/desktop/src/locales/ta/messages.json index 4874985a8fd..2f9d12917d6 100644 --- a/apps/desktop/src/locales/ta/messages.json +++ b/apps/desktop/src/locales/ta/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "மீண்டும் வருக" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "ஒற்றை உள்நுழையைப் பயன்படுத்து" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "சமர்ப்பி" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "நீங்கள் அடிப்படை சேவையக URL-ஐயாவது அல்லது குறைந்தது ஒரு தனிப்பயன் சூழலையாவது சேர்க்க வேண்டும்." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "தனிப்பயன் சூழல்" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "தவறான முதன்மை கடவுச்சொல்" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "இரண்டு-படி உள்நுழைவு உங்கள் கணக்கை ஒரு பாதுகாப்பு விசை, அங்கீகரிப்பான் செயலி, SMS, ஃபோன் அழைப்பு அல்லது மின்னஞ்சல் போன்ற மற்றொரு சாதனம் மூலம் உங்கள் உள்நுழைவை சரிபார்க்க கோருவதன் மூலம் அதை மேலும் பாதுகாக்கிறது. bitwarden.com இணைய பெட்டகத்தில் இரண்டு-படி உள்நுழைவை அமைக்கலாம். இப்போது இணையதளத்திற்குச் செல்ல விரும்புகிறீர்களா?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "மறுதொடக்கம் செய்யும் போது முதன்மை கடவுச்சொல்லுடன் பூட்டவும்" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "கணக்கை நீக்கவும்" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "உலாவி ஒருங்கிணைப்பை இயக்கும்போது ஒரு பிழை ஏற்பட்டது." }, - "browserIntegrationMasOnlyDesc": { - "message": "துரதிர்ஷ்டவசமாக உலாவி ஒருங்கிணைப்பு தற்போது Mac App Store பதிப்பில் மட்டுமே ஆதரிக்கப்படுகிறது." - }, "browserIntegrationWindowsStoreDesc": { "message": "துரதிர்ஷ்டவசமாக உலாவி ஒருங்கிணைப்பு தற்போது Microsoft Store பதிப்பில் ஆதரிக்கப்படவில்லை." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "அழைப்பு ஏற்கப்பட்டது" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "பூட்டப்பட்டது" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "குறைவாகக் காட்டு" }, - "enableAutotype": { - "message": "தானியங்கு வகையை இயக்கு" - }, "enableAutotypeDescription": { "message": "Bitwarden உள்ளீட்டு இடங்களைச் சரிபார்க்காது, ஷார்ட்கட்டைப் பயன்படுத்துவதற்கு முன் சரியான சாளரம் மற்றும் புலத்தில் நீங்கள் இருக்கிறீர்கள் என்பதை உறுதிப்படுத்திக் கொள்ளுங்கள்." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "மேலும் பிரெட்க்ரம்புகள்", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "உறுதிப்படுத்து" }, - "enableAutotypeTransitionKey": { - "message": "தானியங்கு வகை குறுக்குவழியை இயக்கு" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { - "message": "தவறான இடத்தில் தரவை நிரப்புவதைத் தவிர்க்க, குறுக்குவழியைப் பயன்படுத்துவதற்கு முன்பு நீங்கள் சரியான புலத்தில் இருப்பதை உறுதிப்படுத்திக் கொள்ளுங்கள்." + "enableAutotypeShortcutDescription": { + "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "குறுக்குவழியைத் திருத்து" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/te/messages.json b/apps/desktop/src/locales/te/messages.json index 5849d9d4cee..d607bb8d097 100644 --- a/apps/desktop/src/locales/te/messages.json +++ b/apps/desktop/src/locales/te/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Invalid master password" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/th/messages.json b/apps/desktop/src/locales/th/messages.json index fa619695fdb..d794ace629c 100644 --- a/apps/desktop/src/locales/th/messages.json +++ b/apps/desktop/src/locales/th/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Welcome back" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "ส่งข้อมูล" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom Environment" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "รหัสผ่านหลักไม่ถูกต้อง" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "การเข้าสู่ระบบแบบสองขั้นตอนทำให้บัญชีของคุณมีความปลอดภัยมากขึ้นด้วยการให้คุณตรวจสอบการเข้าสู่ระบบของคุณกับอุปกรณ์อื่นเช่นคีย์ความปลอดภัย, แอพ authenticator, SMS, โทรศัพท์หรืออีเมล. เข้าสู่ระบบแบบสองขั้นตอนสามารถเปิดใช้งานบน เว็บนิรภัย bitwarden.com คุณต้องการเยี่ยมชมเว็บไซต์เดี๋ยวนี้หรือไม่" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "ลบบัญชีผู้ใช้" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "An error has occurred while enabling browser integration." }, - "browserIntegrationMasOnlyDesc": { - "message": "Unfortunately browser integration is only supported in the Mac App Store version for now." - }, "browserIntegrationWindowsStoreDesc": { "message": "Unfortunately browser integration is currently not supported in the Microsoft Store version." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, "inviteAccepted": { "message": "Invitation accepted" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." }, "editShortcut": { "message": "Edit shortcut" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Archive", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Archive", + "description": "Verb" + }, + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/tr/messages.json b/apps/desktop/src/locales/tr/messages.json index c33570af387..ac67b177cbf 100644 --- a/apps/desktop/src/locales/tr/messages.json +++ b/apps/desktop/src/locales/tr/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "Bu kaydı düzenleme yetkisine sahip değilsiniz" + }, "welcomeBack": { "message": "Tekrar hoş geldiniz" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Çoklu oturum açma kullan" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Kuruluşunuz çoklu oturum açma gerektiriyor." + }, "submit": { "message": "Gönder" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Temel Sunucu URL’sini veya en az bir özel ortam eklemelisiniz." }, + "selfHostedEnvMustUseHttps": { + "message": "URL'ler HTTPS kullanmalıdır." + }, "customEnvironment": { "message": "Özel ortam" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Geçersiz ana parola" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Ana parola geçersiz. E-posta adresinizin doğru olduğunu ve hesabınızın $HOST$ üzerinde oluşturulduğunu kontrol edin.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "İki aşamalı giriş, hesabınıza girererken işlemi bir güvenlik anahtarı, şifrematik uygulaması, SMS, telefon araması veya e-posta gibi ek bir yöntemle doğrulamanızı isteyerek hesabınızın güvenliğini artırır. İki aşamalı giriş özelliğini bitwarden.com web kasası üzerinden ayarlayabilirsiniz. Şimdi siteye gitmek ister misiniz?" }, @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Yeniden başlatmada ana parola ile kilitle" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Uygulama yeniden başlatıldığında ana parola veya PIN iste" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Uygulama yeniden başlatıldığında ana parola iste" + }, "deleteAccount": { "message": "Hesabı sil" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Tarayıcı entegrasyonu etkinleştirilirken bir hata oluştu." }, - "browserIntegrationMasOnlyDesc": { - "message": "Ne yazık ki tarayıcı entegrasyonu şu anda sadece Mac App Store sürümünde destekleniyor." - }, "browserIntegrationWindowsStoreDesc": { "message": "Maalesef tarayıcı entegrasyonu şimdilik Windows Store sürümünde desteklenmiyor." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum özel zaman aşımı 1 dakikadır." + }, "inviteAccepted": { "message": "Davet kabul edildi" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Kilitli" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Daha az göster" }, - "enableAutotype": { - "message": "Otomatik yazmayı etkinleştir" - }, "enableAutotypeDescription": { "message": "Bitwarden giriş konumlarını doğrulamaz, kısayolu kullanmadan önce doğru pencerede ve alanda olduğunuzdan emin olun." }, + "typeShortcut": { + "message": "Kısayolu yazın" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Geçersiz kısayol" + }, "moreBreadcrumbs": { "message": "Daha fazla gezinme izi", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Onayla" }, - "enableAutotypeTransitionKey": { - "message": "Otomatik yazma kısayolunu etkinleştir" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "Verilerin yanlış yere doldurulmasını önlemek için kısayolu kullanmadan önce doğru alanda olduğunuzdan emin olun." }, "editShortcut": { "message": "Kısayolu düzenle" }, - "archive": { - "message": "Arşivle" + "archiveNoun": { + "message": "Arşiv", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Arşivle", + "description": "Verb" + }, + "unArchive": { "message": "Arşivden çıkar" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Kayıt arşive gönderildi" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "message": "Kayıt arşivden çıkarıldı" }, "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?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / posta kodu" + }, + "cardNumberLabel": { + "message": "Kart numarası" + }, + "upgradeNow": { + "message": "Şimdi yükselt" + }, + "builtInAuthenticator": { + "message": "Dahili kimlik doğrulayıcı" + }, + "secureFileStorage": { + "message": "Güvenli dosya depolama" + }, + "emergencyAccess": { + "message": "Acil durum erişimi" + }, + "breachMonitoring": { + "message": "İhlal izleme" + }, + "andMoreFeatures": { + "message": "Ve daha fazlası!" + }, + "planDescPremium": { + "message": "Eksiksiz çevrimiçi güvenlik" + }, + "upgradeToPremium": { + "message": "Premium'a yükselt" + }, + "sessionTimeoutSettingsAction": { + "message": "Zaman aşımı eylemi" + }, + "sessionTimeoutHeader": { + "message": "Oturum zaman aşımı" } } diff --git a/apps/desktop/src/locales/uk/messages.json b/apps/desktop/src/locales/uk/messages.json index c5c86baacdf..7ed0710ca74 100644 --- a/apps/desktop/src/locales/uk/messages.json +++ b/apps/desktop/src/locales/uk/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "З поверненням" }, @@ -262,10 +265,10 @@ "message": "Пам'ятати до блокування сховища" }, "premiumRequired": { - "message": "Необхідна передплата преміум" + "message": "Необхідна передплата Premium" }, "premiumRequiredDesc": { - "message": "Для використання цієї функції необхідна передплата преміум." + "message": "Для використання цієї функції необхідна передплата Premium." }, "errorOccurred": { "message": "Сталася помилка." @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Використати єдиний вхід" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Ваша організація вимагає єдиний вхід (SSO)." + }, "submit": { "message": "Відправити" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Необхідно додати URL-адресу основного сервера, або принаймні одне користувацьке середовище." }, + "selfHostedEnvMustUseHttps": { + "message": "URL-адреси повинні бути HTTPS." + }, "customEnvironment": { "message": "Власне середовище" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Неправильний головний пароль" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Неправильний головний пароль. Перевірте правильність адреси електронної пошти та розміщення облікового запису на $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Двоетапна перевірка дає змогу надійніше захистити ваш обліковий запис, вимагаючи підтвердження входу з використанням іншого пристрою, наприклад, за допомогою ключа безпеки, програми автентифікації, SMS, телефонного виклику, або е-пошти. Ви можете налаштувати двоетапну перевірку в сховищі на bitwarden.com. Хочете перейти на вебсайт зараз?" }, @@ -1303,7 +1321,7 @@ "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Показувати піктограми вебсайтів та отримувати адреси для зміни паролів" }, "enableMinToTray": { "message": "Згортати до системного лотка" @@ -1452,22 +1470,22 @@ "message": "номер картки" }, "premiumMembership": { - "message": "Преміум статус" + "message": "Передплата Premium" }, "premiumManage": { "message": "Керувати передплатою" }, "premiumManageAlert": { - "message": "Ви можете керувати своїм статусом у сховищі на bitwarden.com. Хочете перейти на вебсайт зараз?" + "message": "Ви можете керувати передплатою у сховищі на bitwarden.com. Хочете перейти на вебсайт зараз?" }, "premiumRefresh": { "message": "Оновити стан передплати" }, "premiumNotCurrentMember": { - "message": "Зараз у вас немає передплати преміум." + "message": "Зараз у вас немає передплати Premium." }, "premiumSignUpAndGet": { - "message": "Передплатіть преміум і отримайте:" + "message": "Передплатіть Premium і отримайте:" }, "premiumSignUpStorage": { "message": "1 ГБ зашифрованого сховища для файлів." @@ -1485,22 +1503,22 @@ "message": "Пріоритетну технічну підтримку." }, "premiumSignUpFuture": { - "message": "Усі майбутні преміумфункції. Їх буде більше!" + "message": "Усі майбутні функції Premium. Їх буде більше!" }, "premiumPurchase": { - "message": "Придбати преміум" + "message": "Придбати Premium" }, "premiumPurchaseAlertV2": { - "message": "Ви можете придбати Преміум у налаштуваннях облікового запису вебпрограмі Bitwarden." + "message": "Ви можете придбати Premium у налаштуваннях облікового запису вебпрограми Bitwarden." }, "premiumCurrentMember": { - "message": "Ви користуєтеся передплатою преміум!" + "message": "Ви користуєтеся передплатою Premium!" }, "premiumCurrentMemberThanks": { "message": "Дякуємо за підтримку Bitwarden." }, "premiumPrice": { - "message": "Всього лише $PRICE$ / за рік!", + "message": "Лише $PRICE$ / рік!", "placeholders": { "price": { "content": "$1", @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Блокувати головним паролем при перезапуску" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Вимагати головний пароль або PIN після перезапуску програми" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Вимагати головний пароль після перезапуску програми" + }, "deleteAccount": { "message": "Видалити обліковий запис" }, @@ -2005,7 +2029,7 @@ "message": "Bitwarden може автоматично заповнювати одноразові коди двоетапної перевірки. Відкрийте камеру, щоб сканувати QR-код на цьому вебсайті, або скопіюйте і вставте ключ у це поле." }, "premium": { - "message": "Преміум", + "message": "Premium", "description": "Premium membership" }, "freeOrgsCannotUseAttachments": { @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Під час увімкнення інтеграції з браузером сталася помилка." }, - "browserIntegrationMasOnlyDesc": { - "message": "На жаль, зараз інтеграція з браузером підтримується лише у версії для Mac з App Store." - }, "browserIntegrationWindowsStoreDesc": { "message": "На жаль, зараз інтеграція з браузером не підтримується у версії з Microsoft Store." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Мінімальний власний час очікування – 1 хвилина." + }, "inviteAccepted": { "message": "Запрошення прийнято" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Буде експортовано лише сховище організації, пов'язане з $ORGANIZATION$.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Буде експортовано лише сховище організації, пов'язане з $ORGANIZATION$. Записи моїх збірок не будуть включені.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Заблоковано" }, @@ -2937,7 +2979,7 @@ "message": "Ключ API" }, "premiumSubcriptionRequired": { - "message": "Необхідна передплата преміум" + "message": "Необхідна передплата Premium" }, "organizationIsDisabled": { "message": "Організацію призупинено" @@ -3586,10 +3628,10 @@ "message": "Увійдіть з використанням облікових даних вашої компанії." }, "importDirectlyFromBrowser": { - "message": "Import directly from browser" + "message": "Імпортувати безпосередньо з браузера" }, "browserProfile": { - "message": "Browser Profile" + "message": "Профіль браузера" }, "seeDetailedInstructions": { "message": "Перегляньте докладні інструкції на нашому довідковому сайті", @@ -3834,10 +3876,10 @@ "message": "Змінити ризикований пароль" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "message": "Цей запис ризикований, і не має адреси вебсайту. Додайте адресу вебсайту і змініть пароль для вдосконалення безпеки." }, "missingWebsite": { - "message": "Missing website" + "message": "Немає вебсайту" }, "cannotRemoveViewOnlyCollections": { "message": "Ви не можете вилучати збірки, маючи дозвіл лише на перегляд: $COLLECTIONS$", @@ -3935,10 +3977,10 @@ "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "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." + "message": "Bitwarden використовуватиме збережені URI-адреси записів для визначення піктограм вебсайтів або URL-адрес для зміни паролів, щоб вдосконалити вашу роботу. Під час використання цієї послуги ваша інформація не збирається і не зберігається." }, "assignToCollections": { "message": "Призначити до збірок" @@ -4080,59 +4122,106 @@ "showLess": { "message": "Згорнути" }, - "enableAutotype": { - "message": "Увімкнути автовведення" - }, "enableAutotypeDescription": { "message": "Bitwarden не перевіряє місця введення. Переконайтеся, що у вас відкрите правильне вікно і вибрано потрібне поле, перш ніж застосувати комбінацію клавіш." }, + "typeShortcut": { + "message": "Введіть комбінацію клавіш" + }, + "editAutotypeShortcutDescription": { + "message": "Використайте один або два таких модифікацій: Ctrl, Alt, Win, Shift, і літеру." + }, + "invalidShortcut": { + "message": "Недійсна комбінація клавіш" + }, "moreBreadcrumbs": { "message": "Інші елементи", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." }, "next": { - "message": "Next" + "message": "Далі" }, "confirmKeyConnectorDomain": { - "message": "Confirm Key Connector domain" + "message": "Підтвердити домен Key Connector" }, "confirm": { - "message": "Confirm" + "message": "Підтвердити" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Увімкнути комбінацію клавіш автовведення (тестова функція)" }, - "enableAutotypeDescriptionTransitionKey": { - "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." + "enableAutotypeShortcutDescription": { + "message": "Перед використанням комбінації клавіш виберіть правильне поле, щоб уникнути заповнення даних у невідповідному місці." }, "editShortcut": { - "message": "Edit shortcut" + "message": "Редагувати комбінацію клавіш" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "Архів", + "description": "Noun" }, - "unarchive": { - "message": "Unarchive" + "archiveVerb": { + "message": "Архівувати", + "description": "Verb" + }, + "unArchive": { + "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": "Архівовані записи з'являтимуться тут і будуть виключені з результатів звичайного пошуку та пропозицій автозаповнення." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Запис архівовано" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "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?" + "message": "Архівовані записи виключаються з результатів звичайного пошуку та пропозицій автозаповнення. Ви дійсно хочете архівувати цей запис?" + }, + "zipPostalCodeLabel": { + "message": "Поштовий індекс" + }, + "cardNumberLabel": { + "message": "Номер картки" + }, + "upgradeNow": { + "message": "Покращити" + }, + "builtInAuthenticator": { + "message": "Вбудований автентифікатор" + }, + "secureFileStorage": { + "message": "Захищене сховище файлів" + }, + "emergencyAccess": { + "message": "Екстрений доступ" + }, + "breachMonitoring": { + "message": "Моніторинг витоків даних" + }, + "andMoreFeatures": { + "message": "Інші можливості!" + }, + "planDescPremium": { + "message": "Повна онлайн-безпека" + }, + "upgradeToPremium": { + "message": "Покращити до Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/vi/messages.json b/apps/desktop/src/locales/vi/messages.json index a9ac6aa5bd7..8bf88aba458 100644 --- a/apps/desktop/src/locales/vi/messages.json +++ b/apps/desktop/src/locales/vi/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "You don't have permission to edit this item" + }, "welcomeBack": { "message": "Chào mừng bạn trở lại" }, @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "Dùng đăng nhập một lần" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Tổ chức của bạn yêu cầu đăng nhập một lần." + }, "submit": { "message": "Gửi" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "Bạn phải thêm URL máy chủ cơ sở hoặc ít nhất một môi trường tùy chỉnh." }, + "selfHostedEnvMustUseHttps": { + "message": "URL phải sử dụng HTTPS." + }, "customEnvironment": { "message": "Môi trường tùy chỉnh" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "Mật khẩu chính không hợp lệ" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "Mật khẩu chính không hợp lệ. Xác nhận email của bạn là chính xác và tài khoản được tạo trên $HOST$.", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "Đăng nhập hai bước giúp tài khoản của bạn an toàn hơn bằng cách yêu cầu bạn xác minh việc đăng nhập bằng một thiết bị khác như khóa bảo mật, ứng dụng xác thực, SMS, cuộc gọi điện thoại hoặc email. Đăng nhập hai bước có thể được thiết lập trên bitwarden.com. Bạn có muốn truy cập trang web bây giờ không?" }, @@ -1235,7 +1253,7 @@ "message": "Đóng kho sau" }, "vaultTimeout1": { - "message": "Quá hạn" + "message": "Thời gian chờ" }, "vaultTimeoutAction1": { "message": "Hành động sau khi đóng kho" @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "Khóa bằng mật khẩu chính khi khởi động lại" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Yêu cầu mật khẩu chính hoặc mã PIN khi khởi động lại ứng dụng" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Yêu cầu mật khẩu chính khi khởi động lại ứng dụng" + }, "deleteAccount": { "message": "Xóa tài khoản" }, @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "Đã xảy ra lỗi khi bật tích hợp với trình duyệt." }, - "browserIntegrationMasOnlyDesc": { - "message": "Rất tiếc, tính năng tích hợp trình duyệt hiện chỉ được hỗ trợ trong phiên bản Mac App Store." - }, "browserIntegrationWindowsStoreDesc": { "message": "Rất tiếc, tính năng tích hợp trình duyệt hiện không được hỗ trợ trong phiên bản Microsoft Store." }, @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "Thời gian đóng kho tùy chỉnh tối thiểu là 1 phút." + }, "inviteAccepted": { "message": "Đã chấp nhận lời mời" }, @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Chỉ kho lưu trữ tổ chức liên kết với $ORGANIZATION$ sẽ được xuất.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Chỉ kho lưu trữ tổ chức liên kết với $ORGANIZATION$ được xuất. Bộ sưu tập mục của tôi sẽ không được bao gồm.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Đã khóa" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "Thu gọn" }, - "enableAutotype": { - "message": "Bật tính năng Tự động nhập liệu" - }, "enableAutotypeDescription": { "message": "Bitwarden không kiểm tra vị trí nhập liệu, hãy đảm bảo bạn đang ở trong đúng cửa sổ và trường nhập liệu trước khi dùng phím tắt." }, + "typeShortcut": { + "message": "Phím tắt nhập liệu" + }, + "editAutotypeShortcutDescription": { + "message": "Bao gồm một hoặc hai trong số các phím bổ trợ sau: Ctrl, Alt, Win hoặc Shift, và một chữ cái." + }, + "invalidShortcut": { + "message": "Phím tắt không hợp lệ" + }, "moreBreadcrumbs": { "message": "Thêm mục điều hướng", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "Xác nhận" }, - "enableAutotypeTransitionKey": { - "message": "Bật phím tắt tự động điền" + "enableAutotypeShortcutPreview": { + "message": "Bật phím tắt tự động nhập (Xem trước tính năng)" }, - "enableAutotypeDescriptionTransitionKey": { - "message": "Hãy đảm bảo bạn đang ở đúng trường trước khi sử dụng phím tắt để tránh điền dữ liệu vào chỗ không đúng." + "enableAutotypeShortcutDescription": { + "message": "Hãy đảm bảo bạn đang ở đúng trường trước khi sử dụng phím tắt để tránh điền dữ liệu vào sai chỗ." }, "editShortcut": { "message": "Chỉnh sửa phím tắt" }, - "archive": { - "message": "Lưu trữ" + "archiveNoun": { + "message": "Lưu trữ", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "Lưu trữ", + "description": "Verb" + }, + "unArchive": { "message": "Hủy lưu trữ" }, "itemsInArchive": { @@ -4123,16 +4176,52 @@ "noItemsInArchiveDesc": { "message": "Các mục đã lưu trữ sẽ hiển thị ở đây và sẽ bị loại khỏi kết quả tìm kiếm và gợi ý tự động điền." }, - "itemSentToArchive": { - "message": "Mục đã được gửi đến kho lưu trữ" + "itemWasSentToArchive": { + "message": "Mục đã được chuyển vào kho lưu trữ" }, - "itemRemovedFromArchive": { - "message": "Mục đã được gỡ khỏi kho lưu trữ" + "itemWasUnarchived": { + "message": "Mục đã được bỏ lưu trữ" }, "archiveItem": { "message": "Lưu trữ mục" }, "archiveItemConfirmDesc": { "message": "Các mục đã lưu trữ sẽ bị loại khỏi kết quả tìm kiếm chung và gợi ý tự động điền. Bạn có chắc chắn muốn lưu trữ mục này không?" + }, + "zipPostalCodeLabel": { + "message": "Mã ZIP / Bưu điện" + }, + "cardNumberLabel": { + "message": "Số thẻ" + }, + "upgradeNow": { + "message": "Nâng cấp ngay" + }, + "builtInAuthenticator": { + "message": "Trình xác thực tích hợp" + }, + "secureFileStorage": { + "message": "Lưu trữ tệp an toàn" + }, + "emergencyAccess": { + "message": "Truy cập khẩn cấp" + }, + "breachMonitoring": { + "message": "Giám sát vi phạm" + }, + "andMoreFeatures": { + "message": "Và nhiều hơn nữa!" + }, + "planDescPremium": { + "message": "Bảo mật trực tuyến toàn diện" + }, + "upgradeToPremium": { + "message": "Nâng cấp lên gói Cao cấp" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index 552afdca34c..5e2b7f7ff7c 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "您没有编辑此项目的权限" + }, "welcomeBack": { "message": "欢迎回来" }, @@ -265,7 +268,7 @@ "message": "需要高级版" }, "premiumRequiredDesc": { - "message": "使用此功能需要高级会员资格。" + "message": "需要高级会员才能使用此功能。" }, "errorOccurred": { "message": "发生错误。" @@ -388,7 +391,7 @@ "message": "州 / 省" }, "zipPostalCode": { - "message": "邮政编码" + "message": "ZIP / 邮政编码" }, "country": { "message": "国家/地区" @@ -576,7 +579,7 @@ "message": "复制验证码 (TOTP)" }, "copyFieldCipherName": { - "message": "复制 $CIPHERNAME$ 的 $FIELD$", + "message": "复制 $CIPHERNAME$ 中的 $FIELD$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "使用单点登录" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "您的组织要求单点登录。" + }, "submit": { "message": "提交" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "您必须添加基础服务器 URL 或至少添加一个自定义环境。" }, + "selfHostedEnvMustUseHttps": { + "message": "URL 必须使用 HTTPS。" + }, "customEnvironment": { "message": "自定义环境" }, @@ -1042,7 +1051,7 @@ "message": "身份验证超时" }, "authenticationSessionTimedOut": { - "message": "身份验证会话超时。请重新启动登录过程。" + "message": "身份验证会话超时。请重新开始登录过程。" }, "selfHostBaseUrl": { "message": "自托管服务器 URL", @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "无效的主密码" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "无效的主密码。请确认您的电子邮箱正确无误,以及您的账户是在 $HOST$ 上创建的。", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "两步登录要求您从其他设备(例如安全密钥、验证器 App、短信、电话或者电子邮件)来验证您的登录,这能使您的账户更加安全。两步登录需要在 bitwarden.com 网页版密码库中设置。现在访问此网站吗?" }, @@ -1229,13 +1247,13 @@ "message": "两步登录" }, "vaultTimeoutHeader": { - "message": "密码库超时时间" + "message": "密码库超时" }, "vaultTimeout": { - "message": "密码库超时时间" + "message": "密码库超时" }, "vaultTimeout1": { - "message": "超时" + "message": "超时时间" }, "vaultTimeoutAction1": { "message": "超时动作" @@ -1280,7 +1298,7 @@ "message": "系统空闲时" }, "onSleep": { - "message": "系统休眠时" + "message": "系统睡眠时" }, "onLocked": { "message": "系统锁定时" @@ -1485,7 +1503,7 @@ "message": "优先客户支持。" }, "premiumSignUpFuture": { - "message": "所有未来的高级功能。即将推出!" + "message": "未来的更多高级版功能。敬请期待!" }, "premiumPurchase": { "message": "购买高级版" @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "重启后使用主密码锁定" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "App 重启时要求主密码或 PIN 码" + }, + "requireMasterPasswordOnAppRestart": { + "message": "App 重启时要求主密码" + }, "deleteAccount": { "message": "删除账户" }, @@ -1880,7 +1904,7 @@ "message": "您必须至少选择一个集合。" }, "premiumUpdated": { - "message": "您已升级到高级会员。" + "message": "您已升级为高级版。" }, "restore": { "message": "恢复" @@ -1949,7 +1973,7 @@ "message": "永久删除" }, "vaultTimeoutLogOutConfirmation": { - "message": "超时后注销将解除对密码库的所有访问权限,并需要进行在线身份验证。确定使用此设置吗?" + "message": "超时后注销账户将解除对密码库的所有访问权限,并需要进行在线身份验证。确定要使用此设置吗?" }, "vaultTimeoutLogOutConfirmationTitle": { "message": "超时动作确认" @@ -2005,7 +2029,7 @@ "message": "Bitwarden 可以存储并填充两步验证码。选择相机图标来拍摄此网站的验证器二维码,或将密钥复制并粘贴到此字段。" }, "premium": { - "message": "高级会员", + "message": "高级版", "description": "Premium membership" }, "freeOrgsCannotUseAttachments": { @@ -2126,9 +2150,6 @@ "browserIntegrationErrorDesc": { "message": "启用浏览器集成时出错。" }, - "browserIntegrationMasOnlyDesc": { - "message": "很遗憾,目前仅 Mac App Store 版本支持浏览器集成。" - }, "browserIntegrationWindowsStoreDesc": { "message": "很遗憾,Microsoft Store 版本目前不支持浏览器集成。" }, @@ -2416,7 +2437,7 @@ "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "changePasswordWarning": { - "message": "更改密码后,您需要使用新密码登录。 在其他设备上的活动会话将在一小时内注销。" + "message": "更改密码后,您需要使用新密码登录。在其他设备上的活动会话将在一小时内注销。" }, "accountRecoveryUpdateMasterPasswordSubtitle": { "message": "更改您的主密码以完成账户恢复。" @@ -2483,7 +2504,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "您的组织策略正在影响您的密码库超时时间。最大允许的密码库超时时间是 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", + "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", "placeholders": { "hours": { "content": "$1", @@ -2500,7 +2521,7 @@ } }, "vaultTimeoutActionPolicyInEffect": { - "message": "您的组织策略已将您的密码库超时动作设置为 $ACTION$。", + "message": "您的组织策略已将您的密码库超时动作设置为「$ACTION$」。", "placeholders": { "action": { "content": "$1", @@ -2509,13 +2530,13 @@ } }, "vaultTimeoutTooLarge": { - "message": "您的密码库超时时间超出了组织设置的限制。" + "message": "您的密码库超时超出了您组织设置的限制。" }, "vaultTimeoutPolicyAffectingOptions": { "message": "企业策略要求已应用到您的超时选项中" }, "vaultTimeoutPolicyInEffect": { - "message": "您的组织策略已将您最大允许的密码库超时时间设置为 $HOURS$ 小时 $MINUTES$ 分钟。", + "message": "您的组织策略已将您最大允许的密码库超时设置为 $HOURS$ 小时 $MINUTES$ 分钟。", "placeholders": { "hours": { "content": "$1", @@ -2528,7 +2549,7 @@ } }, "vaultTimeoutPolicyMaximumError": { - "message": "超时时间超出了您组织设置的限制:最多 $HOURS$ 小时 $MINUTES$ 分钟", + "message": "超时超出了您组织设置的限制:最多 $HOURS$ 小时 $MINUTES$ 分钟", "placeholders": { "hours": { "content": "$1", @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "自定义超时最少为 1 分钟。" + }, "inviteAccepted": { "message": "邀请已接受" }, @@ -2595,13 +2619,13 @@ "message": "偏好设置" }, "appPreferences": { - "message": "应用程序设置(所有账户)" + "message": "App 设置(所有账户)" }, "accountSwitcherLimitReached": { "message": "已达到账户上限。请注销一个账户后再添加其他账户。" }, "settingsTitle": { - "message": "$EMAIL$ 的应用程序设置", + "message": "$EMAIL$ 的 App 设置", "placeholders": { "email": { "content": "$1", @@ -2654,6 +2678,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "仅会导出与 $ORGANIZATION$ 关联的组织密码库。", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "仅会导出与 $ORGANIZATION$ 关联的组织密码库。不包括「我的项目」集合。", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "已锁定" }, @@ -4080,12 +4122,18 @@ "showLess": { "message": "显示更少" }, - "enableAutotype": { - "message": "启用自动输入" - }, "enableAutotypeDescription": { "message": "Bitwarden 不会验证输入位置,在使用快捷键之前,请确保您位于正确的窗口和字段中。" }, + "typeShortcut": { + "message": "输入快捷键" + }, + "editAutotypeShortcutDescription": { + "message": "包含以下一个或两个修饰符:Ctrl、Alt、Win 或 Shift,外加一个字母。" + }, + "invalidShortcut": { + "message": "无效的快捷键" + }, "moreBreadcrumbs": { "message": "更多导航项", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,19 +4147,24 @@ "confirm": { "message": "确认" }, - "enableAutotypeTransitionKey": { - "message": "启用自动输入快捷键" + "enableAutotypeShortcutPreview": { + "message": "启用自动输入快捷键(功能预览)" }, - "enableAutotypeDescriptionTransitionKey": { + "enableAutotypeShortcutDescription": { "message": "在使用快捷键之前,请确保您位于正确的字段中,以避免将数据填入错误的地方。" }, "editShortcut": { "message": "编辑快捷键" }, - "archive": { - "message": "归档" + "archiveNoun": { + "message": "归档", + "description": "Noun" }, - "unarchive": { + "archiveVerb": { + "message": "归档", + "description": "Verb" + }, + "unArchive": { "message": "取消归档" }, "itemsInArchive": { @@ -4123,10 +4176,10 @@ "noItemsInArchiveDesc": { "message": "已归档的项目将显示在此处,并将被排除在一般搜索结果和自动填充建议之外。" }, - "itemSentToArchive": { - "message": "项目已归档" + "itemWasSentToArchive": { + "message": "项目已发送到归档" }, - "itemRemovedFromArchive": { + "itemWasUnarchived": { "message": "项目已取消归档" }, "archiveItem": { @@ -4134,5 +4187,41 @@ }, "archiveItemConfirmDesc": { "message": "已归档的项目将被排除在一般搜索结果和自动填充建议之外。确定要归档此项目吗?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / 邮政编码" + }, + "cardNumberLabel": { + "message": "卡号" + }, + "upgradeNow": { + "message": "立即升级" + }, + "builtInAuthenticator": { + "message": "内置身份验证器" + }, + "secureFileStorage": { + "message": "安全文件存储" + }, + "emergencyAccess": { + "message": "紧急访问" + }, + "breachMonitoring": { + "message": "数据泄露监测" + }, + "andMoreFeatures": { + "message": "以及更多!" + }, + "planDescPremium": { + "message": "全面的在线安全防护" + }, + "upgradeToPremium": { + "message": "升级为高级版" + }, + "sessionTimeoutSettingsAction": { + "message": "超时动作" + }, + "sessionTimeoutHeader": { + "message": "会话超时" } } diff --git a/apps/desktop/src/locales/zh_TW/messages.json b/apps/desktop/src/locales/zh_TW/messages.json index d4e579f89c1..61fc00543ed 100644 --- a/apps/desktop/src/locales/zh_TW/messages.json +++ b/apps/desktop/src/locales/zh_TW/messages.json @@ -69,6 +69,9 @@ } } }, + "noEditPermissions": { + "message": "你沒有權限編輯這個項目" + }, "welcomeBack": { "message": "歡迎回來" }, @@ -470,10 +473,10 @@ "message": "如果您想自動填充表單的復選框,例如「記住電子郵件」,請使用復選框" }, "linkedHelpText": { - "message": "Use a linked field when you are experiencing autofill issues for a specific website." + "message": "使用連結欄位若您在特定網站上遇到自動填入問題。" }, "linkedLabelHelpText": { - "message": "Enter the the field's html id, name, aria-label, or placeholder." + "message": "填入欄位的 html id、名稱、標籤或預留字元" }, "folder": { "message": "資料夾" @@ -576,7 +579,7 @@ "message": "複製驗證碼 (TOTP)" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "複製 $FIELD$,$CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -703,10 +706,10 @@ "message": "附件已儲存" }, "addAttachment": { - "message": "Add attachment" + "message": "新增附件" }, "maxFileSizeSansPunctuation": { - "message": "Maximum file size is 500 MB" + "message": "最大檔案大小為 500MB" }, "file": { "message": "檔案" @@ -771,6 +774,9 @@ "useSingleSignOn": { "message": "使用單一登入" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "您的組織要求使用單一登入。" + }, "submit": { "message": "送出" }, @@ -1032,6 +1038,9 @@ "selfHostedEnvFormInvalid": { "message": "您必須新增伺服器網域 URL 或至少一個自訂環境。" }, + "selfHostedEnvMustUseHttps": { + "message": "URL 必須使用 HTTPS。" + }, "customEnvironment": { "message": "自訂環境" }, @@ -1222,6 +1231,15 @@ "invalidMasterPassword": { "message": "無效的主密碼" }, + "invalidMasterPasswordConfirmEmailAndHost": { + "message": "主密碼無效。請確認你的電子郵件正確,且帳號是於 $HOST$ 建立的。", + "placeholders": { + "host": { + "content": "$1", + "example": "vault.bitwarden.com" + } + } + }, "twoStepLoginConfirmation": { "message": "兩步驟登入需要您從其他裝置(例如安全鑰匙、驗證器程式、SMS、手機或電子郵件)來驗證您的登入,這使您的帳戶更加安全。兩步驟登入可以在 bitwarden.com 網頁版密碼庫啟用。現在要前往嗎?" }, @@ -1229,7 +1247,7 @@ "message": "兩步驟登入" }, "vaultTimeoutHeader": { - "message": "Vault timeout" + "message": "密碼庫逾時時間" }, "vaultTimeout": { "message": "密碼庫逾時時間" @@ -1238,7 +1256,7 @@ "message": "逾時" }, "vaultTimeoutAction1": { - "message": "Timeout action" + "message": "逾時後動作" }, "vaultTimeoutDesc": { "message": "選擇密碼庫何時執行密碼庫逾時動作。" @@ -1303,7 +1321,7 @@ "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "顯示網站圖示並取得變更密碼網址" }, "enableMinToTray": { "message": "最小化至系統匣圖示" @@ -1843,6 +1861,12 @@ "lockWithMasterPassOnRestart1": { "message": "重啟後使用主密碼鎖定" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "要求在重新啟動應用程式時輸入密碼或 PIN 碼" + }, + "requireMasterPasswordOnAppRestart": { + "message": "在應用程式重啟時重新詢問主密碼" + }, "deleteAccount": { "message": "刪除帳戶" }, @@ -1969,7 +1993,7 @@ "description": "Used as a card title description on the set password page to explain why the user is there" }, "cardMetrics": { - "message": "out of $TOTAL$", + "message": "$TOTAL$ 不足", "placeholders": { "total": { "content": "$1", @@ -1981,7 +2005,7 @@ "message": "信用卡資料" }, "cardBrandDetails": { - "message": "$BRAND$ details", + "message": "$BRAND$ 詳細資訊", "placeholders": { "brand": { "content": "$1", @@ -1990,7 +2014,7 @@ } }, "learnMoreAboutAuthenticators": { - "message": "Learn more about authenticators" + "message": "了解更多驗證程式" }, "copyTOTP": { "message": "複製驗證器金鑰 (TOTP)" @@ -1999,23 +2023,23 @@ "message": "無縫兩步驟驗證" }, "totpHelper": { - "message": "Bitwarden can store and fill 2-step verification codes. Copy and paste the key into this field." + "message": "Bitwarden 可以儲存並填入兩步驟驗證碼。複製金鑰並貼上到此欄位。" }, "totpHelperWithCapture": { - "message": "Bitwarden can store and fill 2-step verification codes. Select the camera icon to take a screenshot of this website's authenticator QR code, or copy and paste the key into this field." + "message": "Bitwarden 可以儲存並填入兩步驟驗證碼。選擇相機圖示來截取此網站的驗證器QR code,或手動複製金鑰並貼上到此欄位。" }, "premium": { - "message": "Premium", + "message": "進階版", "description": "Premium membership" }, "freeOrgsCannotUseAttachments": { - "message": "Free organizations cannot use attachments" + "message": "免費組織無法使用附檔" }, "singleFieldNeedsAttention": { - "message": "1 field needs your attention." + "message": "您需注意上方的 1 個欄位。" }, "multipleFieldsNeedAttention": { - "message": "$COUNT$ fields need your attention.", + "message": "您需注意上方的 $COUNT$ 個欄位。", "placeholders": { "count": { "content": "$1", @@ -2024,10 +2048,10 @@ } }, "cardExpiredTitle": { - "message": "Expired card" + "message": "過期的信用卡" }, "cardExpiredMessage": { - "message": "If you've renewed it, update the card's information" + "message": "如果您已續期,請更新信用卡資訊" }, "verificationRequired": { "message": "需要驗證", @@ -2085,19 +2109,19 @@ "message": "新的主密碼不符合原則要求。" }, "receiveMarketingEmailsV2": { - "message": "Get advice, announcements, and research opportunities from Bitwarden in your inbox." + "message": "獲得來自 Bitwarden 的公告、建議及研究資訊電子郵件。" }, "unsubscribe": { - "message": "Unsubscribe" + "message": "取消訂閱" }, "atAnyTime": { - "message": "at any time." + "message": "在任何時間。" }, "byContinuingYouAgreeToThe": { - "message": "By continuing, you agree to the" + "message": "若是繼續,則代表您同意" }, "and": { - "message": "and" + "message": "和" }, "acceptPolicies": { "message": "選中此選取框,即表示您同意下列條款:" @@ -2109,7 +2133,7 @@ "message": "允許瀏覽器整合" }, "enableBrowserIntegrationDesc1": { - "message": "Used to allow biometric unlock in browsers that are not Safari." + "message": "用於在非 Safari 的瀏覽器中啟用生物辨識解鎖。" }, "enableDuckDuckGoBrowserIntegration": { "message": "允許 DuckDuckGo 瀏覽器整合" @@ -2121,13 +2145,10 @@ "message": "不支援瀏覽器整合" }, "browserIntegrationErrorTitle": { - "message": "Error enabling browser integration" + "message": "啟用瀏覽器整合時發生錯誤" }, "browserIntegrationErrorDesc": { - "message": "An error has occurred while enabling browser integration." - }, - "browserIntegrationMasOnlyDesc": { - "message": "很遺憾,目前僅 Mac App Store 版本支援瀏覽器整合功能。" + "message": "啟用瀏覽器整合時發生錯誤。" }, "browserIntegrationWindowsStoreDesc": { "message": "很遺憾,Microsoft Store 版本目前尚不支援瀏覽器整合功能。" @@ -2178,16 +2199,16 @@ "message": "需先在桌面應用程式設定中設定生物特徵辨識,才能使用瀏覽器的生物特徵辨識功能。" }, "biometricsManualSetupTitle": { - "message": "Automatic setup not available" + "message": "無法使用自動設定" }, "biometricsManualSetupDesc": { - "message": "Due to the installation method, biometrics support could not be automatically enabled. Would you like to open the documentation on how to do this manually?" + "message": "由於安裝方式的限制,無法自動啟用生物辨識功能。是否要開啟相關文件以了解如何手動設定?" }, "personalOwnershipSubmitError": { "message": "由於生效中的某個企業原則,您不可將項目儲存至您的個人密碼庫。請將所有權變更為組織帳號,並選擇可用的分類。" }, "yourNewPasswordCannotBeTheSameAsYourCurrentPassword": { - "message": "Your new password cannot be the same as your current password." + "message": "你的新密碼不能與目前的密碼相同。" }, "hintEqualsPassword": { "message": "密碼提示不能與您的密碼相同。" @@ -2199,13 +2220,13 @@ "message": "某個組織政策已禁止您將項目匯入至您的個人密碼庫。" }, "personalDetails": { - "message": "Personal details" + "message": "個人詳細資訊" }, "identification": { - "message": "Identification" + "message": "身分" }, "contactInfo": { - "message": "Contact information" + "message": "聯絡資訊" }, "allSends": { "message": "所有 Send", @@ -2371,10 +2392,10 @@ "message": "驗證 WebAuthn" }, "readSecurityKey": { - "message": "Read security key" + "message": "讀取安全金鑰" }, "awaitingSecurityKeyInteraction": { - "message": "Awaiting security key interaction..." + "message": "等待安全金鑰操作中……" }, "hideEmail": { "message": "對收件人隱藏我的電子郵件位址。" @@ -2386,7 +2407,7 @@ "message": "需要驗證電子郵件" }, "emailVerifiedV2": { - "message": "Email verified" + "message": "電子郵件已驗證" }, "emailVerificationRequiredDesc": { "message": "必須驗證您的電子郵件才能使用此功能。" @@ -2401,7 +2422,7 @@ "message": "此操作受到保護。若要繼續,請重新輸入您的主密碼以驗證您的身份。" }, "masterPasswordSuccessfullySet": { - "message": "Master password successfully set" + "message": "主密碼設定成功" }, "updatedMasterPassword": { "message": "已更新主密碼" @@ -2416,16 +2437,16 @@ "message": "您的主密碼不符合一個或多個組織政策規定。您必須立即更新您的主密碼才能存取密碼庫。進行此動作將登出您目前的工作階段,需要您重新登入。其他裝置上的工作階段可能持續長達一小時。" }, "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": "變更你的主密碼以完成帳號復原。" }, "updateMasterPasswordSubtitle": { - "message": "Your master password does not meet this organization’s requirements. Change your master password to continue." + "message": "你的主密碼不符合此組織的要求。請變更主密碼以繼續。" }, "tdeDisabledMasterPasswordRequired": { - "message": "Your organization has disabled trusted device encryption. Please set a master password to access your vault." + "message": "您的組織停用了信任裝置加密。若要存取您的密碼庫,請設定主密碼。" }, "tryAgain": { "message": "再試一次" @@ -2458,7 +2479,7 @@ "message": "使用生物辨識" }, "enterVerificationCodeSentToEmail": { - "message": "Enter the verification code that was sent to your email." + "message": "輸入傳送到你的電子郵件的驗證碼。" }, "resendCode": { "message": "重新傳送驗證碼" @@ -2470,7 +2491,7 @@ "message": "分鐘" }, "vaultTimeoutPolicyInEffect1": { - "message": "$HOURS$ hour(s) and $MINUTES$ minute(s) maximum.", + "message": "最多 $HOURS$ 小時 $MINUTES$ 分鐘", "placeholders": { "hours": { "content": "$1", @@ -2512,10 +2533,10 @@ "message": "您的密碼庫逾時時間超過組織設定的限制。" }, "vaultTimeoutPolicyAffectingOptions": { - "message": "Enterprise policy requirements have been applied to your timeout options" + "message": "企業政策已套用至您的逾時選項中" }, "vaultTimeoutPolicyInEffect": { - "message": "Your organization policies have set your maximum allowed vault timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).", + "message": "您的組織政策已限定您密碼庫逾時的時間長度。密碼庫逾時時間最高可以設定到 $HOURS$ 小時 $MINUTES$ 分鐘。", "placeholders": { "hours": { "content": "$1", @@ -2528,7 +2549,7 @@ } }, "vaultTimeoutPolicyMaximumError": { - "message": "Timeout exceeds the restriction set by your organization: $HOURS$ hour(s) and $MINUTES$ minute(s) maximum", + "message": "逾時時間超出了您組織設定的此限制:最多 $HOURS$ 小時 $MINUTES$ 分鐘", "placeholders": { "hours": { "content": "$1", @@ -2540,6 +2561,9 @@ } } }, + "vaultCustomTimeoutMinimum": { + "message": "自訂逾時時間最小為 1 分鐘。" + }, "inviteAccepted": { "message": "邀請已接受" }, @@ -2565,13 +2589,13 @@ "message": "主密碼已移除" }, "removeMasterPasswordForOrganizationUserKeyConnector": { - "message": "A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator." + "message": "以下組織的成員已不再需要主密碼。請與你的組織管理員確認下方的網域。" }, "organizationName": { "message": "機構名稱" }, "keyConnectorDomain": { - "message": "Key Connector domain" + "message": "Key Connector 網域" }, "leaveOrganization": { "message": "離開組織" @@ -2634,7 +2658,7 @@ } }, "exportingIndividualVaultWithAttachmentsDescription": { - "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "message": "只會匯出與 $EMAIL$ 關聯的個人密碼庫(包含附件)。組織密碼庫的項目不包含在內。", "placeholders": { "email": { "content": "$1", @@ -2654,11 +2678,29 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "只會匯出與 $ORGANIZATION$ 相關的組織密碼庫。", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "只會匯出與 $ORGANIZATION$ 相關的組織保險庫,「我的項目」集合將不會包含在內。", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "已鎖定" }, "yourVaultIsLockedV2": { - "message": "Your vault is locked" + "message": "您的密碼庫已被鎖定" }, "unlocked": { "message": "已解鎖" @@ -2680,10 +2722,10 @@ "message": "產生使用者名稱" }, "generateEmail": { - "message": "Generate email" + "message": "生成電子郵件" }, "usernameGenerator": { - "message": "Username generator" + "message": "使用者名稱產生器" }, "generatePassword": { "message": "產生密碼" @@ -2692,19 +2734,19 @@ "message": "產生密碼片語" }, "passwordGenerated": { - "message": "Password generated" + "message": "已產生密碼" }, "passphraseGenerated": { - "message": "Passphrase generated" + "message": "已產生密碼" }, "usernameGenerated": { - "message": "Username generated" + "message": "已產生使用者名稱" }, "emailGenerated": { - "message": "Email generated" + "message": "已產生電子郵件" }, "spinboxBoundariesHint": { - "message": "Value must be between $MIN$ and $MAX$.", + "message": "值必須介於 $MIN$ 及 $MAX$。", "description": "Explains spin box minimum and maximum values to the user", "placeholders": { "min": { @@ -2718,7 +2760,7 @@ } }, "passwordLengthRecommendationHint": { - "message": " Use $RECOMMENDED$ characters or more to generate a strong password.", + "message": " 使用 $RECOMMENDED$ 或更多個字元產生更強的密碼。", "description": "Appended to `spinboxBoundariesHint` to recommend a length to the user. This must include any language-specific 'sentence' separator characters (e.g. a space in english).", "placeholders": { "recommended": { @@ -2728,7 +2770,7 @@ } }, "passphraseNumWordsRecommendationHint": { - "message": " Use $RECOMMENDED$ words or more to generate a strong passphrase.", + "message": " 使用 $RECOMMENDED$ 或更多個單字產生強的密碼短語。", "description": "Appended to `spinboxBoundariesHint` to recommend a number of words to the user. This must include any language-specific 'sentence' separator characters (e.g. a space in english).", "placeholders": { "recommended": { @@ -2754,7 +2796,7 @@ "message": "使用您的網域設定的 Catch-all 收件匣。" }, "useThisEmail": { - "message": "Use this email" + "message": "使用此電子郵件" }, "useThisPassword": { "message": "使用此密碼" @@ -2797,11 +2839,11 @@ "description": "Labels the domain name email forwarder service option" }, "forwarderDomainNameHint": { - "message": "Choose a domain that is supported by the selected service", + "message": "選擇一個所選服務支援的網域", "description": "Guidance provided for email forwarding services that support multiple email domains." }, "forwarderError": { - "message": "$SERVICENAME$ error: $ERRORMESSAGE$", + "message": "$SERVICENAME$ 錯誤:$ERRORMESSAGE$", "description": "Reports an error returned by a forwarding service to the user.", "placeholders": { "servicename": { @@ -2819,7 +2861,7 @@ "description": "Displayed with the address on the forwarding service's configuration screen." }, "forwarderGeneratedByWithWebsite": { - "message": "Website: $WEBSITE$. Generated by Bitwarden.", + "message": "網站:$WEBSITE$。透過 Bitwarden 產生。", "description": "Displayed with the address on the forwarding service's configuration screen.", "placeholders": { "WEBSITE": { @@ -2829,7 +2871,7 @@ } }, "forwaderInvalidToken": { - "message": "Invalid $SERVICENAME$ API token", + "message": "無效的 $SERVICENAME$ API 權杖", "description": "Displayed when the user's API token is empty or rejected by the forwarding service.", "placeholders": { "servicename": { @@ -2839,7 +2881,7 @@ } }, "forwaderInvalidTokenWithMessage": { - "message": "Invalid $SERVICENAME$ API token: $ERRORMESSAGE$", + "message": "無效的 $SERVICENAME$ API 權杖:$ERRORMESSAGE$", "description": "Displayed when the user's API token is rejected by the forwarding service with an error message.", "placeholders": { "servicename": { @@ -2853,7 +2895,7 @@ } }, "forwaderInvalidOperation": { - "message": "$SERVICENAME$ refused your request. Please contact your service provider for assistance.", + "message": "$SERVICENAME$ 拒絕了你的請求。請聯絡你的服務提供者以取得協助。", "description": "Displayed when the user is forbidden from using the API by the forwarding service.", "placeholders": { "servicename": { @@ -2863,7 +2905,7 @@ } }, "forwaderInvalidOperationWithMessage": { - "message": "$SERVICENAME$ refused your request: $ERRORMESSAGE$", + "message": "$SERVICENAME$ 拒絕了你的請求:$ERRORMESSAGE$", "description": "Displayed when the user is forbidden from using the API by the forwarding service with an error message.", "placeholders": { "servicename": { @@ -2877,7 +2919,7 @@ } }, "forwarderNoAccountId": { - "message": "Unable to obtain $SERVICENAME$ masked email account ID.", + "message": "無法獲得 $SERVICENAME$ 的轉送電子郵件帳號。", "description": "Displayed when the forwarding service fails to return an account ID.", "placeholders": { "servicename": { @@ -2887,7 +2929,7 @@ } }, "forwarderNoDomain": { - "message": "Invalid $SERVICENAME$ domain.", + "message": "無效的 $SERVICENAME$ 網域。", "description": "Displayed when the domain is empty or domain authorization failed at the forwarding service.", "placeholders": { "servicename": { @@ -2897,7 +2939,7 @@ } }, "forwarderNoUrl": { - "message": "Invalid $SERVICENAME$ url.", + "message": "無效的 $SERVICENAME$ URI。", "description": "Displayed when the url of the forwarding service wasn't supplied.", "placeholders": { "servicename": { @@ -2907,7 +2949,7 @@ } }, "forwarderUnknownError": { - "message": "Unknown $SERVICENAME$ error occurred.", + "message": "發生未知的 $SERVICENAME$ 錯誤。", "description": "Displayed when the forwarding service failed due to an unknown error.", "placeholders": { "servicename": { @@ -2917,7 +2959,7 @@ } }, "forwarderUnknownForwarder": { - "message": "Unknown forwarder: '$SERVICENAME$'.", + "message": "未知的轉送服務提供商:$SERVICENAME$。", "description": "Displayed when the forwarding service is not supported.", "placeholders": { "servicename": { @@ -2976,7 +3018,7 @@ "message": "登入已啟動" }, "logInRequestSent": { - "message": "Request sent" + "message": "已傳送請求" }, "notificationSentDevice": { "message": "已傳送通知至您的裝置。" @@ -2985,13 +3027,13 @@ "message": "已傳送通知至您的裝置" }, "notificationSentDevicePart1": { - "message": "Unlock Bitwarden on your device or on the " + "message": "在你的裝置或其他裝置上解鎖 Bitwarden" }, "notificationSentDeviceAnchor": { "message": "網頁應用程式" }, "notificationSentDevicePart2": { - "message": "Make sure the Fingerprint phrase matches the one below before approving." + "message": "在核准前請確保您的指紋短語與下面完全相符。" }, "needAnotherOptionV1": { "message": "需要另一個選項嗎?" @@ -3003,7 +3045,7 @@ "message": "指紋短語" }, "youWillBeNotifiedOnceTheRequestIsApproved": { - "message": "You will be notified once the request is approved" + "message": "一旦您的請求被通過,您會獲得通知。" }, "needAnotherOption": { "message": "必須先在 Bitwarden 應用程式設定中開啟,才可以使用裝置登入。要改用其他選項嗎?" @@ -3022,7 +3064,7 @@ "description": "'Character count' describes a feature that displays a number next to each character of the password." }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "來自 $EMAIL$ 的存取嘗試", "placeholders": { "email": { "content": "$1", @@ -3031,7 +3073,7 @@ } }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "登入請求已由 $DEVICE$ 上的 $EMAIL$ 批准", "placeholders": { "email": { "content": "$1", @@ -3044,21 +3086,21 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "您拒絕了來自其他裝置的登入嘗試。如果這是您本人,請嘗試再次使用該裝置登入。" }, "webApp": { - "message": "Web app" + "message": "網路應用程式" }, "mobile": { - "message": "Mobile", + "message": "行動裝置", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "擴充套件", "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "電腦版應用程式", "description": "Desktop app" }, "cli": { @@ -3069,13 +3111,13 @@ "description": "Software Development Kit" }, "server": { - "message": "Server" + "message": "伺服器" }, "loginRequest": { - "message": "Login request" + "message": "已要求登入" }, "deviceType": { - "message": "裝置類別" + "message": "裝置類型" }, "ipAddress": { "message": "IP 位址" @@ -3084,10 +3126,10 @@ "message": "時間" }, "confirmAccess": { - "message": "Confirm access" + "message": "確認訪問" }, "denyAccess": { - "message": "Deny access" + "message": "拒絕訪問權限" }, "justNow": { "message": "剛剛" @@ -3108,7 +3150,7 @@ "message": "此請求已失效。" }, "confirmAccessAttempt": { - "message": "Confirm access attempt for $EMAIL$", + "message": "確認 $EMAIL$ 的存取嘗試", "placeholders": { "email": { "content": "$1", @@ -3120,28 +3162,28 @@ "message": "已要求登入" }, "accountAccessRequested": { - "message": "Account access requested" + "message": "帳號存取請求" }, "creatingAccountOn": { - "message": "Creating account on" + "message": "建立帳號於" }, "checkYourEmail": { - "message": "Check your email" + "message": "檢查您的電子郵件" }, "followTheLinkInTheEmailSentTo": { - "message": "Follow the link in the email sent to" + "message": "跟隨電子郵件中的連結" }, "andContinueCreatingYourAccount": { - "message": "and continue creating your account." + "message": "並繼續建立您的帳號" }, "noEmail": { - "message": "No email?" + "message": "沒有電子郵件?" }, "goBack": { "message": "返回" }, "toEditYourEmailAddress": { - "message": "to edit your email address." + "message": "來編輯您的電子郵件位址。" }, "exposedMasterPassword": { "message": "已洩露的主密碼" @@ -3165,13 +3207,13 @@ "message": "重要:" }, "accessing": { - "message": "Accessing" + "message": "正在存取" }, "accessTokenUnableToBeDecrypted": { - "message": "You have been logged out because your access token could not be decrypted. Please log in again to resolve this issue." + "message": "由於無法解密你的存取權杖,你已被登出。請重新登入以解決此問題。" }, "refreshTokenSecureStorageRetrievalFailure": { - "message": "You have been logged out because your refresh token could not be retrieved. Please log in again to resolve this issue." + "message": "由於無法取得你的重新整理權杖,你已被登出。請重新登入以解決此問題。" }, "masterPasswordHint": { "message": "若您忘記主密碼,將會無法找回!" @@ -3192,7 +3234,7 @@ "message": "建議設定更新" }, "rememberThisDeviceToMakeFutureLoginsSeamless": { - "message": "Remember this device to make future logins seamless" + "message": "記住此裝置來讓未來的登入體驗更簡易" }, "deviceApprovalRequired": { "message": "裝置需要取得核准。請在下面選擇一個核准選項:" @@ -3216,10 +3258,10 @@ "message": "要求管理員核准" }, "unableToCompleteLogin": { - "message": "Unable to complete login" + "message": "無法完成登入" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { - "message": "You need to log in on a trusted device or ask your administrator to assign you a password." + "message": "你需要在受信任的裝置上登入,或請管理員為你指派密碼。" }, "region": { "message": "區域" @@ -3262,7 +3304,7 @@ "message": "裝置已信任" }, "trustOrganization": { - "message": "Trust organization" + "message": "目前組織" }, "trust": { "message": "信任" @@ -3274,16 +3316,16 @@ "message": "機構不被信任" }, "emergencyAccessTrustWarning": { - "message": "For the security of your account, only confirm if you have granted emergency access to this user and their fingerprint matches what is displayed in their account" + "message": "為了保護你的帳號安全,僅在你已授予此使用者緊急存取權,且其指紋與其帳號中顯示的指紋相符時才確認。" }, "orgTrustWarning": { - "message": "For the security of your account, only proceed if you are a member of this organization, have account recovery enabled, and the fingerprint displayed below matches the organization's fingerprint." + "message": "為了保護你的帳號安全,僅在你是此組織的成員、已啟用帳號復原功能,且下方顯示的指紋與組織的指紋相符時才繼續。" }, "orgTrustWarning1": { - "message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint." + "message": "此組織有企業政策,會將你加入帳號復原功能。加入後,組織管理員可變更你的密碼。僅在你確認此組織身份,且下方顯示的指紋詞句與該組織的指紋相符時才繼續。" }, "trustUser": { - "message": "Trust user" + "message": "信任使用者" }, "inputRequired": { "message": "必須輸入內容。" @@ -3450,13 +3492,13 @@ "message": "您的帳號要求使用 Duo 兩步驟驗證登入。" }, "duoTwoFactorRequiredPageSubtitle": { - "message": "Duo two-step login is required for your account. Follow the steps below to finish logging in." + "message": "您的帳號需要使用 Duo 兩步驟登入。請依照以下步驟完成登入。" }, "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": "請依照以下步驟使用你的安全金鑰完成登入。" }, "launchDuo": { "message": "使用瀏覽器啟動 Duo" @@ -3486,10 +3528,10 @@ "message": "選擇一個分類" }, "importTargetHintCollection": { - "message": "Select this option if you want the imported file contents moved to a collection" + "message": "若你希望將匯入檔案的內容移至集合,請選擇此選項" }, "importTargetHintFolder": { - "message": "Select this option if you want the imported file contents moved to a folder" + "message": "若你希望將匯入檔案的內容移至資料夾,請選擇此選項" }, "importUnassignedItemsError": { "message": "檔案包含未指派項目。" @@ -3586,10 +3628,10 @@ "message": "請使用您的公司憑證繼續登入。" }, "importDirectlyFromBrowser": { - "message": "Import directly from browser" + "message": "從瀏覽器直接匯入" }, "browserProfile": { - "message": "Browser Profile" + "message": "瀏覽器設定檔" }, "seeDetailedInstructions": { "message": "請參閱我們說明網站上的詳細說明於", @@ -3615,27 +3657,27 @@ "description": "Label indicating the most common import formats" }, "uriMatchDefaultStrategyHint": { - "message": "URI match detection is how Bitwarden identifies autofill suggestions.", + "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": { - "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", + "message": "「正則表達式」是進階選項,可能會增加憑證外洩的風險。", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, "startsWithAdvancedOptionWarning": { - "message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.", + "message": "「開頭為」是進階選項,可能會增加憑證外洩的風險。", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { - "message": "More about match detection", + "message": "深入了解匹配偵測", "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "進階選項", "description": "Advanced option placeholder for uri option component" }, "warningCapitalized": { - "message": "Warning", + "message": "警告", "description": "Warning (should maintain locale-relevant capitalization)" }, "success": { @@ -3709,14 +3751,14 @@ "message": "無法找到可用於 SSO 登入的空閒連接埠。" }, "securePasswordGenerated": { - "message": "Secure password generated! Don't forget to also update your password on the website." + "message": "已產生安全的密碼!請不要忘記同時更新您網站上的密碼。" }, "useGeneratorHelpTextPartOne": { - "message": "Use the generator", + "message": "使用產生器", "description": "This will be used as part of a larger sentence, broken up to include the generator icon. The full sentence will read 'Use the generator [GENERATOR_ICON] to create a strong unique password'" }, "useGeneratorHelpTextPartTwo": { - "message": "to create a strong unique password", + "message": "來產生高強度且唯一的密碼", "description": "This will be used as part of a larger sentence, broken up to include the generator icon. The full sentence will read 'Use the generator [GENERATOR_ICON] to create a strong unique password'" }, "biometricsStatusHelptextUnlockNeeded": { @@ -3750,19 +3792,19 @@ "message": "項目名稱" }, "loginCredentials": { - "message": "Login credentials" + "message": "登入資訊" }, "additionalOptions": { - "message": "Additional options" + "message": "額外選項" }, "itemHistory": { "message": "項目歷史記錄" }, "lastEdited": { - "message": "Last edited" + "message": "最後編輯" }, "upload": { - "message": "Upload" + "message": "上傳" }, "authorize": { "message": "授權" @@ -3774,25 +3816,25 @@ "message": "確認 SSH 密鑰使用" }, "agentForwardingWarningTitle": { - "message": "Warning: Agent Forwarding" + "message": "警告:代理轉送" }, "agentForwardingWarningText": { - "message": "This request comes from a remote device that you are logged into" + "message": "此請求來自你已登入的遠端裝置" }, "sshkeyApprovalMessageInfix": { "message": "正在請求存取權限到" }, "sshkeyApprovalMessageSuffix": { - "message": "in order to" + "message": "用於" }, "sshActionLogin": { - "message": "authenticate to a server" + "message": "驗證伺服器 " }, "sshActionSign": { - "message": "sign a message" + "message": "簽署訊息" }, "sshActionGitSign": { - "message": "sign a git commit" + "message": "簽署 git 提交" }, "unknownApplication": { "message": "應用程式" @@ -3807,40 +3849,40 @@ "message": "從剪貼簿中匯入密鑰" }, "sshKeyImported": { - "message": "SSH key imported successfully" + "message": "SSH 密鑰成功匯入" }, "fileSavedToDevice": { "message": "檔案已儲存到裝置。在您的裝置上管理下載檔案。" }, "allowScreenshots": { - "message": "Allow screen capture" + "message": "允許螢幕擷取" }, "allowScreenshotsDesc": { - "message": "Allow the Bitwarden desktop application to be captured in screenshots and viewed in remote desktop sessions. Disabling this will prevent access on some external displays." + "message": "允許 Bitwarden 桌面應用程式可被截圖或在遠端桌面工作階段中顯示。停用此功能將會限制在部分外接螢幕上的存取。" }, "confirmWindowStillVisibleTitle": { - "message": "Confirm window still visible" + "message": "確認視窗仍可見" }, "confirmWindowStillVisibleContent": { - "message": "Please confirm that the window is still visible." + "message": "請確認視窗仍然可見。" }, "updateBrowserOrDisableFingerprintDialogTitle": { - "message": "Extension update required" + "message": "必須更新擴充套件" }, "updateBrowserOrDisableFingerprintDialogMessage": { - "message": "The browser extension you are using is out of date. Please update it or disable browser integration fingerprint validation in the desktop app settings." + "message": "你使用的瀏覽器擴充功能版本已過期。請更新擴充功能,或在桌面應用程式設定中停用瀏覽器整合的指紋驗證。" }, "changeAtRiskPassword": { - "message": "Change at-risk password" + "message": "變更有風險的密碼" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "message": "此登入資訊存在風險,且缺少網站。請新增網站並變更密碼以提升安全性。" }, "missingWebsite": { - "message": "Missing website" + "message": "缺少網站" }, "cannotRemoveViewOnlyCollections": { - "message": "You cannot remove collections with View only permissions: $COLLECTIONS$", + "message": "若您只有檢視權限,無法移除集合 $COLLECTIONS$。", "placeholders": { "collections": { "content": "$1", @@ -3849,120 +3891,120 @@ } }, "move": { - "message": "Move" + "message": "移動" }, "newFolder": { - "message": "New folder" + "message": "新增資料夾" }, "folderName": { - "message": "Folder Name" + "message": "資料夾名稱" }, "folderHintText": { - "message": "Nest a folder by adding the parent folder's name followed by a “/”. Example: Social/Forums" + "message": "在資料夾名稱後面使用「/」來建立樹狀結構。\n例如:社交網路/論壇" }, "sendsTitleNoItems": { - "message": "Send sensitive information safely", + "message": "安全傳送機密的資訊", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsBodyNoItems": { - "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", + "message": "安全的和任何人及任何平臺分享檔案及資料。您的資料會受到端對端加密的保護。", "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": "快速建立密碼" }, "generatorNudgeBodyOne": { - "message": "Easily create strong and unique passwords by clicking on", + "message": "點擊即可輕鬆產生強且唯一的密碼。", "description": "Two part message", "example": "Easily create strong and unique passwords by clicking on {icon} to help you keep your logins secure." }, "generatorNudgeBodyTwo": { - "message": "to help you keep your logins secure.", + "message": "協助你維持登入資訊的安全。", "description": "Two part message", "example": "Easily create strong and unique passwords by clicking on {icon} to help you keep your logins secure." }, "generatorNudgeBodyAria": { - "message": "Easily create strong and unique passwords by clicking on the Generate password button to help you keep your logins secure.", + "message": "點擊「產生密碼」按鈕即可輕鬆建立強且唯一的密碼,協助你確保登入資訊的安全。", "description": "Aria label for the body content of the generator nudge" }, "newLoginNudgeTitle": { - "message": "Save time with autofill" + "message": "使用自動填入節省時間" }, "newLoginNudgeBodyOne": { - "message": "Include a", + "message": "包含", "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." }, "newLoginNudgeBodyBold": { - "message": "Website", + "message": "網頁", "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." }, "newLoginNudgeBodyTwo": { - "message": "so this login appears as an autofill suggestion.", + "message": "讓此登入顯示為自動填入建議。", "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." }, "newCardNudgeTitle": { - "message": "Seamless online checkout" + "message": "流暢的線上結帳體驗" }, "newCardNudgeBody": { - "message": "With cards, easily autofill payment forms securely and accurately." + "message": "使用卡片功能,安全且精準地自動填入付款表單。" }, "newIdentityNudgeTitle": { - "message": "Simplify creating accounts" + "message": "簡化帳號建立流程 " }, "newIdentityNudgeBody": { - "message": "With identities, quickly autofill long registration or contact forms." + "message": "使用身份資訊,快速自動填入冗長的註冊或聯絡表單。" }, "newNoteNudgeTitle": { - "message": "Keep your sensitive data safe" + "message": "保護你的敏感資料安全" }, "newNoteNudgeBody": { - "message": "With notes, securely store sensitive data like banking or insurance details." + "message": "使用備註功能,安全儲存銀行或保險等敏感資料。" }, "newSshNudgeTitle": { - "message": "Developer-friendly SSH access" + "message": "開發者友善的 SSH 存取" }, "newSshNudgeBodyOne": { - "message": "Store your keys and connect with the SSH agent for fast, encrypted authentication.", + "message": "儲存你的金鑰並透過 SSH 代理程式進行快速、加密的驗證。", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "newSshNudgeBodyTwo": { - "message": "Learn more about SSH agent", + "message": "深入了解 SSH 代理程式", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "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." + "message": "Bitwarden 會使用已儲存的登入 URI 來判斷應顯示的圖示或變更密碼網址,以改善你的使用體驗。使用此服務時,不會收集或儲存任何資訊。" }, "assignToCollections": { - "message": "Assign to collections" + "message": "指派至集合" }, "assignToTheseCollections": { - "message": "Assign to these collections" + "message": "指派至這些集合" }, "bulkCollectionAssignmentDialogDescriptionSingular": { - "message": "Only organization members with access to these collections will be able to see the item." + "message": "只有可以檢視集合的組織成員才能看到其中的項目。" }, "bulkCollectionAssignmentDialogDescriptionPlural": { - "message": "Only organization members with access to these collections will be able to see the items." + "message": "只有可以檢視集合的組織成員才能看到其中的項目。" }, "noCollectionsAssigned": { - "message": "No collections have been assigned" + "message": "尚未指派任何集合" }, "assign": { - "message": "Assign" + "message": "指定" }, "bulkCollectionAssignmentDialogDescription": { - "message": "Only organization members with access to these collections will be able to see the items." + "message": "只有可以檢視集合的組織成員才能看到其中的項目。" }, "bulkCollectionAssignmentWarning": { - "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "message": "您已經選擇 $TOTAL_COUNT$ 個項目。由於您沒有編輯權限,無法更新其中的 $READONLY_COUNT$ 個項目。", "placeholders": { "total_count": { "content": "$1", @@ -3974,10 +4016,10 @@ } }, "selectCollectionsToAssign": { - "message": "Select collections to assign" + "message": "選擇要指派的集合" }, "personalItemsTransferWarning": { - "message": "$PERSONAL_ITEMS_COUNT$ will be permanently transferred to the selected organization. You will no longer own these items.", + "message": "$PERSONAL_ITEMS_COUNT$ 會被永久移到選擇的組織。您將不再擁有這些項目。", "placeholders": { "personal_items_count": { "content": "$1", @@ -3986,7 +4028,7 @@ } }, "personalItemsWithOrgTransferWarning": { - "message": "$PERSONAL_ITEMS_COUNT$ will be permanently transferred to $ORG$. You will no longer own these items.", + "message": "$PERSONAL_ITEMS_COUNT$ 會被永久移到 $ORG$。您將不再擁有這些項目。", "placeholders": { "personal_items_count": { "content": "$1", @@ -3999,10 +4041,10 @@ } }, "personalItemTransferWarningSingular": { - "message": "1 item will be permanently transferred to the selected organization. You will no longer own this item." + "message": "1 個項目會被永久移到選擇的組織。您將不再擁有此項目。" }, "personalItemWithOrgTransferWarningSingular": { - "message": "1 item will be permanently transferred to $ORG$. You will no longer own this item.", + "message": "1 個項目會被永久移到 $ORG$。您將不再擁有此項目。", "placeholders": { "org": { "content": "$1", @@ -4011,13 +4053,13 @@ } }, "successfullyAssignedCollections": { - "message": "Successfully assigned collections" + "message": "指派集合成功" }, "nothingSelected": { - "message": "You have not selected anything." + "message": "您沒有選擇任何項目。" }, "itemsMovedToOrg": { - "message": "Items moved to $ORGNAME$", + "message": "項目已移到 $ORGNAME$", "placeholders": { "orgname": { "content": "$1", @@ -4026,7 +4068,7 @@ } }, "itemMovedToOrg": { - "message": "Item moved to $ORGNAME$", + "message": "項目已移到 $ORGNAME$", "placeholders": { "orgname": { "content": "$1", @@ -4035,7 +4077,7 @@ } }, "movedItemsToOrg": { - "message": "Selected items moved to $ORGNAME$", + "message": "將已選取項目移動至 $ORGNAME$", "placeholders": { "orgname": { "content": "$1", @@ -4075,64 +4117,111 @@ } }, "showMore": { - "message": "Show more" + "message": "顯示更多" }, "showLess": { - "message": "Show less" - }, - "enableAutotype": { - "message": "Enable Autotype" + "message": "顯示較少" }, "enableAutotypeDescription": { - "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." + "message": "Bitwarden 不會驗證輸入位置,請在使用快捷鍵前確認你位於正確的視窗與欄位中。" + }, + "typeShortcut": { + "message": "輸入快捷鍵" + }, + "editAutotypeShortcutDescription": { + "message": "請包含以下修飾鍵之一或兩個:Ctrl、Alt、Win 或 Shift,再加上一個字母。" + }, + "invalidShortcut": { + "message": "無效的捷徑" }, "moreBreadcrumbs": { - "message": "More breadcrumbs", + "message": "更多導覽階層", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." }, "next": { - "message": "Next" + "message": "下一步" }, "confirmKeyConnectorDomain": { - "message": "Confirm Key Connector domain" + "message": "確認 Key Connector 網域" }, "confirm": { - "message": "Confirm" + "message": "確認" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "啟用自動輸入快捷鍵(功能預覽)" }, - "enableAutotypeDescriptionTransitionKey": { - "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." + "enableAutotypeShortcutDescription": { + "message": "請在使用快捷鍵前確認位於正確的欄位中,以避免將資料填入錯誤" }, "editShortcut": { - "message": "Edit shortcut" + "message": "編輯捷徑" }, - "archive": { - "message": "Archive" + "archiveNoun": { + "message": "封存", + "description": "Noun" }, - "unarchive": { - "message": "Unarchive" + "archiveVerb": { + "message": "封存", + "description": "Verb" + }, + "unArchive": { + "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": "封存的項目會顯示在此處,且不會出現在一般搜尋結果或自動填入建議中。" }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "項目已移至封存" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemWasUnarchived": { + "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?" + "message": "封存的項目將不會出現在一般搜尋結果或自動填入建議中。確定要封存此項目嗎?" + }, + "zipPostalCodeLabel": { + "message": "郵編 / 郵政代碼" + }, + "cardNumberLabel": { + "message": "支付卡號碼" + }, + "upgradeNow": { + "message": "立即升級" + }, + "builtInAuthenticator": { + "message": "內建驗證器" + }, + "secureFileStorage": { + "message": "安全檔案儲存" + }, + "emergencyAccess": { + "message": "緊急存取" + }, + "breachMonitoring": { + "message": "外洩監控" + }, + "andMoreFeatures": { + "message": "以及其他功能功能!" + }, + "planDescPremium": { + "message": "完整的線上安全" + }, + "upgradeToPremium": { + "message": "升級到 Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "逾時後動作" + }, + "sessionTimeoutHeader": { + "message": "工作階段逾時" } } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d5484213a90..fbb83a1bf56 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -221,7 +221,7 @@ export class Main { ); this.messagingMain = new MessagingMain(this, this.desktopSettingsService); - this.updaterMain = new UpdaterMain(this.i18nService, this.windowMain); + this.updaterMain = new UpdaterMain(this.i18nService, this.logService, this.windowMain); const messageSubject = new Subject>>(); this.messagingService = MessageSender.combine( diff --git a/apps/desktop/src/main/menu/menu.view.ts b/apps/desktop/src/main/menu/menu.view.ts index 962c57fdb60..d24128730cc 100644 --- a/apps/desktop/src/main/menu/menu.view.ts +++ b/apps/desktop/src/main/menu/menu.view.ts @@ -6,6 +6,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { isDev } from "../../utils"; +import { WindowMain } from "../window.main"; import { IMenubarMenu } from "./menubar"; @@ -42,11 +43,18 @@ export class ViewMenu implements IMenubarMenu { private readonly _i18nService: I18nService; private readonly _messagingService: MessagingService; private readonly _isLocked: boolean; + private readonly _windowMain: WindowMain; - constructor(i18nService: I18nService, messagingService: MessagingService, isLocked: boolean) { + constructor( + i18nService: I18nService, + messagingService: MessagingService, + isLocked: boolean, + windowMain: WindowMain, + ) { this._i18nService = i18nService; this._messagingService = messagingService; this._isLocked = isLocked; + this._windowMain = windowMain; } private get searchVault(): MenuItemConstructorOptions { @@ -86,7 +94,12 @@ export class ViewMenu implements IMenubarMenu { return { id: "zoomIn", label: this.localize("zoomIn"), - role: "zoomIn", + click: async () => { + const currentZoom = this._windowMain.win.webContents.zoomFactor; + const newZoom = currentZoom + 0.1; + this._windowMain.win.webContents.zoomFactor = newZoom; + await this._windowMain.saveZoomFactor(newZoom); + }, accelerator: "CmdOrCtrl+=", }; } @@ -95,7 +108,12 @@ export class ViewMenu implements IMenubarMenu { return { id: "zoomOut", label: this.localize("zoomOut"), - role: "zoomOut", + click: async () => { + const currentZoom = this._windowMain.win.webContents.zoomFactor; + const newZoom = Math.max(0.2, currentZoom - 0.1); + this._windowMain.win.webContents.zoomFactor = newZoom; + await this._windowMain.saveZoomFactor(newZoom); + }, accelerator: "CmdOrCtrl+-", }; } @@ -104,7 +122,11 @@ export class ViewMenu implements IMenubarMenu { return { id: "resetZoom", label: this.localize("resetZoom"), - role: "resetZoom", + click: async () => { + const newZoom = 1.0; + this._windowMain.win.webContents.zoomFactor = newZoom; + await this._windowMain.saveZoomFactor(newZoom); + }, accelerator: "CmdOrCtrl+0", }; } diff --git a/apps/desktop/src/main/menu/menubar.ts b/apps/desktop/src/main/menu/menubar.ts index 8ac3a084d95..0a00a67b84a 100644 --- a/apps/desktop/src/main/menu/menubar.ts +++ b/apps/desktop/src/main/menu/menubar.ts @@ -86,7 +86,7 @@ export class Menubar { updateRequest?.restrictedCipherTypes, ), new EditMenu(i18nService, messagingService, isLocked), - new ViewMenu(i18nService, messagingService, isLocked), + new ViewMenu(i18nService, messagingService, isLocked, windowMain), new AccountMenu( i18nService, messagingService, diff --git a/apps/desktop/src/main/native-messaging.main.ts b/apps/desktop/src/main/native-messaging.main.ts index 93525164ff5..ba5d8616752 100644 --- a/apps/desktop/src/main/native-messaging.main.ts +++ b/apps/desktop/src/main/native-messaging.main.ts @@ -78,7 +78,7 @@ export class NativeMessagingMain { this.ipcServer.stop(); } - this.ipcServer = await ipc.IpcServer.listen("bitwarden", (error, msg) => { + this.ipcServer = await ipc.IpcServer.listen("bw", (error, msg) => { switch (msg.kind) { case ipc.IpcMessageType.Connected: { this.connected.push(msg.clientId); diff --git a/apps/desktop/src/main/updater.main.ts b/apps/desktop/src/main/updater.main.ts index 51d5073911e..60b4f282405 100644 --- a/apps/desktop/src/main/updater.main.ts +++ b/apps/desktop/src/main/updater.main.ts @@ -1,8 +1,9 @@ -import { dialog, shell } from "electron"; +import { dialog, shell, Notification } from "electron"; import log from "electron-log"; import { autoUpdater, UpdateDownloadedEvent, VerifyUpdateSupport } from "electron-updater"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/logging"; import { isAppImage, isDev, isMacAppStore, isWindowsPortable, isWindowsStore } from "../utils"; @@ -11,6 +12,8 @@ import { WindowMain } from "./window.main"; const UpdaterCheckInitialDelay = 5 * 1000; // 5 seconds const UpdaterCheckInterval = 12 * 60 * 60 * 1000; // 12 hours +const MaxTimeBeforeBlockingUpdateNotification = 7 * 24 * 60 * 60 * 1000; // 7 days + export class UpdaterMain { private doingUpdateCheck = false; private doingUpdateCheckWithFeedback = false; @@ -18,8 +21,19 @@ export class UpdaterMain { private updateDownloaded: UpdateDownloadedEvent = null; private originalRolloutFunction: VerifyUpdateSupport = null; + // This needs to be tracked to avoid the Notification being garbage collected, + // which would break the click handler. + private openedNotification: Notification | null = null; + + // This is used to set when the initial update notification was shown. + // The system notifications can be easy to miss or be disabled, so we want to + // ensure the user is eventually made aware of the update. If the user does not + // interact with the notification in a reasonable time, we will prompt them again. + private initialUpdateNotificationTime: number | null = null; + constructor( private i18nService: I18nService, + private logService: LogService, private windowMain: WindowMain, ) { autoUpdater.logger = log; @@ -43,6 +57,8 @@ export class UpdaterMain { }); autoUpdater.on("update-available", async () => { + this.initialUpdateNotificationTime ??= Date.now(); + if (this.doingUpdateCheckWithFeedback) { if (this.windowMain.win == null) { this.reset(); @@ -87,7 +103,7 @@ export class UpdaterMain { } this.updateDownloaded = info; - await this.promptRestartUpdate(info); + await this.promptRestartUpdate(info, this.doingUpdateCheckWithFeedback); }); autoUpdater.on("error", (error) => { @@ -108,7 +124,7 @@ export class UpdaterMain { } if (this.updateDownloaded && withFeedback) { - await this.promptRestartUpdate(this.updateDownloaded); + await this.promptRestartUpdate(this.updateDownloaded, true); return; } @@ -144,7 +160,50 @@ export class UpdaterMain { this.updateDownloaded = null; } - private async promptRestartUpdate(info: UpdateDownloadedEvent) { + private async promptRestartUpdate(info: UpdateDownloadedEvent, blocking: boolean) { + // If we have an initial notification, and it's from a long time ago, + // we will block the user with a dialog to ensure they see it. + const longTimeSinceInitialNotification = + this.initialUpdateNotificationTime != null && + Date.now() - this.initialUpdateNotificationTime > MaxTimeBeforeBlockingUpdateNotification; + + if (!longTimeSinceInitialNotification && !blocking && Notification.isSupported()) { + // If the prompt doesn't have to block and we support notifications, + // we will show a notification instead of a blocking dialog, which won't steal focus. + await this.promptRestartUpdateUsingSystemNotification(info); + } else { + // If we are blocking, or notifications are not supported, we will show a blocking dialog. + // This will steal the user's focus, so we should only do this for user initiated actions + // or when there are no other options. + await this.promptRestartUpdateUsingDialog(info); + } + } + + private async promptRestartUpdateUsingSystemNotification(info: UpdateDownloadedEvent) { + if (this.openedNotification != null) { + this.openedNotification.close(); + } + + this.openedNotification = new Notification({ + title: this.i18nService.t("bitwarden") + " - " + this.i18nService.t("restartToUpdate"), + body: this.i18nService.t("restartToUpdateDesc", info.version), + timeoutType: "never", + silent: false, + }); + + // If the user clicks the notification, prompt again to restart, this time with a blocking dialog. + this.openedNotification.on("click", () => { + void this.promptRestartUpdate(info, true); + }); + // If the notification fails to show, fall back to the blocking dialog as well. + this.openedNotification.on("failed", (error) => { + this.logService.error("Update notification failed", error); + void this.promptRestartUpdate(info, true); + }); + this.openedNotification.show(); + } + + private async promptRestartUpdateUsingDialog(info: UpdateDownloadedEvent) { const result = await dialog.showMessageBox(this.windowMain.win, { type: "info", title: this.i18nService.t("bitwarden") + " - " + this.i18nService.t("restartToUpdate"), diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index 1595252251b..0e234126ea3 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -82,7 +82,12 @@ export class WindowMain { ipcMain.on("window-hide", () => { if (this.win != null) { - this.win.hide(); + if (isWindows()) { + // On windows, to return focus we need minimize + this.win.minimize(); + } else { + this.win.hide(); + } } }); @@ -180,6 +185,7 @@ export class WindowMain { await this.createWindow(); resolve(); + if (this.argvCallback != null) { this.argvCallback(process.argv); } @@ -298,7 +304,9 @@ export class WindowMain { this.win.webContents.zoomFactor = this.windowStates[mainWindowSizeKey].zoomFactor ?? 1.0; }); - // Persist zoom changes immediately when user zooms in/out or resets zoom + // Persist zoom changes from mouse wheel and programmatic zoom operations + // NOTE: This event does NOT fire for keyboard shortcuts (Ctrl+/-/0, Cmd+/-/0) + // which are handled by custom menu click handlers in ViewMenu // We can't depend on higher level web events (like close) to do this // because locking the vault resets window state. this.win.webContents.on("zoom-changed", async () => { @@ -427,6 +435,11 @@ export class WindowMain { await this.desktopSettingsService.setAlwaysOnTop(this.enableAlwaysOnTop); } + async saveZoomFactor(zoomFactor: number) { + this.windowStates[mainWindowSizeKey].zoomFactor = zoomFactor; + await this.desktopSettingsService.setWindow(this.windowStates[mainWindowSizeKey]); + } + private windowStateChangeHandler(configKey: string, win: BrowserWindow) { global.clearTimeout(this.windowStateChangeTimer); this.windowStateChangeTimer = global.setTimeout(async () => { @@ -500,9 +513,9 @@ export class WindowMain { displayBounds.x !== state.displayBounds.x || displayBounds.y !== state.displayBounds.y ) { - state.x = undefined; - state.y = undefined; displayBounds = screen.getPrimaryDisplay().bounds; + state.x = displayBounds.x + displayBounds.width / 2 - state.width / 2; + state.y = displayBounds.y + displayBounds.height / 2 - state.height / 2; } } diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index 706ad67aa2a..44fdb5c23b0 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2025.9.1", + "version": "2025.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2025.9.1", + "version": "2025.12.0", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index 7d8d0cc18a2..4c396304f4a 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2025.9.1", + "version": "2025.12.0", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/desktop/src/platform/components/approve-ssh-request.html b/apps/desktop/src/platform/components/approve-ssh-request.html index b7005872f25..55092788079 100644 --- a/apps/desktop/src/platform/components/approve-ssh-request.html +++ b/apps/desktop/src/platform/components/approve-ssh-request.html @@ -1,6 +1,6 @@
    -
    {{ "sshkeyApprovalTitle" | i18n }}
    +
    {{ "sshkeyApprovalTitle" | i18n }}
    { if (error) { diff --git a/apps/desktop/src/platform/popup-modal-styles.ts b/apps/desktop/src/platform/popup-modal-styles.ts index ae46ebb5c76..5c5619bd463 100644 --- a/apps/desktop/src/platform/popup-modal-styles.ts +++ b/apps/desktop/src/platform/popup-modal-styles.ts @@ -45,11 +45,11 @@ export function applyMainWindowStyles(window: BrowserWindow, existingWindowState // need to guard against null/undefined values if (existingWindowState?.width && existingWindowState?.height) { - window.setSize(existingWindowState.width, existingWindowState.height); + window.setSize(Math.floor(existingWindowState.width), Math.floor(existingWindowState.height)); } if (existingWindowState?.x && existingWindowState?.y) { - window.setPosition(existingWindowState.x, existingWindowState.y); + window.setPosition(Math.floor(existingWindowState.x), Math.floor(existingWindowState.y)); } window.setWindowButtonVisibility?.(true); diff --git a/apps/desktop/src/platform/services/electron-platform-utils.service.ts b/apps/desktop/src/platform/services/electron-platform-utils.service.ts index de63c6b28aa..9377ac567ec 100644 --- a/apps/desktop/src/platform/services/electron-platform-utils.service.ts +++ b/apps/desktop/src/platform/services/electron-platform-utils.service.ts @@ -151,4 +151,20 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService { getAutofillKeyboardShortcut(): Promise { return null; } + + async packageType(): Promise { + if (ipc.platform.isMacAppStore) { + return "MacAppStore"; + } else if (ipc.platform.isWindowsStore) { + return "WindowsStore"; + } else if (ipc.platform.isAppImage) { + return "AppImage"; + } else if (ipc.platform.isSnapStore) { + return "Snap"; + } else if (ipc.platform.isFlatpak) { + return "Flatpak"; + } else { + return "Unknown"; + } + } } diff --git a/apps/desktop/src/platform/services/electron-storage.service.ts b/apps/desktop/src/platform/services/electron-storage.service.ts index 2d292d6537b..34aa8837475 100644 --- a/apps/desktop/src/platform/services/electron-storage.service.ts +++ b/apps/desktop/src/platform/services/electron-storage.service.ts @@ -3,6 +3,7 @@ import * as fs from "fs"; import { ipcMain } from "electron"; +import ElectronStore from "electron-store"; import { Subject } from "rxjs"; import { @@ -11,22 +12,7 @@ import { } from "@bitwarden/common/platform/abstractions/storage.service"; import { NodeUtils } from "@bitwarden/node/node-utils"; -// See: https://github.com/sindresorhus/electron-store/blob/main/index.d.ts -interface ElectronStoreOptions { - defaults: unknown; - name: string; -} - -type ElectronStoreConstructor = new (options: ElectronStoreOptions) => ElectronStore; - -// eslint-disable-next-line -const Store: ElectronStoreConstructor = require("electron-store"); - -interface ElectronStore { - get: (key: string) => unknown; - set: (key: string, obj: unknown) => void; - delete: (key: string) => void; -} +import { isWindowsPortable } from "../../utils"; interface BaseOptions { action: T; @@ -48,11 +34,13 @@ export class ElectronStorageService implements AbstractStorageService { if (!fs.existsSync(dir)) { NodeUtils.mkdirpSync(dir, "700"); } - const storeConfig: ElectronStoreOptions = { + const fileMode = isWindowsPortable() ? 0o666 : 0o600; + const storeConfig: ElectronStore.Options> = { defaults: defaults, name: "data", + configFileMode: fileMode, }; - this.store = new Store(storeConfig); + this.store = new ElectronStore(storeConfig); this.updates$ = this.updatesSubject.asObservable(); ipcMain.handle("storageService", (event, options: Options) => { diff --git a/apps/desktop/src/platform/services/sso-localhost-callback.service.ts b/apps/desktop/src/platform/services/sso-localhost-callback.service.ts index 14baee51b90..75a84919b07 100644 --- a/apps/desktop/src/platform/services/sso-localhost-callback.service.ts +++ b/apps/desktop/src/platform/services/sso-localhost-callback.service.ts @@ -12,10 +12,13 @@ import { MessageSender } from "@bitwarden/common/platform/messaging"; /** * The SSO Localhost login service uses a local host listener as fallback in case scheme handling deeplinks does not work. - * This way it is possible to log in with SSO on appimage, snap, and electron dev using the same methods that the cli uses. + * This way it is possible to log in with SSO on appimage and electron dev using the same methods that the cli uses. */ export class SSOLocalhostCallbackService { private ssoRedirectUri = ""; + // We will only track one server at a time for use-case and performance considerations. + // This will result in a last-one-wins behavior if multiple SSO flows are started simultaneously. + private currentServer: http.Server | null = null; constructor( private environmentService: EnvironmentService, @@ -23,11 +26,30 @@ export class SSOLocalhostCallbackService { private ssoUrlService: SsoUrlService, ) { ipcMain.handle("openSsoPrompt", async (event, { codeChallenge, state, email }) => { - const { ssoCode, recvState } = await this.openSsoPrompt(codeChallenge, state, email); - this.messagingService.send("ssoCallback", { - code: ssoCode, - state: recvState, - redirectUri: this.ssoRedirectUri, + // Close any existing server before starting new one + if (this.currentServer) { + await this.closeCurrentServer(); + } + + return this.openSsoPrompt(codeChallenge, state, email).then(({ ssoCode, recvState }) => { + this.messagingService.send("ssoCallback", { + code: ssoCode, + state: recvState, + redirectUri: this.ssoRedirectUri, + }); + }); + }); + } + + private async closeCurrentServer(): Promise { + if (!this.currentServer) { + return; + } + + return new Promise((resolve) => { + this.currentServer!.close(() => { + this.currentServer = null; + resolve(); }); }); } @@ -59,6 +81,7 @@ export class SSOLocalhostCallbackService { "

    You may now close this tab and return to the app.

    " + "", ); + this.currentServer = null; callbackServer.close(() => resolve({ ssoCode: code, @@ -73,41 +96,68 @@ export class SSOLocalhostCallbackService { "

    You may now close this tab and return to the app.

    " + "", ); + this.currentServer = null; callbackServer.close(() => reject()); } }); - let foundPort = false; - const webUrl = env.getWebVaultUrl(); - for (let port = 8065; port <= 8070; port++) { - try { - this.ssoRedirectUri = "http://localhost:" + port; - const ssoUrl = this.ssoUrlService.buildSsoUrl( - webUrl, - ClientType.Desktop, - this.ssoRedirectUri, - state, - codeChallenge, - email, - ); - callbackServer.listen(port, () => { - this.messagingService.send("launchUri", { - url: ssoUrl, - }); - }); - foundPort = true; - break; - } catch { - // Ignore error since we run the same command up to 5 times. - } - } - if (!foundPort) { - reject(); - } + // Store reference to current server + this.currentServer = callbackServer; - // after 5 minutes, close the server + const webUrl = env.getWebVaultUrl(); + + const tryNextPort = (port: number) => { + if (port > 8070) { + this.currentServer = null; + reject("All available SSO ports in use"); + return; + } + + this.ssoRedirectUri = "http://localhost:" + port; + const ssoUrl = this.ssoUrlService.buildSsoUrl( + webUrl, + ClientType.Desktop, + this.ssoRedirectUri, + state, + codeChallenge, + email, + ); + + // Set up error handler before attempting to listen + callbackServer.once("error", (err: any) => { + if (err.code === "EADDRINUSE") { + // Port is in use, try next port + tryNextPort(port + 1); + } else { + // Another error - reject and set the current server to null + // (one server alive at a time) + this.currentServer = null; + reject(); + } + }); + + // Attempt to listen on the port + callbackServer.listen(port, () => { + // Success - remove error listener and launch SSO + callbackServer.removeAllListeners("error"); + + this.messagingService.send("launchUri", { + url: ssoUrl, + }); + }); + }; + + // Start trying from port 8065 + tryNextPort(8065); + + // Don't allow any server to stay up for more than 5 minutes; + // this gives plenty of time to complete SSO but ensures we don't + // have a server running indefinitely. setTimeout( () => { + if (this.currentServer === callbackServer) { + this.currentServer = null; + } callbackServer.close(() => reject()); }, 5 * 60 * 1000, diff --git a/apps/desktop/src/scss/environment.scss b/apps/desktop/src/scss/environment.scss index e1356178208..699f2246b4a 100644 --- a/apps/desktop/src/scss/environment.scss +++ b/apps/desktop/src/scss/environment.scss @@ -21,7 +21,7 @@ padding-left: 15px; span { - font-weight: 600; + font-weight: 500; font-size: $font-size-small; } } diff --git a/apps/desktop/src/scss/left-nav.scss b/apps/desktop/src/scss/left-nav.scss index d65e60079a5..54b795548f2 100644 --- a/apps/desktop/src/scss/left-nav.scss +++ b/apps/desktop/src/scss/left-nav.scss @@ -72,7 +72,7 @@ &.active { .filter-button { - font-weight: bold; + font-weight: 500; @include themify($themes) { color: themed("primaryColor"); } @@ -114,7 +114,7 @@ .filter-button { @include themify($themes) { color: themed("primaryColor"); - font-weight: bold; + font-weight: 500; } max-width: 90%; } diff --git a/apps/desktop/src/scss/misc.scss b/apps/desktop/src/scss/misc.scss index b64bdd92120..c70eb823213 100644 --- a/apps/desktop/src/scss/misc.scss +++ b/apps/desktop/src/scss/misc.scss @@ -360,11 +360,16 @@ form, } } -.settings-link { +.help-block a.settings-link { + text-decoration: none; @include themify($themes) { color: themed("primaryColor"); + + &:hover, + &:focus { + color: darken(themed("primaryColor"), 6%); + } } - font-weight: bold; } app-root > #loading, diff --git a/apps/desktop/src/scss/modal.scss b/apps/desktop/src/scss/modal.scss index 1d86b1e880a..b3994946394 100644 --- a/apps/desktop/src/scss/modal.scss +++ b/apps/desktop/src/scss/modal.scss @@ -47,7 +47,7 @@ $modal-sm: 300px !default; $modal-transition: transform 0.3s ease-out !default; $close-font-size: $font-size-base * 1.5 !default; -$close-font-weight: bold !default; +$close-font-weight: 500 !default; $close-color: $black !default; $close-text-shadow: 0 1px 0 $white !default; @@ -218,7 +218,7 @@ $close-text-shadow: 0 1px 0 $white !default; h5 { font-size: $font-size-base; - font-weight: bold; + font-weight: 500; display: flex; align-items: center; diff --git a/apps/desktop/src/scss/variables.scss b/apps/desktop/src/scss/variables.scss index b094be63f8c..0afff76e4ef 100644 --- a/apps/desktop/src/scss/variables.scss +++ b/apps/desktop/src/scss/variables.scss @@ -1,6 +1,6 @@ $dark-icon-themes: "theme_dark"; -$font-family-sans-serif: Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif; +$font-family-sans-serif: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; $font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace; $font-size-base: 14px; $font-size-large: 18px; diff --git a/apps/desktop/src/services/biometric-message-handler.service.spec.ts b/apps/desktop/src/services/biometric-message-handler.service.spec.ts index ad555729ab3..49d346bfa3a 100644 --- a/apps/desktop/src/services/biometric-message-handler.service.spec.ts +++ b/apps/desktop/src/services/biometric-message-handler.service.spec.ts @@ -13,13 +13,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { UserId } from "@bitwarden/common/types/guid"; -import { DialogService, I18nMockService } from "@bitwarden/components"; -import { - KeyService, - BiometricsService, - BiometricStateService, - BiometricsCommands, -} from "@bitwarden/key-management"; +import { DialogService } from "@bitwarden/components"; +import { KeyService, BiometricsService, BiometricsCommands } from "@bitwarden/key-management"; +import { ConfigService } from "@bitwarden/services/config.service"; import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; @@ -47,15 +43,14 @@ describe("BiometricMessageHandlerService", () => { let keyService: MockProxy; let encryptService: MockProxy; let logService: MockProxy; + let configService: MockProxy; let messagingService: MockProxy; let desktopSettingsService: DesktopSettingsService; - let biometricStateService: BiometricStateService; let biometricsService: MockProxy; let dialogService: MockProxy; let accountService: AccountService; let authService: MockProxy; let ngZone: MockProxy; - let i18nService: MockProxy; beforeEach(() => { cryptoFunctionService = mock(); @@ -64,14 +59,13 @@ describe("BiometricMessageHandlerService", () => { logService = mock(); messagingService = mock(); desktopSettingsService = mock(); - biometricStateService = mock(); + configService = mock(); biometricsService = mock(); dialogService = mock(); accountService = new FakeAccountService(accounts); authService = mock(); ngZone = mock(); - i18nService = mock(); desktopSettingsService.browserIntegrationEnabled$ = of(false); desktopSettingsService.browserIntegrationFingerprintEnabled$ = of(false); @@ -94,7 +88,7 @@ describe("BiometricMessageHandlerService", () => { cryptoFunctionService.rsaEncrypt.mockResolvedValue( Utils.fromUtf8ToArray("encrypted") as CsprngArray, ); - + configService.getFeatureFlag.mockResolvedValue(false); service = new BiometricMessageHandlerService( cryptoFunctionService, keyService, @@ -102,13 +96,12 @@ describe("BiometricMessageHandlerService", () => { logService, messagingService, desktopSettingsService, - biometricStateService, biometricsService, dialogService, accountService, authService, ngZone, - i18nService, + configService, ); }); @@ -160,13 +153,12 @@ describe("BiometricMessageHandlerService", () => { logService, messagingService, desktopSettingsService, - biometricStateService, biometricsService, dialogService, accountService, authService, ngZone, - i18nService, + configService, ); }); diff --git a/apps/desktop/src/services/biometric-message-handler.service.ts b/apps/desktop/src/services/biometric-message-handler.service.ts index 8b4c3744a8d..ca4ea14c1e0 100644 --- a/apps/desktop/src/services/biometric-message-handler.service.ts +++ b/apps/desktop/src/services/biometric-message-handler.service.ts @@ -4,25 +4,21 @@ import { combineLatest, concatMap, firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; -import { - BiometricStateService, - BiometricsCommands, - BiometricsService, - BiometricsStatus, - KeyService, -} from "@bitwarden/key-management"; +import { BiometricsCommands, BiometricsStatus, KeyService } from "@bitwarden/key-management"; import { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component"; +import { DesktopBiometricsService } from "../key-management/biometrics/desktop.biometrics.service"; import { LegacyMessage, LegacyMessageWrapper } from "../models/native-messaging"; import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; @@ -82,13 +78,12 @@ export class BiometricMessageHandlerService { private logService: LogService, private messagingService: MessagingService, private desktopSettingService: DesktopSettingsService, - private biometricStateService: BiometricStateService, - private biometricsService: BiometricsService, + private biometricsService: DesktopBiometricsService, private dialogService: DialogService, private accountService: AccountService, private authService: AuthService, private ngZone: NgZone, - private i18nService: I18nService, + private configService: ConfigService, ) { combineLatest([ this.desktopSettingService.browserIntegrationEnabled$, @@ -119,6 +114,17 @@ export class BiometricMessageHandlerService { private connectedApps: ConnectedApps = new ConnectedApps(); + async init() { + this.logService.debug( + "[BiometricMessageHandlerService] Initializing biometric message handler", + ); + + const linuxV2Enabled = await this.configService.getFeatureFlag(FeatureFlag.LinuxBiometricsV2); + if (linuxV2Enabled) { + await this.biometricsService.enableLinuxV2Biometrics(); + } + } + async handleMessage(msg: LegacyMessageWrapper) { const { appId, message: rawMessage } = msg as LegacyMessageWrapper; diff --git a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts index 3b33116ea5a..1eee4cd54f6 100644 --- a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts +++ b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts @@ -1,20 +1,31 @@ import { TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; +import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { DialogService } from "@bitwarden/components"; import { DesktopPremiumUpgradePromptService } from "./desktop-premium-upgrade-prompt.service"; describe("DesktopPremiumUpgradePromptService", () => { let service: DesktopPremiumUpgradePromptService; let messager: MockProxy; + let configService: MockProxy; + let dialogService: MockProxy; beforeEach(async () => { messager = mock(); + configService = mock(); + dialogService = mock(); + await TestBed.configureTestingModule({ providers: [ DesktopPremiumUpgradePromptService, { provide: MessagingService, useValue: messager }, + { provide: ConfigService, useValue: configService }, + { provide: DialogService, useValue: dialogService }, ], }).compileComponents(); @@ -22,9 +33,38 @@ describe("DesktopPremiumUpgradePromptService", () => { }); describe("promptForPremium", () => { - it("navigates to the premium update screen", async () => { + let openSpy: jest.SpyInstance; + + beforeEach(() => { + openSpy = jest.spyOn(PremiumUpgradeDialogComponent, "open").mockImplementation(); + }); + + afterEach(() => { + openSpy.mockRestore(); + }); + + it("opens the new premium upgrade dialog when feature flag is enabled", async () => { + configService.getFeatureFlag.mockResolvedValue(true); + await service.promptForPremium(); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); + expect(openSpy).toHaveBeenCalledWith(dialogService); + expect(messager.send).not.toHaveBeenCalled(); + }); + + it("sends openPremium message when feature flag is disabled", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + await service.promptForPremium(); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); expect(messager.send).toHaveBeenCalledWith("openPremium"); + expect(openSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts index f2375ecfebb..5004e5ed547 100644 --- a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts +++ b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts @@ -1,15 +1,29 @@ import { inject } from "@angular/core"; +import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { DialogService } from "@bitwarden/components"; /** * This class handles the premium upgrade process for the desktop. */ export class DesktopPremiumUpgradePromptService implements PremiumUpgradePromptService { private messagingService = inject(MessagingService); + private configService = inject(ConfigService); + private dialogService = inject(DialogService); async promptForPremium() { - this.messagingService.send("openPremium"); + const showNewDialog = await this.configService.getFeatureFlag( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); + + if (showNewDialog) { + PremiumUpgradeDialogComponent.open(this.dialogService); + } else { + this.messagingService.send("openPremium"); + } } } diff --git a/apps/desktop/src/types/biometric-message.ts b/apps/desktop/src/types/biometric-message.ts index 9711b49496d..7ae20945e96 100644 --- a/apps/desktop/src/types/biometric-message.ts +++ b/apps/desktop/src/types/biometric-message.ts @@ -13,6 +13,12 @@ export enum BiometricAction { GetShouldAutoprompt = "getShouldAutoprompt", SetShouldAutoprompt = "setShouldAutoprompt", + + EnrollPersistent = "enrollPersistent", + HasPersistentKey = "hasPersistentKey", + + EnableLinuxV2 = "enableLinuxV2", + IsLinuxV2Enabled = "isLinuxV2Enabled", } export type BiometricMessage = @@ -22,7 +28,15 @@ export type BiometricMessage = key: string; } | { - action: Exclude; + action: BiometricAction.EnrollPersistent; + userId: string; + key: string; + } + | { + action: Exclude< + BiometricAction, + BiometricAction.SetKeyForUser | BiometricAction.EnrollPersistent + >; userId?: string; data?: any; }; diff --git a/apps/desktop/src/utils.ts b/apps/desktop/src/utils.ts index ba8c1a2dba6..0f186060aae 100644 --- a/apps/desktop/src/utils.ts +++ b/apps/desktop/src/utils.ts @@ -53,7 +53,8 @@ export function isWindowsStore() { if ( windows && !windowsStore && - process.resourcesPath?.indexOf("8bitSolutionsLLC.bitwardendesktop_") > -1 + (process.resourcesPath?.indexOf("8bitSolutionsLLC.bitwardendesktop_") > -1 || + process.resourcesPath?.indexOf("8bitSolutionsLLC.BitwardenBeta_") > -1) ) { windowsStore = true; } @@ -69,8 +70,7 @@ export function isWindowsPortable() { } /** - * We block the browser integration on some unsupported platforms, which also - * blocks partially supported platforms (mac .dmg in dev builds) / prevents + * We block the browser integration on some unsupported platforms prevents * experimenting with the feature for QA. So this env var allows overriding * the block. */ diff --git a/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.ts b/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.ts index d81f1662c6c..5af1f96a569 100644 --- a/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.ts +++ b/apps/desktop/src/vault/app/vault/assign-collections/assign-collections-desktop.component.ts @@ -10,6 +10,8 @@ import { CollectionAssignmentResult, } from "@bitwarden/vault"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ standalone: true, templateUrl: "./assign-collections-desktop.component.html", diff --git a/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.ts b/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.ts index 26349920106..775ef55b3eb 100644 --- a/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.ts +++ b/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.ts @@ -39,6 +39,8 @@ export const CredentialGeneratorDialogAction = { type CredentialGeneratorDialogAction = UnionOfValues; +// 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: "credential-generator-dialog", templateUrl: "credential-generator-dialog.component.html", diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.html b/apps/desktop/src/vault/app/vault/item-footer.component.html index 162335d03bb..859b2f1bdc5 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.html +++ b/apps/desktop/src/vault/app/vault/item-footer.component.html @@ -46,7 +46,23 @@ -
    +
    + + +
  • + + + + @if (!(canArchive$ | async)) { + + } +
  • { + let component: StatusFilterComponent; + let fixture: ComponentFixture; + let cipherArchiveService: jest.Mocked; + let accountService: FakeAccountService; + + const mockUserId = Utils.newGuid() as UserId; + const event = new Event("click"); + + beforeEach(async () => { + accountService = mockAccountServiceWith(mockUserId); + cipherArchiveService = mock(); + + await TestBed.configureTestingModule({ + declarations: [StatusFilterComponent], + providers: [ + { provide: AccountService, useValue: accountService }, + { provide: CipherArchiveService, useValue: cipherArchiveService }, + { provide: PremiumUpgradePromptService, useValue: mock() }, + { + provide: BillingAccountProfileStateService, + useValue: mock(), + }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + ], + imports: [JslibModule, PremiumBadgeComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(StatusFilterComponent); + component = fixture.componentInstance; + component.activeFilter = new VaultFilter(); + fixture.detectChanges(); + }); + + describe("handleArchiveFilter", () => { + const applyFilter = jest.fn(); + let promptForPremiumSpy: jest.SpyInstance; + + beforeEach(() => { + applyFilter.mockClear(); + component["applyFilter"] = applyFilter; + + promptForPremiumSpy = jest.spyOn(component["premiumBadgeComponent"]()!, "promptForPremium"); + }); + + it("should apply archive filter when userCanArchive returns true", async () => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(true)); + cipherArchiveService.archivedCiphers$.mockReturnValue(of([])); + + await component["handleArchiveFilter"](event); + + expect(applyFilter).toHaveBeenCalledWith("archive"); + expect(promptForPremiumSpy).not.toHaveBeenCalled(); + }); + + it("should apply archive filter when userCanArchive returns false but hasArchivedCiphers is true", async () => { + const mockCipher = new CipherView(); + mockCipher.id = "test-id"; + + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + cipherArchiveService.archivedCiphers$.mockReturnValue(of([mockCipher])); + + await component["handleArchiveFilter"](event); + + expect(applyFilter).toHaveBeenCalledWith("archive"); + expect(promptForPremiumSpy).not.toHaveBeenCalled(); + }); + + it("should prompt for premium when userCanArchive returns false and hasArchivedCiphers is false", async () => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + cipherArchiveService.archivedCiphers$.mockReturnValue(of([])); + + await component["handleArchiveFilter"](event); + + expect(applyFilter).not.toHaveBeenCalled(); + expect(promptForPremiumSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.ts b/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.ts index 276b11d7138..95ffd3f0212 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.ts @@ -1,10 +1,51 @@ -import { Component } from "@angular/core"; +import { Component, viewChild } from "@angular/core"; +import { combineLatest, firstValueFrom, map, switchMap } from "rxjs"; +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { StatusFilterComponent as BaseStatusFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/status-filter.component"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.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: "app-status-filter", templateUrl: "status-filter.component.html", standalone: false, }) -export class StatusFilterComponent extends BaseStatusFilterComponent {} +export class StatusFilterComponent extends BaseStatusFilterComponent { + private readonly premiumBadgeComponent = viewChild(PremiumBadgeComponent); + + private userId$ = this.accountService.activeAccount$.pipe(getUserId); + protected canArchive$ = this.userId$.pipe( + switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)), + ); + + protected hasArchivedCiphers$ = this.userId$.pipe( + switchMap((userId) => + this.cipherArchiveService.archivedCiphers$(userId).pipe(map((ciphers) => ciphers.length > 0)), + ), + ); + + constructor( + private accountService: AccountService, + private cipherArchiveService: CipherArchiveService, + ) { + super(); + } + + protected async handleArchiveFilter(event: Event) { + const [canArchive, hasArchivedCiphers] = await firstValueFrom( + combineLatest([this.canArchive$, this.hasArchivedCiphers$]), + ); + + if (canArchive || hasArchivedCiphers) { + this.applyFilter("archive"); + } else if (this.premiumBadgeComponent()) { + // The `premiumBadgeComponent` should always be defined here, adding the + // if to satisfy TypeScript. + await this.premiumBadgeComponent().promptForPremium(event); + } + } +} diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.ts b/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.ts index 27e7d5c5ecb..fbab7ce4667 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/type-filter.component.ts @@ -5,6 +5,8 @@ import { TypeFilterComponent as BaseTypeFilterComponent } from "@bitwarden/angul import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; +// 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-type-filter", templateUrl: "type-filter.component.html", diff --git a/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.component.html b/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.component.html index 0664e0591ad..14e72f3bb9d 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.component.html +++ b/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.component.html @@ -17,6 +17,7 @@ class="filter" [hideFavorites]="hideFavorites" [hideTrash]="hideTrash" + [hideArchive]="!showArchiveVaultFilter" [activeFilter]="activeFilter" (onFilterChange)="applyFilter($event)" > diff --git a/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.component.ts b/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.component.ts index 161d22687e8..d7c5bafc3a4 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.component.ts @@ -2,6 +2,8 @@ import { Component } from "@angular/core"; import { VaultFilterComponent as BaseVaultFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/vault-filter.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-vault-filter", templateUrl: "vault-filter.component.html", diff --git a/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.module.ts b/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.module.ts index 8729996c835..54a6d33ca6a 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.module.ts +++ b/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.module.ts @@ -1,6 +1,7 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { DeprecatedVaultFilterService as DeprecatedVaultFilterServiceAbstraction } from "@bitwarden/angular/vault/abstractions/deprecated-vault-filter.service"; import { VaultFilterService } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; @@ -13,7 +14,7 @@ import { TypeFilterComponent } from "./filters/type-filter.component"; import { VaultFilterComponent } from "./vault-filter.component"; @NgModule({ - imports: [CommonModule, JslibModule], + imports: [CommonModule, JslibModule, PremiumBadgeComponent], declarations: [ VaultFilterComponent, CollectionFilterComponent, diff --git a/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts index 06654fb1a5c..d312d49277a 100644 --- a/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts @@ -7,6 +7,7 @@ import { distinctUntilChanged, debounceTime } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angular/vault/components/vault-items.component"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; @@ -20,6 +21,8 @@ import { MenuModule } from "@bitwarden/components"; import { SearchBarService } from "../../../app/layout/search/search-bar.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: "app-vault-items-v2", templateUrl: "vault-items-v2.component.html", @@ -33,8 +36,9 @@ export class VaultItemsV2Component extends BaseVaultIt cipherService: CipherService, accountService: AccountService, restrictedItemTypesService: RestrictedItemTypesService, + configService: ConfigService, ) { - super(searchService, cipherService, accountService, restrictedItemTypesService); + super(searchService, cipherService, accountService, restrictedItemTypesService, configService); this.searchBarService.searchText$ .pipe(debounceTime(SearchTextDebounceInterval), distinctUntilChanged(), takeUntilDestroyed()) diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.html b/apps/desktop/src/vault/app/vault/vault-v2.component.html index a3f55f0ec63..2696dd0d452 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.html +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.html @@ -19,6 +19,7 @@ (onClone)="cloneCipher($event)" (onDelete)="deleteCipher()" (onCancel)="cancelCipher($event)" + (onArchiveToggle)="refreshCurrentCipher()" [masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId" >
    diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index 5a6683ed904..6c4ebe13f14 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -20,12 +20,13 @@ import { AuthRequestServiceAbstraction } from "@bitwarden/auth/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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -34,6 +35,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { getByIds } from "@bitwarden/common/platform/misc"; import { SyncService } from "@bitwarden/common/platform/sync"; import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; @@ -46,6 +48,7 @@ import { CipherViewLike, CipherViewLikeUtils, } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { BadgeModule, ButtonModule, @@ -74,6 +77,7 @@ import { DefaultCipherFormConfigService, PasswordRepromptService, CipherFormComponent, + ArchiveCipherUtilitiesService, } from "@bitwarden/vault"; import { NavComponent } from "../../../app/layout/nav.component"; @@ -90,6 +94,8 @@ import { VaultItemsV2Component } from "./vault-items-v2.component"; const BroadcasterSubscriptionId = "VaultComponent"; +// 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-vault", templateUrl: "vault-v2.component.html", @@ -134,12 +140,20 @@ const BroadcasterSubscriptionId = "VaultComponent"; export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(VaultItemsV2Component, { static: true }) vaultItemsComponent: VaultItemsV2Component | null = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(VaultFilterComponent, { static: true }) vaultFilterComponent: VaultFilterComponent | null = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("folderAddEdit", { read: ViewContainerRef, static: true }) folderAddEditModalRef: ViewContainerRef | null = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(CipherFormComponent) cipherFormComponent: CipherFormComponent | null = null; @@ -169,6 +183,7 @@ export class VaultV2Component private organizations$: Observable = this.accountService.activeAccount$.pipe( map((a) => a?.id), + filterOutNullish(), switchMap((id) => this.organizationService.organizations$(id)), ); @@ -210,6 +225,9 @@ export class VaultV2Component private folderService: FolderService, private configService: ConfigService, private authRequestService: AuthRequestServiceAbstraction, + private cipherArchiveService: CipherArchiveService, + private policyService: PolicyService, + private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService, ) {} async ngOnInit() { @@ -291,7 +309,7 @@ export class VaultV2Component ) { const value = await firstValueFrom( this.totpService.getCode$(this.cipher.login.totp), - ).catch(() => null); + ).catch((): any => null); if (value) { this.copyValue(this.cipher, value.code, "verificationCodeTotp", "TOTP"); } @@ -319,31 +337,18 @@ export class VaultV2Component this.searchBarService.setEnabled(true); this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault")); - if ( - (await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.PM14938_BrowserExtensionLoginApproval), - )) === true - ) { - const authRequests = await firstValueFrom( - this.authRequestService.getLatestPendingAuthRequest$(), - ); - if (authRequests != null) { - this.messagingService.send("openLoginApproval", { - notificationId: authRequests.id, - }); - } - } else { - const authRequest = await this.apiService.getLastAuthRequest(); - if (authRequest != null) { - this.messagingService.send("openLoginApproval", { - notificationId: authRequest.id, - }); - } + const authRequests = await firstValueFrom( + this.authRequestService.getLatestPendingAuthRequest$()!, + ); + if (authRequests != null) { + this.messagingService.send("openLoginApproval", { + notificationId: authRequests.id, + }); } this.activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(getUserId), - ).catch(() => null); + ).catch((): any => null); if (this.activeUserId) { this.cipherService @@ -462,7 +467,7 @@ export class VaultV2Component const dialogRef = AttachmentsV2Component.open(this.dialogService, { cipherId: this.cipherId as CipherId, }); - const result = await firstValueFrom(dialogRef.closed).catch(() => null); + const result = await firstValueFrom(dialogRef.closed).catch((): any => null); if ( result?.action === AttachmentDialogResult.Removed || result?.action === AttachmentDialogResult.Uploaded @@ -502,6 +507,12 @@ export class VaultV2Component async viewCipherMenu(c: CipherViewLike) { const cipher = await this.cipherService.getFullCipherView(c); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const userCanArchive = await firstValueFrom(this.cipherArchiveService.userCanArchive$(userId)); + const orgOwnershipPolicy = await firstValueFrom( + this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId), + ); + const menu: RendererMenuItem[] = [ { label: this.i18nService.t("view"), @@ -526,7 +537,11 @@ export class VaultV2Component }); }, }); - if (!cipher.organizationId) { + + const archivedWithOrgOwnership = cipher.isArchived && orgOwnershipPolicy; + const canCloneArchived = !cipher.isArchived || userCanArchive; + + if (!cipher.organizationId && !archivedWithOrgOwnership && canCloneArchived) { menu.push({ label: this.i18nService.t("clone"), click: () => { @@ -550,6 +565,31 @@ export class VaultV2Component } } + if (!cipher.organizationId && !cipher.isDeleted && !cipher.isArchived) { + menu.push({ + label: this.i18nService.t("archiveVerb"), + click: async () => { + if (!userCanArchive) { + await this.premiumUpgradePromptService.promptForPremium(); + return; + } + + await this.archiveCipherUtilitiesService.archiveCipher(cipher); + await this.refreshCurrentCipher(); + }, + }); + } + + if (cipher.isArchived) { + menu.push({ + label: this.i18nService.t("unArchive"), + click: async () => { + await this.archiveCipherUtilitiesService.unarchiveCipher(cipher); + await this.refreshCurrentCipher(); + }, + }); + } + switch (cipher.type) { case CipherType.Login: if ( @@ -588,7 +628,7 @@ export class VaultV2Component click: async () => { const value = await firstValueFrom( this.totpService.getCode$(cipher.login.totp), - ).catch(() => null); + ).catch((): any => null); if (value) { this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP"); } @@ -631,7 +671,7 @@ export class VaultV2Component async buildFormConfig(action: CipherFormMode) { this.config = await this.formConfigService .buildConfig(action, this.cipherId as CipherId, this.addType) - .catch(() => null); + .catch((): any => null); } async editCipher(cipher: CipherView) { @@ -735,10 +775,6 @@ export class VaultV2Component this.cipherId = cipher.id; this.cipher = cipher; - if (this.activeUserId) { - await this.cipherService.clearCache(this.activeUserId).catch(() => {}); - } - await this.vaultItemsComponent?.load(this.activeFilter.buildFilter()).catch(() => {}); await this.go().catch(() => {}); await this.vaultItemsComponent?.refresh().catch(() => {}); } @@ -761,7 +797,7 @@ export class VaultV2Component async cancelCipher(cipher: CipherView) { this.cipherId = cipher.id; this.cipher = cipher; - this.action = this.cipherId != null ? "view" : null; + this.action = this.cipherId ? "view" : null; await this.go().catch(() => {}); } @@ -771,7 +807,11 @@ export class VaultV2Component ); this.activeFilter = vaultFilter; await this.vaultItemsComponent - ?.reload(this.activeFilter.buildFilter(), vaultFilter.status === "trash") + ?.reload( + this.activeFilter.buildFilter(), + vaultFilter.status === "trash", + vaultFilter.status === "archive", + ) .catch(() => {}); await this.go().catch(() => {}); } @@ -845,6 +885,20 @@ export class VaultV2Component } } + /** Refresh the current cipher object */ + protected async refreshCurrentCipher() { + if (!this.cipher) { + return; + } + + this.cipher = await firstValueFrom( + this.cipherService.cipherViews$(this.activeUserId!).pipe( + filter((c) => !!c), + map((ciphers) => ciphers.find((c) => c.id === this.cipherId) ?? null), + ), + ); + } + private dirtyInput(): boolean { return ( (this.action === "add" || this.action === "edit" || this.action === "clone") && diff --git a/apps/desktop/tailwind.config.js b/apps/desktop/tailwind.config.js index bf65ae8d7cb..e67c0c38010 100644 --- a/apps/desktop/tailwind.config.js +++ b/apps/desktop/tailwind.config.js @@ -9,6 +9,7 @@ config.content = [ "../../libs/key-management-ui/src/**/*.{html,ts}", "../../libs/angular/src/**/*.{html,ts}", "../../libs/vault/src/**/*.{html,ts,mdx}", + "../../libs/pricing/src/**/*.{html,ts}", ]; module.exports = config; diff --git a/apps/desktop/tsconfig.spec.json b/apps/desktop/tsconfig.spec.json index d52d889aa78..e6627a8ce45 100644 --- a/apps/desktop/tsconfig.spec.json +++ b/apps/desktop/tsconfig.spec.json @@ -4,5 +4,6 @@ "isolatedModules": true, "emitDecoratorMetadata": false }, - "files": ["./test.setup.ts"] + "files": ["./test.setup.ts"], + "include": ["src/**/*.spec.ts"] } diff --git a/apps/desktop/webpack.base.js b/apps/desktop/webpack.base.js new file mode 100644 index 00000000000..c9da84cd2e1 --- /dev/null +++ b/apps/desktop/webpack.base.js @@ -0,0 +1,333 @@ +const path = require("path"); +const webpack = require("webpack"); +const { merge } = require("webpack-merge"); +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const CopyWebpackPlugin = require("copy-webpack-plugin"); +const { AngularWebpackPlugin } = require("@ngtools/webpack"); +const TerserPlugin = require("terser-webpack-plugin"); +const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); +const { EnvironmentPlugin, DefinePlugin } = require("webpack"); +const configurator = require(path.resolve(__dirname, "config/config")); + +module.exports.getEnv = function getEnv() { + const NODE_ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV; + const ENV = process.env.ENV == null ? "development" : process.env.ENV; + + return { NODE_ENV, ENV }; +}; + +const DEFAULT_PARAMS = { + outputPath: process.env.OUTPUT_PATH + ? path.isAbsolute(process.env.OUTPUT_PATH) + ? process.env.OUTPUT_PATH + : path.resolve(__dirname, process.env.OUTPUT_PATH) + : path.resolve(__dirname, "build"), +}; + +/** + * @param {{ + * configName: string; + * renderer: { + * entry: string; + * entryModule: string; + * tsConfig: string; + * }; + * main: { + * entry: string; + * tsConfig: string; + * }; + * preload: { + * entry: string; + * tsConfig: string; + * }; + * outputPath?: string; + * }} params + */ +module.exports.buildConfig = function buildConfig(params) { + params = { ...DEFAULT_PARAMS, ...params }; + const { NODE_ENV, ENV } = module.exports.getEnv(); + + console.log(`Building ${params.configName} Desktop App`); + + const envConfig = configurator.load(NODE_ENV); + configurator.log(envConfig); + + const commonConfig = { + resolve: { + extensions: [".tsx", ".ts", ".js"], + symlinks: false, + modules: [ + path.resolve(__dirname, "../../node_modules"), + path.resolve(process.cwd(), "node_modules"), + ], + }, + }; + + const getOutputConfig = (isDev) => ({ + filename: "[name].js", + path: params.outputPath, + ...(isDev && { devtoolModuleFilenameTemplate: "[absolute-resource-path]" }), + }); + + const mainConfig = { + name: "main", + mode: NODE_ENV, + target: "electron-main", + node: { + __dirname: false, + __filename: false, + }, + entry: { + main: params.main.entry, + }, + optimization: { + minimize: false, + }, + output: getOutputConfig(NODE_ENV === "development"), + devtool: NODE_ENV === "development" ? "cheap-source-map" : false, + module: { + rules: [ + { + test: /\.tsx?$/, + use: "ts-loader", + exclude: /node_modules\/(?!(@bitwarden)\/).*/, + }, + { + test: /\.node$/, + loader: "node-loader", + }, + ], + }, + experiments: { + asyncWebAssembly: true, + }, + resolve: { + ...commonConfig.resolve, + plugins: [new TsconfigPathsPlugin({ configFile: params.main.tsConfig })], + }, + plugins: [ + new CopyWebpackPlugin({ + patterns: [ + path.resolve(__dirname, "src/package.json"), + { from: path.resolve(__dirname, "src/images"), to: "images" }, + { from: path.resolve(__dirname, "src/locales"), to: "locales" }, + ], + }), + new DefinePlugin({ + BIT_ENVIRONMENT: JSON.stringify(NODE_ENV), + }), + new EnvironmentPlugin({ + FLAGS: envConfig.flags, + DEV_FLAGS: NODE_ENV === "development" ? envConfig.devFlags : {}, + }), + ], + externals: { + "electron-reload": "commonjs2 electron-reload", + "@bitwarden/desktop-napi": "commonjs2 @bitwarden/desktop-napi", + }, + }; + + const preloadConfig = { + name: "preload", + mode: NODE_ENV, + target: "electron-preload", + node: { + __dirname: false, + __filename: false, + }, + entry: { + preload: params.preload.entry, + }, + optimization: { + minimize: false, + }, + output: getOutputConfig(NODE_ENV === "development"), + devtool: NODE_ENV === "development" ? "cheap-source-map" : false, + module: { + rules: [ + { + test: /\.tsx?$/, + use: "ts-loader", + exclude: /node_modules\/(?!(@bitwarden)\/).*/, + }, + ], + }, + resolve: { + ...commonConfig.resolve, + plugins: [new TsconfigPathsPlugin({ configFile: params.preload.tsConfig })], + }, + plugins: [ + new DefinePlugin({ + BIT_ENVIRONMENT: JSON.stringify(NODE_ENV), + }), + ], + }; + + const rendererConfig = { + name: "renderer", + mode: NODE_ENV, + devtool: "source-map", + target: "web", + node: { + __dirname: false, + }, + entry: { + "app/main": params.renderer.entry, + }, + output: { + filename: "[name].js", + path: params.outputPath, + }, + optimization: { + minimizer: [ + new TerserPlugin({ + terserOptions: { + // Replicate Angular CLI behaviour + compress: { + global_defs: { + ngDevMode: false, + ngI18nClosureMode: false, + }, + }, + }, + }), + ], + splitChunks: { + cacheGroups: { + commons: { + test: /[\\/]node_modules[\\/]/, + name: "app/vendor", + chunks: (chunk) => { + return chunk.name === "app/main"; + }, + }, + }, + }, + }, + module: { + rules: [ + { + test: /\.[cm]?js$/, + use: [ + { + loader: "babel-loader", + options: { + configFile: path.resolve(__dirname, "../../babel.config.json"), + }, + }, + ], + }, + { + test: /\.[jt]sx?$/, + loader: "@ngtools/webpack", + }, + { + test: /\.(html)$/, + loader: "html-loader", + }, + { + test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/, + exclude: /loading.svg/, + generator: { + filename: "fonts/[name].[contenthash][ext]", + }, + type: "asset/resource", + }, + { + test: /\.(jpe?g|png|gif|svg)$/i, + exclude: /.*(bwi-font)\.svg/, + generator: { + filename: "images/[name][ext]", + }, + type: "asset/resource", + }, + { + test: /\.css$/, + use: [ + { + loader: MiniCssExtractPlugin.loader, + }, + "css-loader", + "resolve-url-loader", + { + loader: "postcss-loader", + options: { + sourceMap: true, + }, + }, + ], + }, + { + test: /\.scss$/, + use: [ + { + loader: MiniCssExtractPlugin.loader, + options: { + publicPath: "../", + }, + }, + "css-loader", + "resolve-url-loader", + { + loader: "sass-loader", + options: { + sourceMap: true, + }, + }, + ], + }, + // Hide System.import warnings. ref: https://github.com/angular/angular/issues/21560 + { + test: /[\/\\]@angular[\/\\].+\.js$/, + parser: { system: true }, + }, + ], + }, + experiments: { + asyncWebAssembly: true, + }, + resolve: { + ...commonConfig.resolve, + fallback: { + path: require.resolve("path-browserify"), + fs: false, + }, + }, + plugins: [ + new AngularWebpackPlugin({ + tsConfigPath: params.renderer.tsConfig, + entryModule: params.renderer.entryModule, + sourceMap: true, + }), + // ref: https://github.com/angular/angular/issues/20357 + new webpack.ContextReplacementPlugin( + /\@angular(\\|\/)core(\\|\/)fesm5/, + path.resolve(__dirname, "./src"), + ), + new HtmlWebpackPlugin({ + template: path.resolve(__dirname, "src/index.html"), + filename: "index.html", + chunks: ["app/vendor", "app/main"], + }), + new webpack.SourceMapDevToolPlugin({ + include: ["app/main.js"], + }), + new MiniCssExtractPlugin({ + filename: "[name].[contenthash].css", + chunkFilename: "[id].[contenthash].css", + }), + new webpack.DefinePlugin({ + BIT_ENVIRONMENT: JSON.stringify(NODE_ENV), + }), + new webpack.EnvironmentPlugin({ + ENV: ENV, + FLAGS: envConfig.flags, + DEV_FLAGS: NODE_ENV === "development" ? envConfig.devFlags : {}, + ADDITIONAL_REGIONS: envConfig.additionalRegions ?? [], + }), + ], + }; + + return [mainConfig, rendererConfig, preloadConfig]; +}; diff --git a/apps/desktop/webpack.config.js b/apps/desktop/webpack.config.js new file mode 100644 index 00000000000..685196e56c0 --- /dev/null +++ b/apps/desktop/webpack.config.js @@ -0,0 +1,43 @@ +const path = require("path"); +const { buildConfig } = require("./webpack.base"); + +module.exports = (webpackConfig, context) => { + const isNxBuild = context && context.options; + + if (isNxBuild) { + return buildConfig({ + configName: "OSS", + renderer: { + entry: path.resolve(__dirname, "src/app/main.ts"), + entryModule: "src/app/app.module#AppModule", + tsConfig: path.resolve(context.context.root, "apps/desktop/tsconfig.renderer.json"), + }, + main: { + entry: path.resolve(__dirname, "src/entry.ts"), + tsConfig: path.resolve(context.context.root, "apps/desktop/tsconfig.json"), + }, + preload: { + entry: path.resolve(__dirname, "src/preload.ts"), + tsConfig: path.resolve(context.context.root, "apps/desktop/tsconfig.json"), + }, + outputPath: path.resolve(context.context.root, context.options.outputPath), + }); + } else { + return buildConfig({ + configName: "OSS", + renderer: { + entry: path.resolve(__dirname, "src/app/main.ts"), + entryModule: "src/app/app.module#AppModule", + tsConfig: path.resolve(__dirname, "tsconfig.renderer.json"), + }, + main: { + entry: path.resolve(__dirname, "src/entry.ts"), + tsConfig: path.resolve(__dirname, "tsconfig.json"), + }, + preload: { + entry: path.resolve(__dirname, "src/preload.ts"), + tsConfig: path.resolve(__dirname, "tsconfig.json"), + }, + }); + } +}; diff --git a/apps/desktop/webpack.main.js b/apps/desktop/webpack.main.js deleted file mode 100644 index 151b1d0cea2..00000000000 --- a/apps/desktop/webpack.main.js +++ /dev/null @@ -1,93 +0,0 @@ -const path = require("path"); -const { merge } = require("webpack-merge"); -const CopyWebpackPlugin = require("copy-webpack-plugin"); -const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); -const configurator = require("./config/config"); -const { EnvironmentPlugin, DefinePlugin } = require("webpack"); - -const NODE_ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV; - -console.log("Main process config"); -const envConfig = configurator.load(NODE_ENV); -configurator.log(envConfig); - -const common = { - module: { - rules: [ - { - test: /\.tsx?$/, - use: "ts-loader", - exclude: /node_modules\/(?!(@bitwarden)\/).*/, - }, - ], - }, - plugins: [], - resolve: { - extensions: [".tsx", ".ts", ".js"], - plugins: [new TsconfigPathsPlugin({ configFile: "./tsconfig.json" })], - }, -}; - -const prod = { - output: { - filename: "[name].js", - path: path.resolve(__dirname, "build"), - }, -}; - -const dev = { - output: { - filename: "[name].js", - path: path.resolve(__dirname, "build"), - devtoolModuleFilenameTemplate: "[absolute-resource-path]", - }, - devtool: "cheap-source-map", -}; - -const main = { - mode: NODE_ENV, - target: "electron-main", - node: { - __dirname: false, - __filename: false, - }, - entry: { - main: "./src/entry.ts", - }, - optimization: { - minimize: false, - }, - module: { - rules: [ - { - test: /\.node$/, - loader: "node-loader", - }, - ], - }, - experiments: { - asyncWebAssembly: true, - }, - plugins: [ - new CopyWebpackPlugin({ - patterns: [ - "./src/package.json", - { from: "./src/images", to: "images" }, - { from: "./src/locales", to: "locales" }, - ], - }), - new DefinePlugin({ - BIT_ENVIRONMENT: JSON.stringify(NODE_ENV), - }), - new EnvironmentPlugin({ - FLAGS: envConfig.flags, - DEV_FLAGS: NODE_ENV === "development" ? envConfig.devFlags : {}, - }), - ], - externals: { - "electron-reload": "commonjs2 electron-reload", - "@bitwarden/desktop-napi": "commonjs2 @bitwarden/desktop-napi", - }, -}; - -module.exports = merge(common, NODE_ENV === "development" ? dev : prod, main); diff --git a/apps/desktop/webpack.preload.js b/apps/desktop/webpack.preload.js deleted file mode 100644 index db75e882644..00000000000 --- a/apps/desktop/webpack.preload.js +++ /dev/null @@ -1,66 +0,0 @@ -const path = require("path"); -const { merge } = require("webpack-merge"); -const CopyWebpackPlugin = require("copy-webpack-plugin"); -const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); -const configurator = require("./config/config"); -const { EnvironmentPlugin, DefinePlugin } = require("webpack"); - -const NODE_ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV; - -console.log("Preload process config"); -const envConfig = configurator.load(NODE_ENV); -configurator.log(envConfig); - -const common = { - module: { - rules: [ - { - test: /\.tsx?$/, - use: "ts-loader", - exclude: /node_modules\/(?!(@bitwarden)\/).*/, - }, - ], - }, - plugins: [ - new DefinePlugin({ - BIT_ENVIRONMENT: JSON.stringify(NODE_ENV), - }), - ], - resolve: { - extensions: [".tsx", ".ts", ".js"], - plugins: [new TsconfigPathsPlugin({ configFile: "./tsconfig.json" })], - }, -}; - -const prod = { - output: { - filename: "[name].js", - path: path.resolve(__dirname, "build"), - }, -}; - -const dev = { - output: { - filename: "[name].js", - path: path.resolve(__dirname, "build"), - devtoolModuleFilenameTemplate: "[absolute-resource-path]", - }, - devtool: "cheap-source-map", -}; - -const main = { - mode: NODE_ENV, - target: "electron-preload", - node: { - __dirname: false, - __filename: false, - }, - entry: { - preload: "./src/preload.ts", - }, - optimization: { - minimize: false, - }, -}; - -module.exports = merge(common, NODE_ENV === "development" ? dev : prod, main); diff --git a/apps/desktop/webpack.renderer.js b/apps/desktop/webpack.renderer.js deleted file mode 100644 index 9c5b0fd2584..00000000000 --- a/apps/desktop/webpack.renderer.js +++ /dev/null @@ -1,192 +0,0 @@ -const path = require("path"); -const webpack = require("webpack"); -const { merge } = require("webpack-merge"); -const HtmlWebpackPlugin = require("html-webpack-plugin"); -const MiniCssExtractPlugin = require("mini-css-extract-plugin"); -const { AngularWebpackPlugin } = require("@ngtools/webpack"); -const TerserPlugin = require("terser-webpack-plugin"); -const configurator = require("./config/config"); - -const NODE_ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV; - -console.log("Renderer process config"); -const envConfig = configurator.load(NODE_ENV); -configurator.log(envConfig); - -const ENV = process.env.ENV == null ? "development" : process.env.ENV; - -const common = { - module: { - rules: [ - { - test: /\.[cm]?js$/, - use: [ - { - loader: "babel-loader", - options: { - configFile: "../../babel.config.json", - }, - }, - ], - }, - { - test: /\.[jt]sx?$/, - loader: "@ngtools/webpack", - }, - { - test: /\.(jpe?g|png|gif|svg)$/i, - exclude: /.*(bwi-font)\.svg/, - generator: { - filename: "images/[name][ext]", - }, - type: "asset/resource", - }, - ], - }, - plugins: [], - resolve: { - extensions: [".tsx", ".ts", ".js"], - symlinks: false, - modules: [path.resolve("../../node_modules")], - fallback: { - path: require.resolve("path-browserify"), - fs: false, - }, - }, - output: { - filename: "[name].js", - path: path.resolve(__dirname, "build"), - }, -}; - -const renderer = { - mode: NODE_ENV, - devtool: "source-map", - target: "web", - node: { - __dirname: false, - }, - entry: { - "app/main": "./src/app/main.ts", - }, - optimization: { - minimizer: [ - new TerserPlugin({ - terserOptions: { - // Replicate Angular CLI behaviour - compress: { - global_defs: { - ngDevMode: false, - ngI18nClosureMode: false, - }, - }, - }, - }), - ], - splitChunks: { - cacheGroups: { - commons: { - test: /[\\/]node_modules[\\/]/, - name: "app/vendor", - chunks: (chunk) => { - return chunk.name === "app/main"; - }, - }, - }, - }, - }, - module: { - rules: [ - { - test: /\.(html)$/, - loader: "html-loader", - }, - { - test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/, - exclude: /loading.svg/, - generator: { - filename: "fonts/[name].[contenthash][ext]", - }, - type: "asset/resource", - }, - { - test: /\.css$/, - use: [ - { - loader: MiniCssExtractPlugin.loader, - }, - "css-loader", - "resolve-url-loader", - { - loader: "postcss-loader", - options: { - sourceMap: true, - }, - }, - ], - }, - { - test: /\.scss$/, - use: [ - { - loader: MiniCssExtractPlugin.loader, - options: { - publicPath: "../", - }, - }, - "css-loader", - "resolve-url-loader", - { - loader: "sass-loader", - options: { - sourceMap: true, - }, - }, - ], - }, - // Hide System.import warnings. ref: https://github.com/angular/angular/issues/21560 - { - test: /[\/\\]@angular[\/\\].+\.js$/, - parser: { system: true }, - }, - ], - }, - experiments: { - asyncWebAssembly: true, - }, - plugins: [ - new AngularWebpackPlugin({ - tsConfigPath: "tsconfig.renderer.json", - entryModule: "src/app/app.module#AppModule", - sourceMap: true, - }), - // ref: https://github.com/angular/angular/issues/20357 - new webpack.ContextReplacementPlugin( - /\@angular(\\|\/)core(\\|\/)fesm5/, - path.resolve(__dirname, "./src"), - ), - new HtmlWebpackPlugin({ - template: "./src/index.html", - filename: "index.html", - chunks: ["app/vendor", "app/main"], - }), - new webpack.SourceMapDevToolPlugin({ - include: ["app/main.js"], - }), - new MiniCssExtractPlugin({ - filename: "[name].[contenthash].css", - chunkFilename: "[id].[contenthash].css", - }), - new webpack.DefinePlugin({ - BIT_ENVIRONMENT: JSON.stringify(NODE_ENV), - }), - new webpack.EnvironmentPlugin({ - ENV: ENV, - FLAGS: envConfig.flags, - DEV_FLAGS: NODE_ENV === "development" ? envConfig.devFlags : {}, - ADDITIONAL_REGIONS: envConfig.additionalRegions ?? [], - }), - ], -}; - -module.exports = merge(common, renderer); diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md new file mode 100644 index 00000000000..b9fe0055fe5 --- /dev/null +++ b/apps/web/CLAUDE.md @@ -0,0 +1,13 @@ +# Web Vault - Critical Rules + +- **NEVER** access browser extension APIs + - Web vault runs in standard browser context (no chrome._/browser._ APIs) + - DON'T import or use BrowserApi or extension-specific code + +- **ALWAYS** assume multi-tenant organization features + - Web vault supports enterprise organizations with complex permissions + - Use organization permission guards: `/apps/web/src/app/admin-console/organizations/guards/` + +- **CRITICAL**: All sensitive operations must work without local storage + - Web vault may run in environments that clear storage aggressively + - DON'T rely on localStorage/sessionStorage for security-critical data diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 6017d60df5f..6d27e12537a 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -9,6 +9,12 @@ COPY package*.json ./ COPY . . RUN npm ci +# Remove commercial packages if LICENSE_TYPE is not 'commercial' +ARG LICENSE_TYPE=oss +RUN if [ "${LICENSE_TYPE}" != "commercial" ] ; then \ + rm -rf node_modules/@bitwarden/commercial-sdk-internal ; \ + fi + WORKDIR /source/apps/web ARG NPM_COMMAND=dist:bit:selfhost RUN npm run ${NPM_COMMAND} diff --git a/apps/web/config/development.json b/apps/web/config/development.json index d80a56ff671..3a6015dae7e 100644 --- a/apps/web/config/development.json +++ b/apps/web/config/development.json @@ -8,6 +8,7 @@ "proxyIdentity": "http://localhost:33656", "proxyEvents": "http://localhost:46273", "proxyNotifications": "http://localhost:61840", + "proxyIcons": "http://localhost:50024", "proxySeederApi": "http://localhost:5047", "wsConnectSrc": "ws://localhost:61840" }, diff --git a/apps/web/config/selfhosted.json b/apps/web/config/selfhosted.json index cd36ab15c5e..ffb7621e594 100644 --- a/apps/web/config/selfhosted.json +++ b/apps/web/config/selfhosted.json @@ -4,6 +4,7 @@ "proxyIdentity": "http://localhost:33657", "proxyEvents": "http://localhost:46274", "proxyNotifications": "http://localhost:61841", + "proxyKeyConnector": "http://localhost:5000", "port": 8081 }, "flags": {} diff --git a/apps/web/package.json b/apps/web/package.json index 517b8aa8004..344a78f2a2c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2025.9.1", + "version": "2025.12.0", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js index 5657df3afcf..0403fe4061e 100644 --- a/apps/web/postcss.config.js +++ b/apps/web/postcss.config.js @@ -1,9 +1,13 @@ /* eslint-disable @typescript-eslint/no-require-imports */ +const path = require("path"); + module.exports = { plugins: [ - require("postcss-import"), + require("postcss-import")({ + path: [path.resolve(__dirname, "../../libs"), path.resolve(__dirname, "src/scss")], + }), require("postcss-nested"), - require("tailwindcss"), + require("tailwindcss")({ config: path.resolve(__dirname, "tailwind.config.js") }), require("autoprefixer"), ], }; diff --git a/apps/web/project.json b/apps/web/project.json new file mode 100644 index 00000000000..710fd7cb5e7 --- /dev/null +++ b/apps/web/project.json @@ -0,0 +1,185 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "web", + "projectType": "application", + "sourceRoot": "apps/web/src", + "tags": ["scope:web", "type:app"], + "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "oss", + "options": { + "outputPath": "dist/apps/web", + "webpackConfig": "apps/web/webpack.config.js", + "tsConfig": "apps/web/tsconfig.json", + "main": "apps/web/src/main.ts", + "target": "web", + "compiler": "tsc" + }, + "configurations": { + "oss": { + "mode": "production", + "outputPath": "dist/apps/web/oss" + }, + "oss-dev": { + "mode": "development", + "outputPath": "dist/apps/web/oss-dev", + "env": { + "NODE_ENV": "development", + "ENV": "development" + } + }, + "commercial": { + "mode": "production", + "outputPath": "dist/apps/web/commercial", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts" + }, + "commercial-dev": { + "mode": "development", + "outputPath": "dist/apps/web/commercial-dev", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "NODE_ENV": "development", + "ENV": "development" + } + }, + "commercial-qa": { + "mode": "production", + "outputPath": "dist/apps/web/commercial-qa", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "NODE_ENV": "production", + "ENV": "qa" + } + }, + "commercial-cloud": { + "mode": "production", + "outputPath": "dist/apps/web/commercial-cloud", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "NODE_ENV": "production", + "ENV": "cloud" + } + }, + "commercial-euprd": { + "mode": "production", + "outputPath": "dist/apps/web/commercial-euprd", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "NODE_ENV": "production", + "ENV": "euprd" + } + }, + "commercial-euqa": { + "mode": "production", + "outputPath": "dist/apps/web/commercial-euqa", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "NODE_ENV": "production", + "ENV": "euqa" + } + }, + "commercial-usdev": { + "mode": "production", + "outputPath": "dist/apps/web/commercial-usdev", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "NODE_ENV": "production", + "ENV": "usdev" + } + }, + "commercial-ee": { + "mode": "production", + "outputPath": "dist/apps/web/commercial-ee", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "NODE_ENV": "production", + "ENV": "ee" + } + }, + "oss-selfhost": { + "mode": "production", + "outputPath": "dist/apps/web/oss-selfhost", + "env": { + "ENV": "selfhosted", + "NODE_ENV": "production" + } + }, + "oss-selfhost-dev": { + "mode": "development", + "outputPath": "dist/apps/web/oss-selfhost-dev", + "env": { + "NODE_ENV": "development", + "ENV": "selfhosted" + } + }, + "commercial-selfhost": { + "mode": "production", + "outputPath": "dist/apps/web/commercial-selfhost", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "ENV": "selfhosted", + "NODE_ENV": "production" + } + }, + "commercial-selfhost-dev": { + "mode": "development", + "outputPath": "dist/apps/web/commercial-selfhost-dev", + "webpackConfig": "bitwarden_license/bit-web/webpack.config.js", + "main": "bitwarden_license/bit-web/src/main.ts", + "env": { + "NODE_ENV": "development", + "ENV": "selfhosted" + } + } + } + }, + "serve": { + "executor": "@nx/webpack:dev-server", + "defaultConfiguration": "oss-dev", + "options": { + "buildTarget": "web:build", + "host": "localhost", + "port": 8080 + }, + "configurations": { + "oss": { + "buildTarget": "web:build:oss-dev" + }, + "commercial": { + "buildTarget": "web:build:commercial-dev" + }, + "oss-selfhost": { + "buildTarget": "web:build:oss-selfhost-dev" + }, + "commercial-selfhost": { + "buildTarget": "web:build:commercial-selfhost-dev" + } + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/web/jest.config.js" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/web/**/*.ts", "apps/web/**/*.html"] + } + } + } +} diff --git a/apps/web/src/app/admin-console/common/base-members.component.ts b/apps/web/src/app/admin-console/common/base-members.component.ts index 21c52949254..5ecf4269a1a 100644 --- a/apps/web/src/app/admin-console/common/base-members.component.ts +++ b/apps/web/src/app/admin-console/common/base-members.component.ts @@ -24,6 +24,7 @@ import { KeyService } from "@bitwarden/key-management"; import { OrganizationUserView } from "../organizations/core/views/organization-user.view"; import { UserConfirmComponent } from "../organizations/manage/user-confirm.component"; +import { MemberActionResult } from "../organizations/members/services/member-actions/member-actions.service"; import { PeopleTableDataSource, peopleFilter } from "./people-table-data-source"; @@ -75,7 +76,7 @@ export abstract class BaseMembersComponent { /** * The currently executing promise - used to avoid multiple user actions executing at once. */ - actionPromise?: Promise; + actionPromise?: Promise; protected searchControl = new FormControl("", { nonNullable: true }); protected statusToggle = new BehaviorSubject(undefined); @@ -101,13 +102,13 @@ export abstract class BaseMembersComponent { abstract edit(user: UserView, organization?: Organization): void; abstract getUsers(organization?: Organization): Promise | UserView[]>; - abstract removeUser(id: string, organization?: Organization): Promise; - abstract reinviteUser(id: string, organization?: Organization): Promise; + abstract removeUser(id: string, organization?: Organization): Promise; + abstract reinviteUser(id: string, organization?: Organization): Promise; abstract confirmUser( user: UserView, publicKey: Uint8Array, organization?: Organization, - ): Promise; + ): Promise; abstract invite(organization?: Organization): void; async load(organization?: Organization) { @@ -140,12 +141,16 @@ export abstract class BaseMembersComponent { this.actionPromise = this.removeUser(user.id, organization); try { - await this.actionPromise; - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)), - }); - this.dataSource.removeUser(user); + const result = await this.actionPromise; + if (result.success) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("removedUserId", this.userNamePipe.transform(user)), + }); + this.dataSource.removeUser(user); + } else { + throw new Error(result.error); + } } catch (e) { this.validationService.showError(e); } @@ -159,11 +164,15 @@ export abstract class BaseMembersComponent { this.actionPromise = this.reinviteUser(user.id, organization); try { - await this.actionPromise; - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)), - }); + const result = await this.actionPromise; + if (result.success) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)), + }); + } else { + throw new Error(result.error); + } } catch (e) { this.validationService.showError(e); } @@ -174,14 +183,18 @@ export abstract class BaseMembersComponent { const confirmUser = async (publicKey: Uint8Array) => { try { this.actionPromise = this.confirmUser(user, publicKey, organization); - await this.actionPromise; - user.status = this.userStatusType.Confirmed; - this.dataSource.replaceUser(user); + const result = await this.actionPromise; + if (result.success) { + user.status = this.userStatusType.Confirmed; + this.dataSource.replaceUser(user); - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)), - }); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)), + }); + } else { + throw new Error(result.error); + } } catch (e) { this.validationService.showError(e); throw e; diff --git a/apps/web/src/app/admin-console/organizations/collections/bulk-collections-dialog/bulk-collections-dialog.component.ts b/apps/web/src/app/admin-console/organizations/collections/bulk-collections-dialog/bulk-collections-dialog.component.ts index 7c4e2156ffb..b8c82ac2f01 100644 --- a/apps/web/src/app/admin-console/organizations/collections/bulk-collections-dialog/bulk-collections-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/bulk-collections-dialog/bulk-collections-dialog.component.ts @@ -50,6 +50,8 @@ export enum BulkCollectionsDialogResult { Canceled = "canceled", } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ imports: [SharedModule, AccessSelectorModule], selector: "app-bulk-collections-dialog", diff --git a/apps/web/src/app/admin-console/organizations/collections/collection-access-restricted.component.ts b/apps/web/src/app/admin-console/organizations/collections/collection-access-restricted.component.ts index 86b83d75ca4..eafa3f4470a 100644 --- a/apps/web/src/app/admin-console/organizations/collections/collection-access-restricted.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/collection-access-restricted.component.ts @@ -6,6 +6,8 @@ import { ButtonModule, NoItemsModule } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; import { CollectionDialogTabType } from "../shared/components/collection-dialog"; +// 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: "collection-access-restricted", imports: [SharedModule, ButtonModule, NoItemsModule], @@ -37,9 +39,15 @@ export class CollectionAccessRestrictedComponent { protected icon = RestrictedView; protected collectionDialogTabType = CollectionDialogTabType; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() canEditCollection = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() canViewCollectionInfo = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() viewCollectionClicked = new EventEmitter<{ readonly: boolean; tab: CollectionDialogTabType; diff --git a/apps/web/src/app/admin-console/organizations/collections/collection-badge/collection-name.badge.component.ts b/apps/web/src/app/admin-console/organizations/collections/collection-badge/collection-name.badge.component.ts index d3893b5bd24..70a2e40001a 100644 --- a/apps/web/src/app/admin-console/organizations/collections/collection-badge/collection-name.badge.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/collection-badge/collection-name.badge.component.ts @@ -9,13 +9,19 @@ import { CollectionId } from "@bitwarden/sdk-internal"; import { SharedModule } from "../../../../shared/shared.module"; import { GetCollectionNameFromIdPipe } from "../pipes"; +// 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-collection-badge", templateUrl: "collection-name-badge.component.html", imports: [SharedModule, GetCollectionNameFromIdPipe], }) export class CollectionNameBadgeComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() collectionIds: CollectionId[] | string[]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() collections: CollectionView[]; get shownCollections(): string[] { diff --git a/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.html b/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.html deleted file mode 100644 index 326dc627e17..00000000000 --- a/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.html +++ /dev/null @@ -1,121 +0,0 @@ -@if (organization) { - - - - -} - - - -
    -
    - -
    -
    - - - {{ "all" | i18n }} - - - - {{ "addAccess" | i18n }} - - - - {{ trashCleanupWarning }} - - - - - - {{ "noItemsInList" | i18n }} - - - - - -
    - - {{ "loading" | i18n }} -
    -
    -
    diff --git a/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.ts deleted file mode 100644 index fce2827c073..00000000000 --- a/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.ts +++ /dev/null @@ -1,1389 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core"; -import { ActivatedRoute, Params, Router } from "@angular/router"; -import { - BehaviorSubject, - combineLatest, - firstValueFrom, - lastValueFrom, - merge, - Observable, - Subject, -} from "rxjs"; -import { - concatMap, - debounceTime, - distinctUntilChanged, - filter, - first, - map, - shareReplay, - switchMap, - takeUntil, - tap, -} from "rxjs/operators"; - -import { - CollectionAdminService, - CollectionAdminView, - CollectionService, - CollectionView, - Unassigned, -} from "@bitwarden/admin-console/common"; -import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; -import { NoResults } from "@bitwarden/assets/svg"; -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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; -import { EventType } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SyncService } from "@bitwarden/common/platform/sync"; -import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; -import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; -import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; -import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; -import { - BannerModule, - DialogRef, - DialogService, - NoItemsModule, - ToastService, -} from "@bitwarden/components"; -import { - AttachmentDialogResult, - AttachmentsV2Component, - CipherFormConfig, - CipherFormConfigService, - CollectionAssignmentResult, - DecryptionFailureDialogComponent, - PasswordRepromptService, -} from "@bitwarden/vault"; -import { - OrganizationFreeTrialWarningComponent, - OrganizationResellerRenewalWarningComponent, -} from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; -import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; -import { VaultItemsComponent } from "@bitwarden/web-vault/app/vault/components/vault-items/vault-items.component"; - -import { SharedModule } from "../../../shared"; -import { AssignCollectionsWebComponent } from "../../../vault/components/assign-collections"; -import { - VaultItemDialogComponent, - VaultItemDialogMode, - VaultItemDialogResult, -} from "../../../vault/components/vault-item-dialog/vault-item-dialog.component"; -import { VaultItemEvent } from "../../../vault/components/vault-items/vault-item-event"; -import { VaultItemsModule } from "../../../vault/components/vault-items/vault-items.module"; -import { - BulkDeleteDialogResult, - openBulkDeleteDialog, -} from "../../../vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component"; -import { VaultFilterService } from "../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service"; -import { RoutedVaultFilterBridgeService } from "../../../vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service"; -import { RoutedVaultFilterService } from "../../../vault/individual-vault/vault-filter/services/routed-vault-filter.service"; -import { createFilterFunction } from "../../../vault/individual-vault/vault-filter/shared/models/filter-function"; -import { - All, - RoutedVaultFilterModel, -} from "../../../vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model"; -import { VaultFilter } from "../../../vault/individual-vault/vault-filter/shared/models/vault-filter.model"; -import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service"; -import { GroupApiService, GroupView } from "../core"; -import { openEntityEventsDialog } from "../manage/entity-events.component"; -import { - CollectionDialogAction, - CollectionDialogTabType, - openCollectionDialog, -} from "../shared/components/collection-dialog"; - -import { - BulkCollectionsDialogComponent, - BulkCollectionsDialogResult, -} from "./bulk-collections-dialog"; -import { CollectionAccessRestrictedComponent } from "./collection-access-restricted.component"; -import { getFlatCollectionTree, getNestedCollectionTree } from "./utils"; -import { VaultFilterModule } from "./vault-filter/vault-filter.module"; -import { VaultHeaderComponent } from "./vault-header/vault-header.component"; - -const BroadcasterSubscriptionId = "OrgVaultComponent"; -const SearchTextDebounceInterval = 200; - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -enum AddAccessStatusType { - All = 0, - AddAccess = 1, -} - -@Component({ - selector: "app-org-vault", - templateUrl: "deprecated_vault.component.html", - imports: [ - VaultHeaderComponent, - CollectionAccessRestrictedComponent, - VaultFilterModule, - VaultItemsModule, - SharedModule, - BannerModule, - NoItemsModule, - OrganizationFreeTrialWarningComponent, - OrganizationResellerRenewalWarningComponent, - ], - providers: [ - RoutedVaultFilterService, - RoutedVaultFilterBridgeService, - { provide: CipherFormConfigService, useClass: AdminConsoleCipherFormConfigService }, - ], -}) -export class VaultComponent implements OnInit, OnDestroy { - protected Unassigned = Unassigned; - - trashCleanupWarning: string = null; - activeFilter: VaultFilter = new VaultFilter(); - - protected showAddAccessToggle = false; - protected noItemIcon = NoResults; - protected performingInitialLoad = true; - protected refreshing = false; - protected processingEvent = false; - protected filter: RoutedVaultFilterModel = {}; - protected organization: Organization; - protected allCollections: CollectionAdminView[]; - protected allGroups: GroupView[]; - protected ciphers: CipherView[]; - protected collections: CollectionAdminView[]; - protected selectedCollection: TreeNode | undefined; - protected isEmpty: boolean; - protected showCollectionAccessRestricted: boolean; - protected currentSearchText$: Observable; - protected prevCipherId: string | null = null; - protected userId: UserId; - /** - * A list of collections that the user can assign items to and edit those items within. - * @protected - */ - protected editableCollections$: Observable; - protected allCollectionsWithoutUnassigned$: Observable; - - protected get hideVaultFilters(): boolean { - return this.organization?.isProviderUser && !this.organization?.isMember; - } - - private searchText$ = new Subject(); - private refresh$ = new BehaviorSubject(null); - private destroy$ = new Subject(); - protected addAccessStatus$ = new BehaviorSubject(0); - private vaultItemDialogRef?: DialogRef | undefined; - - @ViewChild("vaultItems", { static: false }) vaultItemsComponent: VaultItemsComponent; - - constructor( - private route: ActivatedRoute, - private organizationService: OrganizationService, - protected vaultFilterService: VaultFilterService, - private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService, - private routedVaultFilterService: RoutedVaultFilterService, - private router: Router, - private changeDetectorRef: ChangeDetectorRef, - private syncService: SyncService, - private i18nService: I18nService, - private dialogService: DialogService, - private messagingService: MessagingService, - private broadcasterService: BroadcasterService, - private ngZone: NgZone, - private platformUtilsService: PlatformUtilsService, - private cipherService: CipherService, - private passwordRepromptService: PasswordRepromptService, - private collectionAdminService: CollectionAdminService, - private searchService: SearchService, - private searchPipe: SearchPipe, - private groupService: GroupApiService, - private logService: LogService, - private eventCollectionService: EventCollectionService, - private totpService: TotpService, - private apiService: ApiService, - private toastService: ToastService, - private configService: ConfigService, - private cipherFormConfigService: CipherFormConfigService, - protected billingApiService: BillingApiServiceAbstraction, - private accountService: AccountService, - private organizationWarningsService: OrganizationWarningsService, - private collectionService: CollectionService, - ) {} - - async ngOnInit() { - this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - - this.trashCleanupWarning = this.i18nService.t( - this.platformUtilsService.isSelfHost() - ? "trashCleanupWarningSelfHosted" - : "trashCleanupWarning", - ); - - const filter$ = this.routedVaultFilterService.filter$; - - // FIXME: The RoutedVaultFilterModel uses `organizationId: Unassigned` to represent the individual vault, - // but that is never used in Admin Console. This function narrows the type so it doesn't pollute our code here, - // but really we should change to using our own vault filter model that only represents valid states in AC. - const isOrganizationId = (value: OrganizationId | Unassigned): value is OrganizationId => - value !== Unassigned; - const organizationId$ = filter$.pipe( - map((filter) => filter.organizationId), - filter((filter) => filter !== undefined), - filter(isOrganizationId), - distinctUntilChanged(), - ); - - const organization$ = this.accountService.activeAccount$.pipe( - map((account) => account?.id), - switchMap((id) => - organizationId$.pipe( - switchMap((organizationId) => - this.organizationService - .organizations$(id) - .pipe(map((organizations) => organizations.find((org) => org.id === organizationId))), - ), - takeUntil(this.destroy$), - shareReplay({ refCount: false, bufferSize: 1 }), - ), - ), - ); - - const firstSetup$ = combineLatest([organization$, this.route.queryParams]).pipe( - first(), - switchMap(async ([organization]) => { - this.organization = organization; - - if (!organization.canEditAnyCollection) { - await this.syncService.fullSync(false); - } - - return undefined; - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.ngZone.run(async () => { - switch (message.command) { - case "syncCompleted": - if (message.successfully) { - this.refresh(); - this.changeDetectorRef.detectChanges(); - } - break; - } - }); - }); - - this.routedVaultFilterBridgeService.activeFilter$ - .pipe(takeUntil(this.destroy$)) - .subscribe((activeFilter) => { - this.activeFilter = activeFilter; - - // watch the active filters. Only show toggle when viewing the collections filter - if (!this.activeFilter.collectionId) { - this.showAddAccessToggle = false; - } - }); - - this.searchText$ - .pipe(debounceTime(SearchTextDebounceInterval), takeUntil(this.destroy$)) - .subscribe((searchText) => - this.router.navigate([], { - queryParams: { search: Utils.isNullOrEmpty(searchText) ? null : searchText }, - queryParamsHandling: "merge", - replaceUrl: true, - }), - ); - - this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search)); - - this.allCollectionsWithoutUnassigned$ = this.refresh$.pipe( - switchMap(() => organizationId$), - switchMap((orgId) => - this.accountService.activeAccount$.pipe( - getUserId, - switchMap((userId) => this.collectionAdminService.collectionAdminViews$(orgId, userId)), - ), - ), - shareReplay({ refCount: false, bufferSize: 1 }), - ); - - this.editableCollections$ = this.allCollectionsWithoutUnassigned$.pipe( - map((collections) => { - // Users that can edit all ciphers can implicitly add to / edit within any collection - if (this.organization.canEditAllCiphers) { - return collections; - } - return collections.filter((c) => c.assigned); - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const allCollections$ = combineLatest([ - organizationId$, - this.allCollectionsWithoutUnassigned$, - ]).pipe( - map(([organizationId, allCollections]) => { - // FIXME: We should not assert that the Unassigned type is a CollectionId. - // Instead we should consider representing the Unassigned collection as a different object, given that - // it is not actually a collection. - return allCollections.concat( - new CollectionAdminView({ - name: this.i18nService.t("unassigned"), - id: Unassigned as CollectionId, - organizationId, - }), - ); - }), - ); - - const allGroups$ = organizationId$.pipe( - switchMap((organizationId) => this.groupService.getAll(organizationId)), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const allCiphers$ = combineLatest([organization$, this.refresh$]).pipe( - switchMap(async ([organization]) => { - // If user swaps organization reset the addAccessToggle - if (!this.showAddAccessToggle || organization) { - this.addAccessToggle(0); - } - let ciphers; - - // Restricted providers (who are not members) do not have access org cipher endpoint below - // Return early to avoid 404 response - if (!organization.isMember && organization.isProviderUser) { - return []; - } - - // If the user can edit all ciphers for the organization then fetch them ALL. - if (organization.canEditAllCiphers) { - ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id); - ciphers?.forEach((c) => (c.edit = true)); - } else { - // Otherwise, only fetch ciphers they have access to (includes unassigned for admins). - ciphers = await this.cipherService.getManyFromApiForOrganization(organization.id); - } - - await this.searchService.indexCiphers(this.userId, ciphers, organization.id); - return ciphers; - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const allCipherMap$ = allCiphers$.pipe( - map((ciphers) => { - return Object.fromEntries(ciphers.map((c) => [c.id, c])); - }), - ); - - const nestedCollections$ = allCollections$.pipe( - map((collections) => getNestedCollectionTree(collections)), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const collections$ = combineLatest([ - nestedCollections$, - filter$, - this.currentSearchText$, - this.addAccessStatus$, - ]).pipe( - filter(([collections, filter]) => collections != undefined && filter != undefined), - concatMap(async ([collections, filter, searchText, addAccessStatus]) => { - if ( - filter.collectionId === Unassigned || - (filter.collectionId === undefined && filter.type !== undefined) - ) { - return []; - } - - this.showAddAccessToggle = false; - let searchableCollectionNodes: TreeNode[] = []; - if (filter.collectionId === undefined || filter.collectionId === All) { - searchableCollectionNodes = collections; - } else { - const selectedCollection = ServiceUtils.getTreeNodeObjectFromList( - collections, - filter.collectionId, - ); - searchableCollectionNodes = selectedCollection?.children ?? []; - } - - let collectionsToReturn: CollectionAdminView[] = []; - - if (await this.searchService.isSearchable(this.userId, searchText)) { - // Flatten the tree for searching through all levels - const flatCollectionTree: CollectionAdminView[] = - getFlatCollectionTree(searchableCollectionNodes); - - collectionsToReturn = this.searchPipe.transform( - flatCollectionTree, - searchText, - (collection) => collection.name, - (collection) => collection.id, - ); - } else { - collectionsToReturn = searchableCollectionNodes.map( - (treeNode: TreeNode): CollectionAdminView => treeNode.node, - ); - } - - // Add access toggle is only shown if allowAdminAccessToAllCollectionItems is false and there are unmanaged collections the user can edit - this.showAddAccessToggle = - !this.organization.allowAdminAccessToAllCollectionItems && - this.organization.canEditUnmanagedCollections && - collectionsToReturn.some((c) => c.unmanaged); - - if (addAccessStatus === 1 && this.showAddAccessToggle) { - collectionsToReturn = collectionsToReturn.filter((c) => c.unmanaged); - } - return collectionsToReturn; - }), - takeUntil(this.destroy$), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const selectedCollection$ = combineLatest([nestedCollections$, filter$]).pipe( - filter(([collections, filter]) => collections != undefined && filter != undefined), - map(([collections, filter]) => { - if ( - filter.collectionId === undefined || - filter.collectionId === All || - filter.collectionId === Unassigned - ) { - return undefined; - } - - return ServiceUtils.getTreeNodeObjectFromList(collections, filter.collectionId); - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const showCollectionAccessRestricted$ = combineLatest([ - filter$, - selectedCollection$, - organization$, - ]).pipe( - map(([filter, collection, organization]) => { - return ( - (filter.collectionId === Unassigned && !organization.canEditUnassignedCiphers) || - (!organization.canEditAllCiphers && collection != undefined && !collection.node.assigned) - ); - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const ciphers$ = combineLatest([ - allCiphers$, - filter$, - this.currentSearchText$, - showCollectionAccessRestricted$, - ]).pipe( - filter(([ciphers, filter]) => ciphers != undefined && filter != undefined), - concatMap(async ([ciphers, filter, searchText, showCollectionAccessRestricted]) => { - if (filter.collectionId === undefined && filter.type === undefined) { - return []; - } - - if (showCollectionAccessRestricted) { - // Do not show ciphers for restricted collections - // Ciphers belonging to multiple collections may still be present in $allCiphers and shouldn't be visible - return []; - } - - const filterFunction = createFilterFunction(filter); - - if (await this.searchService.isSearchable(this.userId, searchText)) { - return await this.searchService.searchCiphers( - this.userId, - searchText, - [filterFunction], - ciphers, - ); - } - - return ciphers.filter(filterFunction); - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - firstSetup$ - .pipe( - switchMap(() => combineLatest([this.route.queryParams, allCipherMap$])), - filter(() => this.vaultItemDialogRef == undefined), - switchMap(async ([qParams, allCiphersMap]) => { - const cipherId = getCipherIdFromParams(qParams); - - if (!cipherId) { - this.prevCipherId = null; - return; - } - - if (cipherId === this.prevCipherId) { - return; - } - - this.prevCipherId = cipherId; - - const cipher = allCiphersMap[cipherId]; - if (cipher) { - let action = qParams.action; - - if (action == "showFailedToDecrypt") { - DecryptionFailureDialogComponent.open(this.dialogService, { - cipherIds: [cipherId as CipherId], - }); - await this.router.navigate([], { - queryParams: { itemId: null, cipherId: null, action: null }, - queryParamsHandling: "merge", - replaceUrl: true, - }); - return; - } - - // Default to "view" - if (action == null) { - action = "view"; - } - - if (action === "view") { - await this.viewCipherById(cipher); - } else { - await this.editCipher(cipher, false); - } - } else { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("unknownCipher"), - }); - await this.router.navigate([], { - queryParams: { cipherId: null, itemId: null }, - queryParamsHandling: "merge", - }); - } - }), - takeUntil(this.destroy$), - ) - .subscribe(); - - firstSetup$ - .pipe( - switchMap(() => combineLatest([this.route.queryParams, organization$, allCiphers$])), - switchMap(async ([qParams, organization, allCiphers$]) => { - const cipherId = qParams.viewEvents; - if (!cipherId) { - return; - } - const cipher = allCiphers$.find((c) => c.id === cipherId); - if (organization.useEvents && cipher != undefined) { - await this.viewEvents(cipher); - } else { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("unknownCipher"), - }); - await this.router.navigate([], { - queryParams: { viewEvents: null }, - queryParamsHandling: "merge", - }); - } - }), - takeUntil(this.destroy$), - ) - .subscribe(); - - // Billing Warnings - organization$ - .pipe( - switchMap((organization) => - merge( - this.organizationWarningsService.showInactiveSubscriptionDialog$(organization), - this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization), - ), - ), - takeUntil(this.destroy$), - ) - .subscribe(); - // End Billing Warnings - - firstSetup$ - .pipe( - switchMap(() => this.refresh$), - tap(() => (this.refreshing = true)), - switchMap(() => - combineLatest([ - organization$, - filter$, - allCollections$, - allGroups$, - ciphers$, - collections$, - selectedCollection$, - showCollectionAccessRestricted$, - ]), - ), - takeUntil(this.destroy$), - ) - .subscribe( - ([ - organization, - filter, - allCollections, - allGroups, - ciphers, - collections, - selectedCollection, - showCollectionAccessRestricted, - ]) => { - this.organization = organization; - this.filter = filter; - this.allCollections = allCollections; - this.allGroups = allGroups; - this.ciphers = ciphers; - this.collections = collections; - this.selectedCollection = selectedCollection; - this.showCollectionAccessRestricted = showCollectionAccessRestricted; - - this.isEmpty = collections?.length === 0 && ciphers?.length === 0; - - // This is a temporary fix to avoid double fetching collections. - // TODO: Remove when implementing new VVR menu - this.vaultFilterService.reloadCollections(allCollections); - - this.refreshing = false; - this.performingInitialLoad = false; - }, - ); - } - - async navigateToPaymentMethod() { - const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, - ); - const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; - await this.router.navigate(["organizations", `${this.organization?.id}`, "billing", route], { - state: { launchPaymentModalAutomatically: true }, - }); - } - - addAccessToggle(e: AddAccessStatusType) { - this.addAccessStatus$.next(e); - } - - get loading() { - return this.refreshing || this.processingEvent; - } - - ngOnDestroy() { - this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); - this.destroy$.next(); - this.destroy$.complete(); - } - - async onVaultItemsEvent(event: VaultItemEvent) { - this.processingEvent = true; - - try { - switch (event.type) { - case "viewAttachments": - await this.editCipherAttachments(event.item); - break; - case "clone": - await this.cloneCipher(event.item); - break; - case "restore": - if (event.items.length === 1) { - await this.restore(event.items[0]); - } else { - await this.bulkRestore(event.items); - } - break; - case "delete": { - const ciphers = event.items - .filter((i) => i.collection === undefined) - .map((i) => i.cipher); - const collections = event.items - .filter((i) => i.cipher === undefined) - .map((i) => i.collection); - if (ciphers.length === 1 && collections.length === 0) { - await this.deleteCipher(ciphers[0]); - } else if (ciphers.length === 0 && collections.length === 1) { - await this.deleteCollection(collections[0] as CollectionAdminView); - } else { - await this.bulkDelete(ciphers, collections, this.organization); - } - break; - } - case "copyField": - await this.copy(event.item, event.field); - break; - case "editCollection": - await this.editCollection( - event.item as CollectionAdminView, - CollectionDialogTabType.Info, - event.readonly, - ); - break; - case "viewCollectionAccess": - await this.editCollection( - event.item as CollectionAdminView, - CollectionDialogTabType.Access, - event.readonly, - ); - break; - case "bulkEditCollectionAccess": - await this.bulkEditCollectionAccess(event.items, this.organization); - break; - case "assignToCollections": - await this.bulkAssignToCollections(event.items); - break; - case "viewEvents": - await this.viewEvents(event.item); - break; - } - } finally { - this.processingEvent = false; - } - } - - filterSearchText(searchText: string) { - this.searchText$.next(searchText); - } - - async editCipherAttachments(cipher: CipherView) { - if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) { - this.go({ cipherId: null, itemId: null }); - return; - } - - if (this.organization.maxStorageGb == null || this.organization.maxStorageGb === 0) { - this.messagingService.send("upgradeOrganization", { organizationId: cipher.organizationId }); - return; - } - - const dialogRef = AttachmentsV2Component.open(this.dialogService, { - cipherId: cipher.id as CipherId, - organizationId: cipher.organizationId as OrganizationId, - admin: true, - }); - - const result = await firstValueFrom(dialogRef.closed); - - if ( - result.action === AttachmentDialogResult.Removed || - result.action === AttachmentDialogResult.Uploaded - ) { - this.refresh(); - } - } - - /** Opens the Add/Edit Dialog */ - async addCipher(cipherType?: CipherType) { - const cipherFormConfig = await this.cipherFormConfigService.buildConfig( - "add", - null, - cipherType, - ); - - const collectionId: CollectionId | undefined = this.activeFilter.collectionId as CollectionId; - - cipherFormConfig.initialValues = { - organizationId: this.organization.id as OrganizationId, - collectionIds: collectionId ? [collectionId] : [], - }; - - await this.openVaultItemDialog("form", cipherFormConfig); - } - - /** - * Edit the given cipher or add a new cipher - * @param cipherView - When set, the cipher to be edited - * @param cloneCipher - `true` when the cipher should be cloned. - */ - async editCipher(cipher: CipherView | null, cloneCipher: boolean) { - if ( - cipher && - cipher.reprompt !== 0 && - !(await this.passwordRepromptService.showPasswordPrompt()) - ) { - // didn't pass password prompt, so don't open add / edit modal - this.go({ cipherId: null, itemId: null }); - return; - } - - const cipherFormConfig = await this.cipherFormConfigService.buildConfig( - cloneCipher ? "clone" : "edit", - cipher?.id as CipherId | null, - ); - - await this.openVaultItemDialog("form", cipherFormConfig, cipher); - } - - /** Opens the view dialog for the given cipher unless password reprompt fails */ - async viewCipherById(cipher: CipherView) { - if (!cipher) { - return; - } - - if ( - cipher && - cipher.reprompt !== 0 && - !(await this.passwordRepromptService.showPasswordPrompt()) - ) { - // Didn't pass password prompt, so don't open add / edit modal. - await this.go({ cipherId: null, itemId: null, action: null }); - return; - } - - const cipherFormConfig = await this.cipherFormConfigService.buildConfig( - "edit", - cipher.id as CipherId, - cipher.type, - ); - - await this.openVaultItemDialog( - "view", - cipherFormConfig, - cipher, - this.activeFilter.collectionId as CollectionId, - ); - } - - /** - * Open the combined view / edit dialog for a cipher. - */ - async openVaultItemDialog( - mode: VaultItemDialogMode, - formConfig: CipherFormConfig, - cipher?: CipherView, - activeCollectionId?: CollectionId, - ) { - const disableForm = cipher ? !cipher.edit && !this.organization.canEditAllCiphers : false; - // If the form is disabled, force the mode into `view` - const dialogMode = disableForm ? "view" : mode; - this.vaultItemDialogRef = VaultItemDialogComponent.open(this.dialogService, { - mode: dialogMode, - formConfig, - disableForm, - activeCollectionId, - isAdminConsoleAction: true, - restore: this.restore, - }); - - const result = await lastValueFrom(this.vaultItemDialogRef.closed); - this.vaultItemDialogRef = undefined; - - // If the dialog was closed by deleting the cipher, refresh the vault. - if (result === VaultItemDialogResult.Deleted || result === VaultItemDialogResult.Saved) { - this.refresh(); - } - - // Clear the query params when the dialog closes - await this.go({ cipherId: null, itemId: null, action: null }); - } - - async cloneCipher(cipher: CipherView) { - if (cipher.login?.hasFido2Credentials) { - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "passkeyNotCopied" }, - content: { key: "passkeyNotCopiedAlert" }, - type: "info", - }); - - if (!confirmed) { - return false; - } - } - - await this.editCipher(cipher, true); - } - - restore = async (c: CipherView): Promise => { - if (!c.isDeleted) { - return; - } - - if ( - !this.organization.permissions.editAnyCollection && - !c.edit && - !this.organization.allowAdminAccessToAllCollectionItems - ) { - this.showMissingPermissionsError(); - return; - } - - if (!(await this.repromptCipher([c]))) { - return; - } - - // Allow restore of an Unassigned Item - try { - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - const asAdmin = this.organization?.canEditAnyCollection || c.isUnassigned; - await this.cipherService.restoreWithServer(c.id, activeUserId, asAdmin); - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("restoredItem"), - }); - this.refresh(); - } catch (e) { - this.logService.error(e); - } - }; - - async bulkRestore(ciphers: CipherView[]) { - if ( - !this.organization.permissions.editAnyCollection && - ciphers.some((c) => !c.edit && !this.organization.allowAdminAccessToAllCollectionItems) - ) { - this.showMissingPermissionsError(); - return; - } - - if (!(await this.repromptCipher(ciphers))) { - return; - } - - // assess if there are unassigned ciphers and/or editable ciphers selected in bulk for restore - const editAccessCiphers: string[] = []; - const unassignedCiphers: string[] = []; - - // If user has edit all Access no need to check for unassigned ciphers - if (this.organization.canEditAllCiphers) { - ciphers.map((cipher) => { - editAccessCiphers.push(cipher.id); - }); - } else { - ciphers.map((cipher) => { - if (cipher.collectionIds.length === 0) { - unassignedCiphers.push(cipher.id); - } else if (cipher.edit) { - editAccessCiphers.push(cipher.id); - } - }); - } - - if (unassignedCiphers.length === 0 && editAccessCiphers.length === 0) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("nothingSelected"), - }); - return; - } - - if (unassignedCiphers.length > 0 || editAccessCiphers.length > 0) { - await this.cipherService.restoreManyWithServer( - [...unassignedCiphers, ...editAccessCiphers], - this.userId, - this.organization.id, - ); - } - - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("restoredItems"), - }); - this.refresh(); - } - - async deleteCipher(c: CipherView): Promise { - if (!c.edit && !this.organization.canEditAllCiphers) { - this.showMissingPermissionsError(); - return; - } - - if (!(await this.repromptCipher([c]))) { - return; - } - - const permanent = c.isDeleted; - - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: permanent ? "permanentlyDeleteItem" : "deleteItem" }, - content: { key: permanent ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation" }, - type: "warning", - }); - - if (!confirmed) { - return false; - } - - try { - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - await this.deleteCipherWithServer(c.id, activeUserId, permanent, c.isUnassigned); - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t(permanent ? "permanentlyDeletedItem" : "deletedItem"), - }); - this.refresh(); - } catch (e) { - this.logService.error(e); - } - } - - async deleteCollection(collection: CollectionAdminView): Promise { - if (!collection.canDelete(this.organization)) { - this.showMissingPermissionsError(); - return; - } - const confirmed = await this.dialogService.openSimpleDialog({ - title: collection.name, - content: { key: "deleteCollectionConfirmation" }, - type: "warning", - }); - - if (!confirmed) { - return; - } - try { - await this.apiService.deleteCollection(this.organization?.id, collection.id); - await this.collectionService.delete([collection.id as CollectionId], this.userId); - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("deletedCollectionId", collection.name), - }); - - // Clear the cipher cache to clear the deleted collection from the cipher state - await this.cipherService.clear(); - - // Navigate away if we deleted the collection we were viewing - if (this.selectedCollection?.node.id === collection.id) { - void this.router.navigate([], { - queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null }, - queryParamsHandling: "merge", - replaceUrl: true, - }); - } - - this.refresh(); - } catch (e) { - this.logService.error(e); - } - } - - async bulkDelete( - ciphers: CipherView[], - collections: CollectionView[], - organization: Organization, - ) { - if (!(await this.repromptCipher(ciphers))) { - return; - } - - // Allow bulk deleting of Unassigned Items - const unassignedCiphers: string[] = []; - const assignedCiphers: string[] = []; - - ciphers.map((c) => { - if (c.isUnassigned) { - unassignedCiphers.push(c.id); - } else { - assignedCiphers.push(c.id); - } - }); - - if (ciphers.length === 0 && collections.length === 0) { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("nothingSelected"), - }); - return; - } - - const canDeleteCollections = - collections == null || collections.every((c) => c.canDelete(organization)); - const canDeleteCiphers = - ciphers == null || ciphers.every((c) => c.edit) || this.organization.canEditAllCiphers; - - if (!canDeleteCiphers || !canDeleteCollections) { - this.showMissingPermissionsError(); - return; - } - - const dialog = openBulkDeleteDialog(this.dialogService, { - data: { - permanent: this.filter.type === "trash", - cipherIds: assignedCiphers, - collections: collections, - organization, - unassignedCiphers, - }, - }); - - const result = await lastValueFrom(dialog.closed); - if (result === BulkDeleteDialogResult.Deleted) { - this.refresh(); - } - } - - async copy(cipher: CipherView, field: "username" | "password" | "totp") { - let aType; - let value; - let typeI18nKey; - - if (field === "username") { - aType = "Username"; - value = cipher.login.username; - typeI18nKey = "username"; - } else if (field === "password") { - aType = "Password"; - value = cipher.login.password; - typeI18nKey = "password"; - } else if (field === "totp") { - aType = "TOTP"; - const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp)); - value = totpResponse?.code; - typeI18nKey = "verificationCodeTotp"; - } else { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("unexpectedError"), - }); - return; - } - - if ( - this.passwordRepromptService.protectedFields().includes(aType) && - !(await this.repromptCipher([cipher])) - ) { - return; - } - - if (!cipher.viewPassword) { - return; - } - - this.platformUtilsService.copyToClipboard(value, { window: window }); - this.toastService.showToast({ - variant: "info", - title: null, - message: this.i18nService.t("valueCopied", this.i18nService.t(typeI18nKey)), - }); - - if (field === "password") { - await this.eventCollectionService.collect(EventType.Cipher_ClientCopiedPassword, cipher.id); - } else if (field === "totp") { - await this.eventCollectionService.collect( - EventType.Cipher_ClientCopiedHiddenField, - cipher.id, - ); - } - } - - async addCollection(): Promise { - const dialog = openCollectionDialog(this.dialogService, { - data: { - organizationId: this.organization?.id, - parentCollectionId: this.selectedCollection?.node.id, - limitNestedCollections: !this.organization.canEditAnyCollection, - isAdminConsoleActive: true, - }, - }); - - const result = await lastValueFrom(dialog.closed); - if ( - result.action === CollectionDialogAction.Saved || - result.action === CollectionDialogAction.Deleted - ) { - this.refresh(); - } - } - - async editCollection( - c: CollectionAdminView, - tab: CollectionDialogTabType, - readonly: boolean, - ): Promise { - const dialog = openCollectionDialog(this.dialogService, { - data: { - collectionId: c?.id, - organizationId: this.organization?.id, - initialTab: tab, - readonly: readonly, - isAddAccessCollection: c.unmanaged, - limitNestedCollections: !this.organization.canEditAnyCollection, - isAdminConsoleActive: true, - }, - }); - - const result = await lastValueFrom(dialog.closed); - if ( - result.action === CollectionDialogAction.Saved || - result.action === CollectionDialogAction.Deleted - ) { - this.refresh(); - - // If we deleted the selected collection, navigate up/away - if ( - result.action === CollectionDialogAction.Deleted && - this.selectedCollection?.node.id === c?.id - ) { - void this.router.navigate([], { - queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null }, - queryParamsHandling: "merge", - replaceUrl: true, - }); - } - } - } - - async bulkEditCollectionAccess( - collections: CollectionView[], - organization: Organization, - ): Promise { - if (collections.length === 0) { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("noCollectionsSelected"), - }); - return; - } - - if (collections.some((c) => !c.canEdit(organization))) { - this.showMissingPermissionsError(); - return; - } - - const dialog = BulkCollectionsDialogComponent.open(this.dialogService, { - data: { - collections, - organizationId: this.organization?.id, - }, - }); - - const result = await lastValueFrom(dialog.closed); - if (result === BulkCollectionsDialogResult.Saved) { - this.refresh(); - } - } - - async bulkAssignToCollections(items: CipherView[]) { - if (items.length === 0) { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("nothingSelected"), - }); - return; - } - - const availableCollections = await firstValueFrom(this.editableCollections$); - - const dialog = AssignCollectionsWebComponent.open(this.dialogService, { - data: { - ciphers: items, - organizationId: this.organization?.id as OrganizationId, - availableCollections, - activeCollection: this.activeFilter?.selectedCollectionNode?.node, - isSingleCipherAdmin: - items.length === 1 && (this.organization?.canEditAllCiphers || items[0].isUnassigned), - }, - }); - - const result = await lastValueFrom(dialog.closed); - if (result === CollectionAssignmentResult.Saved) { - this.refresh(); - } - } - - async viewEvents(cipher: CipherView) { - await openEntityEventsDialog(this.dialogService, { - data: { - name: cipher.name, - organizationId: this.organization.id, - entityId: cipher.id, - showUser: true, - entity: "cipher", - }, - }); - } - - protected deleteCipherWithServer( - id: string, - userId: UserId, - permanent: boolean, - isUnassigned: boolean, - ) { - const asAdmin = this.organization?.canEditAllCiphers || isUnassigned; - return permanent - ? this.cipherService.deleteWithServer(id, userId, asAdmin) - : this.cipherService.softDeleteWithServer(id, userId, asAdmin); - } - - protected async repromptCipher(ciphers: CipherView[]) { - const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None); - - return notProtected || (await this.passwordRepromptService.showPasswordPrompt()); - } - - private refresh() { - this.refresh$.next(); - this.vaultItemsComponent?.clearSelection(); - } - - private go(queryParams: any = null) { - if (queryParams == null) { - queryParams = { - type: this.activeFilter.cipherType, - collectionId: this.activeFilter.collectionId, - deleted: this.activeFilter.isDeleted || null, - }; - } - - void this.router.navigate([], { - relativeTo: this.route, - queryParams: queryParams, - queryParamsHandling: "merge", - replaceUrl: true, - }); - } - - protected readonly CollectionDialogTabType = CollectionDialogTabType; - - private showMissingPermissionsError() { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("missingPermissions"), - }); - } -} - -/** - * Allows backwards compatibility with - * old links that used the original `cipherId` param - */ -const getCipherIdFromParams = (params: Params): string => { - return params["itemId"] || params["cipherId"]; -}; diff --git a/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.html b/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.html index 9ddc9897a31..a8021e82c39 100644 --- a/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.html +++ b/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.html @@ -1 +1 @@ - + diff --git a/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.ts b/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.ts index 8f703acf9af..3c1d0d2b691 100644 --- a/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.ts @@ -1,6 +1,4 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, Input, OnChanges } from "@angular/core"; +import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core"; import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -11,20 +9,25 @@ import { GroupView } from "../../core"; selector: "app-group-badge", templateUrl: "group-name-badge.component.html", standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class GroupNameBadgeComponent implements OnChanges { - @Input() selectedGroups: SelectionReadOnlyRequest[]; - @Input() allGroups: GroupView[]; +export class GroupNameBadgeComponent { + readonly selectedGroups = input([]); + readonly allGroups = input([]); - protected groupNames: string[] = []; + protected readonly groupNames = computed(() => { + const allGroups = this.allGroups(); + if (!allGroups) { + return []; + } + + return this.selectedGroups() + .map((g) => { + return allGroups.find((o) => o.id === g.id)?.name; + }) + .filter((name): name is string => name !== undefined) + .sort(this.i18nService.collator.compare); + }); constructor(private i18nService: I18nService) {} - - ngOnChanges() { - this.groupNames = this.selectedGroups - .map((g) => { - return this.allGroups.find((o) => o.id === g.id)?.name; - }) - .sort(this.i18nService.collator.compare); - } } diff --git a/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts b/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts index 67cb4c7cdc8..33325b3a4bd 100644 --- a/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts +++ b/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts @@ -5,6 +5,7 @@ import { CollectionView, NestingDelimiter, } from "@bitwarden/admin-console/common"; +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"; @@ -26,15 +27,21 @@ export function getNestedCollectionTree( .sort((a, b) => a.name.localeCompare(b.name)) .map(cloneCollection); - const nodes: TreeNode[] = []; - clonedCollections.forEach((collection) => { - const parts = - collection.name != null - ? collection.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) - : []; - ServiceUtils.nestedTraverse(nodes, 0, parts, collection, null, NestingDelimiter); + const all: TreeNode[] = []; + const groupedByOrg = new Map(); + clonedCollections.map((c) => { + const key = c.organizationId; + (groupedByOrg.get(key) ?? groupedByOrg.set(key, []).get(key)!).push(c); }); - return nodes; + for (const group of groupedByOrg.values()) { + const nodes: TreeNode[] = []; + for (const c of group) { + const parts = c.name ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; + ServiceUtils.nestedTraverse(nodes, 0, parts, c, undefined, NestingDelimiter); + } + all.push(...nodes); + } + return all; } export function cloneCollection(collection: CollectionView): CollectionView; diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts index 1ab76c74655..01e61f0ab28 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts @@ -9,11 +9,11 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; 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 { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { DialogService, ToastService } from "@bitwarden/components"; -import { CipherArchiveService } from "@bitwarden/vault"; import { VaultFilterComponent as BaseVaultFilterComponent } from "../../../../vault/individual-vault/vault-filter/components/vault-filter.component"; import { VaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service"; @@ -24,6 +24,8 @@ import { } from "../../../../vault/individual-vault/vault-filter/shared/models/vault-filter-section.type"; import { CollectionFilter } from "../../../../vault/individual-vault/vault-filter/shared/models/vault-filter.type"; +// 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-organization-vault-filter", templateUrl: @@ -34,6 +36,8 @@ export class VaultFilterComponent extends BaseVaultFilterComponent implements OnInit, OnDestroy, OnChanges { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() set organization(value: Organization) { if (value && value !== this._organization) { this._organization = value; diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts index 1be16c65cb8..30582063ab2 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts @@ -37,6 +37,8 @@ import { } from "../../../../vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model"; import { CollectionDialogTabType } from "../../shared/components/collection-dialog"; +// 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-org-vault-header", templateUrl: "./vault-header.component.html", @@ -59,36 +61,56 @@ export class VaultHeaderComponent { * Boolean to determine the loading state of the header. * Shows a loading spinner if set to true */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() loading: boolean; /** Current active filter */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() filter: RoutedVaultFilterModel; /** The organization currently being viewed */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() organization: Organization; /** Currently selected collection */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() collection?: TreeNode; /** The current search text in the header */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() searchText: string; /** Emits an event when the new item button is clicked in the header */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onAddCipher = new EventEmitter(); /** Emits an event when the new collection button is clicked in the header */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onAddCollection = new EventEmitter(); /** Emits an event when the edit collection button is clicked in the header */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onEditCollection = new EventEmitter<{ tab: CollectionDialogTabType; readonly: boolean; }>(); /** Emits an event when the delete collection button is clicked in the header */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onDeleteCollection = new EventEmitter(); /** Emits an event when the search text changes in the header*/ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() searchTextChanged = new EventEmitter(); protected CollectionDialogTabType = CollectionDialogTabType; diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-routing.module.ts b/apps/web/src/app/admin-console/organizations/collections/vault-routing.module.ts index d529c4c31fe..7ad9f050d7b 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-routing.module.ts @@ -1,26 +1,19 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; -import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; import { canAccessVaultTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { organizationPermissionsGuard } from "../guards/org-permissions.guard"; -import { VaultComponent } from "./deprecated_vault.component"; -import { vNextVaultComponent } from "./vault.component"; +import { VaultComponent } from "./vault.component"; const routes: Routes = [ - ...featureFlaggedRoute({ - defaultComponent: VaultComponent, - flaggedComponent: vNextVaultComponent, - featureFlag: FeatureFlag.CollectionVaultRefactor, - routeOptions: { - data: { titleId: "vaults" }, - path: "", - canActivate: [organizationPermissionsGuard(canAccessVaultTab)], - }, - }), + { + data: { titleId: "vaults" }, + path: "", + canActivate: [organizationPermissionsGuard(canAccessVaultTab)], + component: VaultComponent, + }, ]; @NgModule({ diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 64aa6936468..f827dda9a9b 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -44,9 +44,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { EventType } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -142,6 +140,8 @@ enum AddAccessStatusType { AddAccess = 1, } +// 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-org-vault", templateUrl: "vault.component.html", @@ -162,7 +162,7 @@ enum AddAccessStatusType { { provide: CipherFormConfigService, useClass: AdminConsoleCipherFormConfigService }, ], }) -export class vNextVaultComponent implements OnInit, OnDestroy { +export class VaultComponent implements OnInit, OnDestroy { protected Unassigned = Unassigned; trashCleanupWarning: string = this.i18nService.t( @@ -209,6 +209,8 @@ export class vNextVaultComponent implements OnInit, OnDestroy { protected selectedCollection$: Observable | undefined>; private nestedCollections$: Observable[]>; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("vaultItems", { static: false }) vaultItemsComponent: | VaultItemsComponent | undefined; @@ -239,7 +241,6 @@ export class vNextVaultComponent implements OnInit, OnDestroy { private totpService: TotpService, private apiService: ApiService, private toastService: ToastService, - private configService: ConfigService, private cipherFormConfigService: CipherFormConfigService, protected billingApiService: BillingApiServiceAbstraction, private accountService: AccountService, @@ -710,14 +711,13 @@ export class vNextVaultComponent implements OnInit, OnDestroy { } async navigateToPaymentMethod() { - const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, - ); - const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; const organizationId = await firstValueFrom(this.organizationId$); - await this.router.navigate(["organizations", `${organizationId}`, "billing", route], { - state: { launchPaymentModalAutomatically: true }, - }); + await this.router.navigate( + ["organizations", `${organizationId}`, "billing", "payment-details"], + { + state: { launchPaymentModalAutomatically: true }, + }, + ); } addAccessToggle(e: AddAccessStatusType) { @@ -794,6 +794,9 @@ export class vNextVaultComponent implements OnInit, OnDestroy { case "viewEvents": await this.viewEvents(event.item); break; + case "editCipher": + await this.editCipher(event.item); + break; } } finally { this.processingEvent$.next(false); @@ -856,7 +859,7 @@ export class vNextVaultComponent implements OnInit, OnDestroy { * @param cipherView - When set, the cipher to be edited * @param cloneCipher - `true` when the cipher should be cloned. */ - async editCipher(cipher: CipherView | undefined, cloneCipher: boolean) { + async editCipher(cipher: CipherView | undefined, cloneCipher?: boolean) { if ( cipher && cipher.reprompt !== 0 && @@ -982,7 +985,7 @@ export class vNextVaultComponent implements OnInit, OnDestroy { // Allow restore of an Unassigned Item try { - if (c.id == null) { + if (c.id == null || c.id === "") { throw new Error("Cipher must have an Id to be restored"); } const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); @@ -1215,7 +1218,7 @@ export class vNextVaultComponent implements OnInit, OnDestroy { aType = "Password"; value = cipher.login.password; typeI18nKey = "password"; - } else if (field === "totp") { + } else if (field === "totp" && cipher.login.totp != null) { aType = "TOTP"; const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp)); value = totpResponse.code; @@ -1236,7 +1239,7 @@ export class vNextVaultComponent implements OnInit, OnDestroy { return; } - if (!cipher.viewPassword) { + if (!cipher.viewPassword || value == null) { return; } diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.module.ts b/apps/web/src/app/admin-console/organizations/collections/vault.module.ts index 92dbc5d832c..d7c6a468eba 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.module.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.module.ts @@ -2,14 +2,12 @@ import { NgModule } from "@angular/core"; import { SharedModule } from "../../../shared/shared.module"; import { OrganizationBadgeModule } from "../../../vault/individual-vault/organization-badge/organization-badge.module"; -import { ViewComponent } from "../../../vault/individual-vault/view.component"; import { CollectionDialogComponent } from "../shared/components/collection-dialog"; import { CollectionNameBadgeComponent } from "./collection-badge"; -import { VaultComponent } from "./deprecated_vault.component"; import { GroupBadgeModule } from "./group-badge/group-badge.module"; import { VaultRoutingModule } from "./vault-routing.module"; -import { vNextVaultComponent } from "./vault.component"; +import { VaultComponent } from "./vault.component"; @NgModule({ imports: [ @@ -20,8 +18,6 @@ import { vNextVaultComponent } from "./vault.component"; OrganizationBadgeModule, CollectionDialogComponent, VaultComponent, - vNextVaultComponent, - ViewComponent, ], }) export class VaultModule {} diff --git a/apps/web/src/app/admin-console/organizations/create/organization-information.component.ts b/apps/web/src/app/admin-console/organizations/create/organization-information.component.ts index cd14b73a156..d45e06ad239 100644 --- a/apps/web/src/app/admin-console/organizations/create/organization-information.component.ts +++ b/apps/web/src/app/admin-console/organizations/create/organization-information.component.ts @@ -6,17 +6,31 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.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: "app-org-info", templateUrl: "organization-information.component.html", standalone: false, }) export class OrganizationInformationComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() nameOnly = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() createOrganization = true; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() isProvider = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() acceptingSponsorship = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() formGroup: UntypedFormGroup; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() changedBusinessOwned = new EventEmitter(); constructor(private accountService: AccountService) {} diff --git a/apps/web/src/app/admin-console/organizations/guards/is-enterprise-org.guard.spec.ts b/apps/web/src/app/admin-console/organizations/guards/is-enterprise-org.guard.spec.ts index bc4a942301a..b463d24ea3c 100644 --- a/apps/web/src/app/admin-console/organizations/guards/is-enterprise-org.guard.spec.ts +++ b/apps/web/src/app/admin-console/organizations/guards/is-enterprise-org.guard.spec.ts @@ -19,18 +19,24 @@ import { DialogService } from "@bitwarden/components"; import { isEnterpriseOrgGuard } from "./is-enterprise-org.guard"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

    This is the home screen!

    ", standalone: false, }) export class HomescreenComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

    This component can only be accessed by a enterprise organization!

    ", standalone: false, }) export class IsEnterpriseOrganizationComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

    This is the organization upgrade screen!

    ", standalone: false, diff --git a/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.spec.ts b/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.spec.ts index ab5fd79321a..d7c4e247d8e 100644 --- a/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.spec.ts +++ b/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.spec.ts @@ -18,18 +18,24 @@ import { DialogService } from "@bitwarden/components"; import { isPaidOrgGuard } from "./is-paid-org.guard"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

    This is the home screen!

    ", standalone: false, }) export class HomescreenComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

    This component can only be accessed by a paid organization!

    ", standalone: false, }) export class PaidOrganizationOnlyComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

    This is the organization upgrade screen!

    ", standalone: false, diff --git a/apps/web/src/app/admin-console/organizations/guards/org-policy.guard.ts b/apps/web/src/app/admin-console/organizations/guards/org-policy.guard.ts new file mode 100644 index 00000000000..5964601fbe7 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/guards/org-policy.guard.ts @@ -0,0 +1,70 @@ +import { inject } from "@angular/core"; +import { CanActivateFn, Router } from "@angular/router"; +import { firstValueFrom, Observable, switchMap, tap } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { ToastService } from "@bitwarden/components"; +import { UserId } from "@bitwarden/user-core"; + +/** + * This guard is intended to prevent members of an organization from accessing + * routes based on compliance with organization + * policies. e.g Emergency access, which is a non-organization + * feature is restricted by the Auto Confirm policy. + */ +export function organizationPolicyGuard( + featureCallback: ( + userId: UserId, + configService: ConfigService, + policyService: PolicyService, + ) => Observable, +): CanActivateFn { + return async () => { + const router = inject(Router); + const toastService = inject(ToastService); + const i18nService = inject(I18nService); + const accountService = inject(AccountService); + const policyService = inject(PolicyService); + const configService = inject(ConfigService); + const syncService = inject(SyncService); + + const synced = await firstValueFrom( + accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => syncService.lastSync$(userId)), + ), + ); + + if (synced == null) { + await syncService.fullSync(false); + } + + const compliant = await firstValueFrom( + accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => featureCallback(userId, configService, policyService)), + tap((compliant) => { + if (typeof compliant !== "boolean") { + throw new Error("Feature callback must return a boolean."); + } + }), + ), + ); + + if (!compliant) { + toastService.showToast({ + variant: "error", + message: i18nService.t("noPageAccess"), + }); + + return router.createUrlTree(["/"]); + } + + return compliant; + }; +} diff --git a/apps/web/src/app/admin-console/organizations/guards/org-redirect.guard.spec.ts b/apps/web/src/app/admin-console/organizations/guards/org-redirect.guard.spec.ts index 9dc084484f3..38f13c4d781 100644 --- a/apps/web/src/app/admin-console/organizations/guards/org-redirect.guard.spec.ts +++ b/apps/web/src/app/admin-console/organizations/guards/org-redirect.guard.spec.ts @@ -17,18 +17,24 @@ import { UserId } from "@bitwarden/common/types/guid"; import { organizationRedirectGuard } from "./org-redirect.guard"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

    This is the home screen!

    ", standalone: false, }) export class HomescreenComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

    This is the admin console!

    ", standalone: false, }) export class AdminConsoleComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "

    This is a subroute of the admin console!

    ", standalone: false, diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index 10290d52f1e..accb5f77fdc 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -2,17 +2,12 @@ - - - + > - @let paymentDetailsPageData = paymentDetailsPageData$ | async; diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts index a9b61debf89..ee09143ed2f 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts @@ -23,9 +23,6 @@ import { PolicyType, ProviderStatusType } from "@bitwarden/common/admin-console/ 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 { OrganizationBillingServiceAbstraction } 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getById } from "@bitwarden/common/platform/misc"; import { BannerModule, IconModule } from "@bitwarden/components"; @@ -39,6 +36,8 @@ import { FreeFamiliesPolicyService } from "../../../billing/services/free-famili import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component"; import { WebLayoutModule } from "../../../layouts/web-layout.module"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-organization-layout", templateUrl: "organization-layout.component.html", @@ -70,11 +69,6 @@ export class OrganizationLayoutComponent implements OnInit { protected showSponsoredFamiliesDropdown$: Observable; - protected paymentDetailsPageData$: Observable<{ - route: string; - textKey: string; - }>; - protected subscriber$: Observable; protected getTaxIdWarning$: () => Observable; @@ -82,12 +76,10 @@ export class OrganizationLayoutComponent implements OnInit { private route: ActivatedRoute, private organizationService: OrganizationService, private platformUtilsService: PlatformUtilsService, - private configService: ConfigService, private policyService: PolicyService, private providerService: ProviderService, private accountService: AccountService, private freeFamiliesPolicyService: FreeFamiliesPolicyService, - private organizationBillingService: OrganizationBillingServiceAbstraction, private organizationWarningsService: OrganizationWarningsService, ) {} @@ -141,16 +133,6 @@ export class OrganizationLayoutComponent implements OnInit { this.integrationPageEnabled$ = this.organization$.pipe(map((org) => org.canAccessIntegrations)); - this.paymentDetailsPageData$ = this.configService - .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) - .pipe( - map((managePaymentDetailsOutsideCheckout) => - managePaymentDetailsOutsideCheckout - ? { route: "billing/payment-details", textKey: "paymentDetails" } - : { route: "billing/payment-method", textKey: "paymentMethod" }, - ), - ); - this.subscriber$ = this.organization$.pipe( map((organization) => ({ type: "organization", diff --git a/apps/web/src/app/admin-console/organizations/manage/entity-events.component.ts b/apps/web/src/app/admin-console/organizations/manage/entity-events.component.ts index 8484b05283d..b4dcb9fdfac 100644 --- a/apps/web/src/app/admin-console/organizations/manage/entity-events.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/entity-events.component.ts @@ -28,7 +28,7 @@ import { EventService } from "../../../core"; import { SharedModule } from "../../../shared"; export interface EntityEventsDialogParams { - entity: "user" | "cipher" | "secret" | "project"; + entity: "user" | "cipher" | "secret" | "project" | "service-account"; entityId: string; organizationId?: string; @@ -37,6 +37,8 @@ export interface EntityEventsDialogParams { name?: string; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ imports: [SharedModule], templateUrl: "entity-events.component.html", @@ -174,6 +176,14 @@ export class EntityEventsComponent implements OnInit, OnDestroy { dates[1], clearExisting ? null : this.continuationToken, ); + } else if (this.params.entity === "service-account") { + response = await this.apiService.getEventsServiceAccount( + this.params.organizationId, + this.params.entityId, + dates[0], + dates[1], + clearExisting ? null : this.continuationToken, + ); } else if (this.params.entity === "project") { response = await this.apiService.getEventsProject( this.params.organizationId, diff --git a/apps/web/src/app/admin-console/organizations/manage/events.component.html b/apps/web/src/app/admin-console/organizations/manage/events.component.html index 344e8afef53..83665a4b99e 100644 --- a/apps/web/src/app/admin-console/organizations/manage/events.component.html +++ b/apps/web/src/app/admin-console/organizations/manage/events.component.html @@ -124,7 +124,7 @@ > -

    +

    {{ "upgradeEventLogTitleMessage" | i18n }}

    diff --git a/apps/web/src/app/admin-console/organizations/manage/events.component.ts b/apps/web/src/app/admin-console/organizations/manage/events.component.ts index 966499c0bee..62f6539cc16 100644 --- a/apps/web/src/app/admin-console/organizations/manage/events.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/events.component.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { concatMap, filter, firstValueFrom, lastValueFrom, map, switchMap, takeUntil } from "rxjs"; +import { concatMap, firstValueFrom, lastValueFrom, map, of, switchMap, takeUntil, tap } from "rxjs"; import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; @@ -46,6 +46,8 @@ const EVENT_SYSTEM_USER_TO_TRANSLATION: Record = { [EventSystemUser.PublicApi]: "publicApi", }; +// 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: "events.component.html", imports: [SharedModule, HeaderModule], @@ -141,17 +143,23 @@ export class EventsComponent extends BaseEventsComponent implements OnInit, OnDe getUserId, switchMap((userId) => this.providerService.get$(this.organization.providerId, userId)), map((provider) => provider != null && provider.canManageUsers), - filter((result) => result), - switchMap(() => this.apiService.getProviderUsers(this.organization.id)), - map((providerUsersResponse) => - providerUsersResponse.data.forEach((u) => { - const name = this.userNamePipe.transform(u); - this.orgUsersUserIdMap.set(u.userId, { - name: `${name} (${this.organization.providerName})`, - email: u.email, + switchMap((canManage) => { + if (canManage) { + return this.apiService.getProviderUsers(this.organization.providerId); + } + return of(null); + }), + tap((providerUsersResponse) => { + if (providerUsersResponse) { + providerUsersResponse.data.forEach((u) => { + const name = this.userNamePipe.transform(u); + this.orgUsersUserIdMap.set(u.userId, { + name: `${name} (${this.organization.providerName})`, + email: u.email, + }); }); - }), - ), + } + }), ), ); } catch (e) { diff --git a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts index 9b9be4e50b3..03a24703c0f 100644 --- a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts @@ -107,6 +107,8 @@ export const openGroupAddEditDialog = ( ); }; +// 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-group-add-edit", templateUrl: "group-add-edit.component.html", diff --git a/apps/web/src/app/admin-console/organizations/manage/groups.component.html b/apps/web/src/app/admin-console/organizations/manage/groups.component.html index 62d0b5b874b..aa4f2ccf138 100644 --- a/apps/web/src/app/admin-console/organizations/manage/groups.component.html +++ b/apps/web/src/app/admin-console/organizations/manage/groups.component.html @@ -34,7 +34,7 @@ (change)="toggleAllVisible($event)" id="selectAll" /> -

    + + {{ "loading" | i18n }} +
    + } +
    + @if (policy.showDescription) { +

    {{ policy.description | i18n }}

    + } +
    + + + + @let footer = (multiStepSubmit | async)[currentStep()]?.footerContent(); + @if (footer) { + + } + + + + + +
    + @let showBadge = firstTimeDialog(); + @if (showBadge) { + {{ "availableNow" | i18n }} + } + + {{ (showBadge ? "autoConfirm" : "editPolicy") | i18n }} + @if (!showBadge) { + + {{ policy.name | i18n }} + + } + +
    +
    + + + {{ "howToTurnOnAutoConfirm" | i18n }} + + + + + + + + + + + diff --git a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts new file mode 100644 index 00000000000..99d484f04f2 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts @@ -0,0 +1,289 @@ +import { + AfterViewInit, + ChangeDetectorRef, + Component, + Inject, + signal, + Signal, + TemplateRef, + viewChild, +} from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { Router } from "@angular/router"; +import { + combineLatest, + firstValueFrom, + map, + Observable, + of, + shareReplay, + startWith, + switchMap, + tap, +} from "rxjs"; + +import { AutomaticUserConfirmationService } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { getById } from "@bitwarden/common/platform/misc"; +import { + DIALOG_DATA, + DialogConfig, + DialogRef, + DialogService, + ToastService, +} from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; + +import { SharedModule } from "../../../shared"; + +import { AutoConfirmPolicyEditComponent } from "./policy-edit-definitions/auto-confirm-policy.component"; +import { + PolicyEditDialogComponent, + PolicyEditDialogData, + PolicyEditDialogResult, +} from "./policy-edit-dialog.component"; + +export type MultiStepSubmit = { + sideEffect: () => Promise; + footerContent: Signal | undefined>; + titleContent: Signal | undefined>; +}; + +export type AutoConfirmPolicyDialogData = PolicyEditDialogData & { + firstTimeDialog?: boolean; +}; + +/** + * Custom policy dialog component for Auto-Confirm policy. + * Satisfies the PolicyDialogComponent interface structurally + * via its static open() function. + */ +// 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: "auto-confirm-edit-policy-dialog.component.html", + imports: [SharedModule], +}) +export class AutoConfirmPolicyDialogComponent + extends PolicyEditDialogComponent + implements AfterViewInit +{ + policyType = PolicyType; + + protected readonly firstTimeDialog = signal(false); + protected readonly currentStep = signal(0); + protected multiStepSubmit: Observable = of([]); + protected autoConfirmEnabled$: Observable = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.policyService.policies$(userId)), + map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm)?.enabled ?? false), + ); + // Users with manage policies custom permission should not see the dialog's second step since + // they do not have permission to configure the setting. This will only allow them to configure + // the policy. + protected managePoliciesOnly$: Observable = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.organizationService.organizations$(userId)), + getById(this.data.organizationId), + map((organization) => (!organization?.isAdmin && organization?.canManagePolicies) ?? false), + ); + + private readonly submitPolicy: Signal | undefined> = viewChild("step0"); + private readonly openExtension: Signal | undefined> = viewChild("step1"); + + private readonly submitPolicyTitle: Signal | undefined> = + viewChild("step0Title"); + private readonly openExtensionTitle: Signal | undefined> = + viewChild("step1Title"); + + override policyComponent: AutoConfirmPolicyEditComponent | undefined; + + constructor( + @Inject(DIALOG_DATA) protected data: AutoConfirmPolicyDialogData, + accountService: AccountService, + policyApiService: PolicyApiServiceAbstraction, + i18nService: I18nService, + cdr: ChangeDetectorRef, + formBuilder: FormBuilder, + dialogRef: DialogRef, + toastService: ToastService, + configService: ConfigService, + keyService: KeyService, + private organizationService: OrganizationService, + private policyService: PolicyService, + private router: Router, + private autoConfirmService: AutomaticUserConfirmationService, + ) { + super( + data, + accountService, + policyApiService, + i18nService, + cdr, + formBuilder, + dialogRef, + toastService, + configService, + keyService, + ); + + this.firstTimeDialog.set(data.firstTimeDialog ?? false); + } + + /** + * Instantiates the child policy component and inserts it into the view. + */ + async ngAfterViewInit() { + await super.ngAfterViewInit(); + + if (this.policyComponent) { + this.saveDisabled$ = combineLatest([ + this.autoConfirmEnabled$, + this.policyComponent.enabled.valueChanges.pipe( + startWith(this.policyComponent.enabled.value), + ), + ]).pipe(map(([policyEnabled, value]) => !policyEnabled && !value)); + } + + this.multiStepSubmit = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.policyService.policies$(userId)), + map((policies) => policies.find((p) => p.type === PolicyType.SingleOrg)?.enabled ?? false), + tap((singleOrgPolicyEnabled) => + this.policyComponent?.setSingleOrgEnabled(singleOrgPolicyEnabled), + ), + switchMap((singleOrgPolicyEnabled) => this.buildMultiStepSubmit(singleOrgPolicyEnabled)), + shareReplay({ bufferSize: 1, refCount: true }), + ); + } + + private buildMultiStepSubmit(singleOrgPolicyEnabled: boolean): Observable { + return this.managePoliciesOnly$.pipe( + map((managePoliciesOnly) => { + const submitSteps = [ + { + sideEffect: () => this.handleSubmit(singleOrgPolicyEnabled ?? false), + footerContent: this.submitPolicy, + titleContent: this.submitPolicyTitle, + }, + ]; + + if (!managePoliciesOnly) { + submitSteps.push({ + sideEffect: () => this.openBrowserExtension(), + footerContent: this.openExtension, + titleContent: this.openExtensionTitle, + }); + } + return submitSteps; + }), + ); + } + + private async handleSubmit(singleOrgEnabled: boolean) { + if (!singleOrgEnabled) { + await this.submitSingleOrg(); + } + await this.submitAutoConfirm(); + } + + /** + * Triggers policy submission for auto confirm. + * @returns boolean: true if multi-submit workflow should continue, false otherwise. + */ + private async submitAutoConfirm() { + if (!this.policyComponent) { + throw new Error("PolicyComponent not initialized."); + } + + const autoConfirmRequest = await this.policyComponent.buildRequest(); + await this.policyApiService.putPolicy( + this.data.organizationId, + this.data.policy.type, + autoConfirmRequest, + ); + + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + const currentAutoConfirmState = await firstValueFrom( + this.autoConfirmService.configuration$(userId), + ); + + await this.autoConfirmService.upsert(userId, { + ...currentAutoConfirmState, + showSetupDialog: false, + }); + + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("editedPolicyId", this.i18nService.t(this.data.policy.name)), + }); + + if (!this.policyComponent.enabled.value) { + this.dialogRef.close("saved"); + } + } + + private async submitSingleOrg(): Promise { + const singleOrgRequest: PolicyRequest = { + enabled: true, + data: null, + }; + + await this.policyApiService.putPolicy( + this.data.organizationId, + PolicyType.SingleOrg, + singleOrgRequest, + ); + } + + private async openBrowserExtension() { + await this.router.navigate(["/browser-extension-prompt"], { + queryParams: { url: "AutoConfirm" }, + }); + } + + submit = async () => { + if (!this.policyComponent) { + throw new Error("PolicyComponent not initialized."); + } + + if ((await this.policyComponent.confirm()) == false) { + this.dialogRef.close(); + return; + } + + try { + const multiStepSubmit = await firstValueFrom(this.multiStepSubmit); + await multiStepSubmit[this.currentStep()].sideEffect(); + + if (this.currentStep() === multiStepSubmit.length - 1) { + this.dialogRef.close("saved"); + return; + } + + this.currentStep.update((value) => value + 1); + this.policyComponent.setStep(this.currentStep()); + } catch (error: any) { + this.toastService.showToast({ + variant: "error", + message: error.message, + }); + } + }; + + static open = ( + dialogService: DialogService, + config: DialogConfig, + ) => { + return dialogService.open(AutoConfirmPolicyDialogComponent, config); + }; +} diff --git a/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts b/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts index 2e5faea4702..c1b175fa988 100644 --- a/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts @@ -8,6 +8,20 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request"; import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; + +import type { PolicyEditDialogData, PolicyEditDialogResult } from "./policy-edit-dialog.component"; + +/** + * Interface for policy dialog components. + * Any component that implements this interface can be used as a custom policy edit dialog. + */ +export interface PolicyDialogComponent { + open: ( + dialogService: DialogService, + config: DialogConfig, + ) => DialogRef; +} /** * A metadata class that defines how a policy is displayed in the Admin Console Policies page for editing. @@ -32,6 +46,12 @@ export abstract class BasePolicyEditDefinition { */ abstract component: Constructor; + /** + * The dialog component that will be opened when editing this policy. + * This allows customizing the look and feel of each policy's dialog contents. + */ + editDialogComponent?: PolicyDialogComponent; + /** * If true, the {@link description} will be reused in the policy edit modal. Set this to false if you * have more complex requirements that you will implement in your template instead. @@ -58,7 +78,11 @@ export abstract class BasePolicyEditDefinition { */ @Directive() export abstract class BasePolicyEditComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() policyResponse: PolicyResponse | undefined; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() policy: BasePolicyEditDefinition | undefined; /** @@ -85,7 +109,6 @@ export abstract class BasePolicyEditComponent implements OnInit { } const request: PolicyRequest = { - type: this.policy.type, enabled: this.enabled.value ?? false, data: this.buildRequestData(), }; diff --git a/apps/web/src/app/admin-console/organizations/policies/index.ts b/apps/web/src/app/admin-console/organizations/policies/index.ts index 624e5132faf..3042be240f7 100644 --- a/apps/web/src/app/admin-console/organizations/policies/index.ts +++ b/apps/web/src/app/admin-console/organizations/policies/index.ts @@ -2,3 +2,6 @@ export { PoliciesComponent } from "./policies.component"; export { ossPolicyEditRegister } from "./policy-edit-register"; export { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component"; export { POLICY_EDIT_REGISTER } from "./policy-register-token"; +export { AutoConfirmPolicyDialogComponent } from "./auto-confirm-edit-policy-dialog.component"; +export { AutoConfirmPolicy } from "./policy-edit-definitions"; +export { PolicyEditDialogResult } from "./policy-edit-dialog.component"; diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.html b/apps/web/src/app/admin-console/organizations/policies/policies.component.html index ea14986749f..8df73a50e14 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.html @@ -1,7 +1,6 @@ - @let organization = organization$ | async; @if (loading) { - @for (p of policies; track p.name) { - @if (p.display$(organization, configService) | async) { - - - - @if (policiesEnabledMap.get(p.type)) { - {{ "on" | i18n }} - } - {{ p.description | i18n }} - - - } + @for (p of policies$ | async; track p.type) { + + + + @if (policiesEnabledMap.get(p.type)) { + {{ "on" | i18n }} + } + {{ p.description | i18n }} + + } diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts index 45383133687..e80796fd0af 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts @@ -1,19 +1,30 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; -import { firstValueFrom, lastValueFrom, Observable } from "rxjs"; -import { first, map } from "rxjs/operators"; +import { + combineLatest, + firstValueFrom, + Observable, + of, + switchMap, + first, + map, + withLatestFrom, + tap, +} from "rxjs"; import { getOrganizationById, OrganizationService, } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { DialogService } from "@bitwarden/components"; import { safeProvider } from "@bitwarden/ui-common"; @@ -21,11 +32,13 @@ import { safeProvider } from "@bitwarden/ui-common"; import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; -import { BasePolicyEditDefinition } from "./base-policy-edit.component"; +import { BasePolicyEditDefinition, PolicyDialogComponent } from "./base-policy-edit.component"; import { PolicyEditDialogComponent } from "./policy-edit-dialog.component"; import { PolicyListService } from "./policy-list.service"; import { POLICY_EDIT_REGISTER } from "./policy-register-token"; +// 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: "policies.component.html", imports: [SharedModule, HeaderModule], @@ -39,8 +52,7 @@ import { POLICY_EDIT_REGISTER } from "./policy-register-token"; export class PoliciesComponent implements OnInit { loading = true; organizationId: string; - policies: readonly BasePolicyEditDefinition[]; - protected organization$: Observable; + policies$: Observable; private orgPolicies: PolicyResponse[]; protected policiesEnabledMap: Map = new Map(); @@ -52,8 +64,18 @@ export class PoliciesComponent implements OnInit { private policyApiService: PolicyApiServiceAbstraction, private policyListService: PolicyListService, private dialogService: DialogService, + private policyService: PolicyService, protected configService: ConfigService, - ) {} + ) { + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => this.policyService.policies$(userId)), + tap(async () => await this.load()), + takeUntilDestroyed(), + ) + .subscribe(); + } async ngOnInit() { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe @@ -63,28 +85,41 @@ export class PoliciesComponent implements OnInit { this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); - this.organization$ = this.organizationService + const organization$ = this.organizationService .organizations$(userId) .pipe(getOrganizationById(this.organizationId)); - this.policies = this.policyListService.getPolicies(); + this.policies$ = organization$.pipe( + withLatestFrom(of(this.policyListService.getPolicies())), + switchMap(([organization, policies]) => { + return combineLatest( + policies.map((policy) => + policy + .display$(organization, this.configService) + .pipe(map((shouldDisplay) => ({ policy, shouldDisplay }))), + ), + ); + }), + map((results) => + results.filter((result) => result.shouldDisplay).map((result) => result.policy), + ), + ); await this.load(); // Handle policies component launch from Event message - this.route.queryParams - .pipe(first()) + combineLatest([this.route.queryParams.pipe(first()), this.policies$]) /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ - .subscribe(async (qParams) => { + .subscribe(async ([qParams, policies]) => { if (qParams.policyId != null) { const policyIdFromEvents: string = qParams.policyId; for (const orgPolicy of this.orgPolicies) { if (orgPolicy.id === policyIdFromEvents) { - for (let i = 0; i < this.policies.length; i++) { - if (this.policies[i].type === orgPolicy.type) { + for (let i = 0; i < policies.length; i++) { + if (policies[i].type === orgPolicy.type) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.edit(this.policies[i]); + this.edit(policies[i]); break; } } @@ -107,16 +142,13 @@ export class PoliciesComponent implements OnInit { } async edit(policy: BasePolicyEditDefinition) { - const dialogRef = PolicyEditDialogComponent.open(this.dialogService, { + const dialogComponent: PolicyDialogComponent = + policy.editDialogComponent ?? PolicyEditDialogComponent; + dialogComponent.open(this.dialogService, { data: { policy: policy, organizationId: this.organizationId, }, }); - - const result = await lastValueFrom(dialogRef.closed); - if (result == "saved") { - await this.load(); - } } } diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html new file mode 100644 index 00000000000..54f166b662e --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html @@ -0,0 +1,59 @@ + + + +

    + {{ "autoConfirmPolicyEditDescription" | i18n }} +

    + +
      +
    • + + {{ "autoConfirmAcceptSecurityRiskTitle" | i18n }} + + {{ "autoConfirmAcceptSecurityRiskDescription" | i18n }} + + {{ "autoConfirmAcceptSecurityRiskLearnMore" | i18n }} + + +
    • + +
    • + @if (singleOrgEnabled$ | async) { + + {{ "autoConfirmSingleOrgExemption" | i18n }} + + } @else { + + {{ "autoConfirmSingleOrgRequired" | i18n }} + + } + {{ "autoConfirmSingleOrgRequiredDesc" | i18n }} +
    • + +
    • + + {{ "autoConfirmNoEmergencyAccess" | i18n }} + + {{ "autoConfirmNoEmergencyAccessDescription" | i18n }} +
    • +
    + + + {{ "autoConfirmCheckBoxLabel" | i18n }} +
    + + +
    + +
    +
      +
    1. 1. {{ "autoConfirmExtension1" | i18n }}
    2. + +
    3. + 2. {{ "autoConfirmExtension2" | i18n }} + + {{ "autoConfirmExtension3" | i18n }} + +
    4. +
    +
    diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts new file mode 100644 index 00000000000..7fa4fc2eea7 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts @@ -0,0 +1,52 @@ +import { Component, OnInit, Signal, TemplateRef, viewChild } from "@angular/core"; +import { BehaviorSubject, map, Observable } from "rxjs"; + +import { AutoConfirmSvg } from "@bitwarden/assets/svg"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +import { SharedModule } from "../../../../shared"; +import { AutoConfirmPolicyDialogComponent } from "../auto-confirm-edit-policy-dialog.component"; +import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component"; + +export class AutoConfirmPolicy extends BasePolicyEditDefinition { + name = "autoConfirm"; + description = "autoConfirmDescription"; + type = PolicyType.AutoConfirm; + component = AutoConfirmPolicyEditComponent; + showDescription = false; + editDialogComponent = AutoConfirmPolicyDialogComponent; + + override display$(organization: Organization, configService: ConfigService): Observable { + return configService + .getFeatureFlag$(FeatureFlag.AutoConfirm) + .pipe(map((enabled) => enabled && organization.useAutomaticUserConfirmation)); + } +} + +// 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: "auto-confirm-policy.component.html", + imports: [SharedModule], +}) +export class AutoConfirmPolicyEditComponent extends BasePolicyEditComponent implements OnInit { + protected readonly autoConfirmSvg = AutoConfirmSvg; + private readonly policyForm: Signal | undefined> = viewChild("step0"); + private readonly extensionButton: Signal | undefined> = viewChild("step1"); + + protected step: number = 0; + protected steps = [this.policyForm, this.extensionButton]; + + protected singleOrgEnabled$: BehaviorSubject = new BehaviorSubject(false); + + setSingleOrgEnabled(enabled: boolean) { + this.singleOrgEnabled$.next(enabled); + } + + setStep(step: number) { + this.step = step; + } +} diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/autotype-policy.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/autotype-policy.component.ts index ce62a7ff5a3..ceace60cd99 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/autotype-policy.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/autotype-policy.component.ts @@ -18,6 +18,8 @@ export class DesktopAutotypeDefaultSettingPolicy extends BasePolicyEditDefinitio return configService.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype); } } +// 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: "autotype-policy.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/disable-send.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/disable-send.component.ts index 3b4df75e555..103420fbf51 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/disable-send.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/disable-send.component.ts @@ -12,6 +12,8 @@ export class DisableSendPolicy extends BasePolicyEditDefinition { component = DisableSendPolicyComponent; } +// 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: "disable-send.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts index bb2c40b7a76..9b46e228af9 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts @@ -10,7 +10,9 @@ export { RestrictedItemTypesPolicy } from "./restricted-item-types.component"; export { SendOptionsPolicy } from "./send-options.component"; export { SingleOrgPolicy } from "./single-org.component"; export { TwoFactorAuthenticationPolicy } from "./two-factor-authentication.component"; +export { UriMatchDefaultPolicy } from "./uri-match-default.component"; export { vNextOrganizationDataOwnershipPolicy, vNextOrganizationDataOwnershipPolicyComponent, } from "./vnext-organization-data-ownership.component"; +export { AutoConfirmPolicy } from "./auto-confirm-policy.component"; diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts index fe3d76a0907..c1223a2004b 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts @@ -26,6 +26,8 @@ export class MasterPasswordPolicy extends BasePolicyEditDefinition { component = MasterPasswordPolicyComponent; } +// 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: "master-password.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts index 94094b76f69..d832dff158a 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts @@ -22,6 +22,8 @@ export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition { } } +// 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: "organization-data-ownership.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/password-generator.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/password-generator.component.ts index e26d37bfdf2..e3a67362cc9 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/password-generator.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/password-generator.component.ts @@ -19,6 +19,8 @@ export class PasswordGeneratorPolicy extends BasePolicyEditDefinition { component = PasswordGeneratorPolicyComponent; } +// 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: "password-generator.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.ts index e95ef8a1422..ac768d47d6e 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.ts @@ -12,6 +12,8 @@ export class RemoveUnlockWithPinPolicy extends BasePolicyEditDefinition { component = RemoveUnlockWithPinPolicyComponent; } +// 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: "remove-unlock-with-pin.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/require-sso.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/require-sso.component.ts index 3f28c0cb068..904c29ca70d 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/require-sso.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/require-sso.component.ts @@ -19,6 +19,8 @@ export class RequireSsoPolicy extends BasePolicyEditDefinition { } } +// 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: "require-sso.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/reset-password.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/reset-password.component.ts index fafb0b32398..bfe149048e3 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/reset-password.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/reset-password.component.ts @@ -26,6 +26,8 @@ export class ResetPasswordPolicy extends BasePolicyEditDefinition { } } +// 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: "reset-password.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/restricted-item-types.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/restricted-item-types.component.ts index 8f2573f0da3..554542f8a84 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/restricted-item-types.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/restricted-item-types.component.ts @@ -12,6 +12,8 @@ export class RestrictedItemTypesPolicy extends BasePolicyEditDefinition { component = RestrictedItemTypesPolicyComponent; } +// 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: "restricted-item-types.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/send-options.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/send-options.component.ts index e581ed2f4c7..b8a59e8f8ef 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/send-options.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/send-options.component.ts @@ -13,6 +13,8 @@ export class SendOptionsPolicy extends BasePolicyEditDefinition { component = SendOptionsPolicyComponent; } +// 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: "send-options.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/single-org.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/single-org.component.ts index ecaa86b03bc..655c5f20610 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/single-org.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/single-org.component.ts @@ -12,6 +12,8 @@ export class SingleOrgPolicy extends BasePolicyEditDefinition { component = SingleOrgPolicyComponent; } +// 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: "single-org.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/two-factor-authentication.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/two-factor-authentication.component.ts index 13b7660c4e7..62f3d1f3466 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/two-factor-authentication.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/two-factor-authentication.component.ts @@ -12,6 +12,8 @@ export class TwoFactorAuthenticationPolicy extends BasePolicyEditDefinition { component = TwoFactorAuthenticationPolicyComponent; } +// 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: "two-factor-authentication.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/uri-match-default.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/uri-match-default.component.html new file mode 100644 index 00000000000..399a4ad2dcd --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/uri-match-default.component.html @@ -0,0 +1,22 @@ + + {{ "requireSsoPolicyReq" | i18n }} + + + + + {{ "turnOn" | i18n }} + + +
    + + {{ "uriMatchDetectionOptionsLabel" | i18n }} + + + + +
    diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/uri-match-default.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/uri-match-default.component.ts new file mode 100644 index 00000000000..5c0b667bea2 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/uri-match-default.component.ts @@ -0,0 +1,72 @@ +import { Component, ChangeDetectionStrategy } from "@angular/core"; +import { FormBuilder, FormControl, Validators } from "@angular/forms"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request"; +import { + UriMatchStrategy, + UriMatchStrategySetting, +} from "@bitwarden/common/models/domain/domain-service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { SharedModule } from "../../../../shared"; +import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component"; + +export class UriMatchDefaultPolicy extends BasePolicyEditDefinition { + name = "uriMatchDetectionPolicy"; + description = "uriMatchDetectionPolicyDesc"; + type = PolicyType.UriMatchDefaults; + component = UriMatchDefaultPolicyComponent; +} +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "uri-match-default.component.html", + imports: [SharedModule], +}) +export class UriMatchDefaultPolicyComponent extends BasePolicyEditComponent { + uriMatchOptions: { label: string; value: UriMatchStrategySetting | null; disabled?: boolean }[]; + + constructor( + private formBuilder: FormBuilder, + private i18nService: I18nService, + ) { + super(); + + this.data = this.formBuilder.group({ + uriMatchDetection: new FormControl(UriMatchStrategy.Domain, { + validators: [Validators.required], + nonNullable: true, + }), + }); + + this.uriMatchOptions = [ + { label: i18nService.t("baseDomain"), value: UriMatchStrategy.Domain }, + { label: i18nService.t("host"), value: UriMatchStrategy.Host }, + { label: i18nService.t("exact"), value: UriMatchStrategy.Exact }, + { label: i18nService.t("never"), value: UriMatchStrategy.Never }, + ]; + } + + protected loadData() { + const uriMatchDetection = this.policyResponse?.data?.uriMatchDetection; + + this.data?.patchValue({ + uriMatchDetection: uriMatchDetection, + }); + } + + protected buildRequestData() { + return { + uriMatchDetection: this.data?.value?.uriMatchDetection, + }; + } + + async buildRequest(): Promise { + const request = await super.buildRequest(); + if (request.data?.uriMatchDetection == null) { + throw new Error(this.i18nService.t("invalidUriMatchDefaultPolicySetting")); + } + + return request; + } +} diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html index 0abc40da683..bd2237bc2fd 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html @@ -1,5 +1,5 @@

    - {{ "organizationDataOwnershipContent" | i18n }} + {{ "organizationDataOwnershipDescContent" | i18n }} ; override async confirm(): Promise { if (this.policyResponse?.enabled && !this.enabled.value) { - const dialogRef = this.dialogService.open(this.warningContent); + const dialogRef = this.dialogService.open(this.warningContent, { + positionStrategy: new CenterPositionStrategy(), + }); const result = await lastValueFrom(dialogRef.closed); return Boolean(result); } @@ -70,7 +76,6 @@ export class vNextOrganizationDataOwnershipPolicyComponent const request: VNextPolicyRequest = { policy: { - type: this.policy.type, enabled: this.enabled.value ?? false, data: this.buildRequestData(), }, diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts index f0672d0f861..98b6d1c6bee 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts @@ -30,7 +30,7 @@ import { KeyService } from "@bitwarden/key-management"; import { SharedModule } from "../../../shared"; import { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component"; -import { vNextOrganizationDataOwnershipPolicyComponent } from "./policy-edit-definitions"; +import { vNextOrganizationDataOwnershipPolicyComponent } from "./policy-edit-definitions/vnext-organization-data-ownership.component"; export type PolicyEditDialogData = { /** @@ -45,11 +45,15 @@ export type PolicyEditDialogData = { export type PolicyEditDialogResult = "saved"; +// 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: "policy-edit-dialog.component.html", imports: [SharedModule], }) export class PolicyEditDialogComponent implements AfterViewInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("policyForm", { read: ViewContainerRef, static: true }) policyFormRef: ViewContainerRef | undefined; @@ -64,13 +68,13 @@ export class PolicyEditDialogComponent implements AfterViewInit { }); constructor( @Inject(DIALOG_DATA) protected data: PolicyEditDialogData, - private accountService: AccountService, - private policyApiService: PolicyApiServiceAbstraction, - private i18nService: I18nService, + protected accountService: AccountService, + protected policyApiService: PolicyApiServiceAbstraction, + protected i18nService: I18nService, private cdr: ChangeDetectorRef, private formBuilder: FormBuilder, - private dialogRef: DialogRef, - private toastService: ToastService, + protected dialogRef: DialogRef, + protected toastService: ToastService, private configService: ConfigService, private keyService: KeyService, ) {} diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts index 5e63ba1358a..a4bdece0a7b 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts @@ -1,5 +1,6 @@ import { BasePolicyEditDefinition } from "./base-policy-edit.component"; import { + AutoConfirmPolicy, DesktopAutotypeDefaultSettingPolicy, DisableSendPolicy, MasterPasswordPolicy, @@ -12,6 +13,7 @@ import { SendOptionsPolicy, SingleOrgPolicy, TwoFactorAuthenticationPolicy, + UriMatchDefaultPolicy, vNextOrganizationDataOwnershipPolicy, } from "./policy-edit-definitions"; @@ -33,4 +35,6 @@ export const ossPolicyEditRegister: BasePolicyEditDefinition[] = [ new SendOptionsPolicy(), new RestrictedItemTypesPolicy(), new DesktopAutotypeDefaultSettingPolicy(), + new UriMatchDefaultPolicy(), + new AutoConfirmPolicy(), ]; diff --git a/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts b/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts index 52cb24c90d1..6043bfd3193 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts @@ -14,6 +14,8 @@ import { ProductTierType } from "@bitwarden/common/billing/enums"; import { ReportVariant, reports, ReportType, ReportEntry } from "../../../dirt/reports"; +// 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-org-reports-home", templateUrl: "reports-home.component.html", diff --git a/apps/web/src/app/admin-console/organizations/settings/account.component.ts b/apps/web/src/app/admin-console/organizations/settings/account.component.ts index 21424e86521..ec8ba59b987 100644 --- a/apps/web/src/app/admin-console/organizations/settings/account.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/account.component.ts @@ -38,6 +38,8 @@ import { PurgeVaultComponent } from "../../../vault/settings/purge-vault.compone import { DeleteOrganizationDialogResult, openDeleteOrganizationDialog } from "./components"; +// 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-org-account", templateUrl: "account.component.html", @@ -166,18 +168,11 @@ export class AccountComponent implements OnInit, OnDestroy { return; } - const request = new OrganizationUpdateRequest(); - - /* - * When you disable a FormControl, it is removed from formGroup.values, so we have to use - * the original value. - * */ - request.name = this.formGroup.get("orgName").disabled - ? this.org.name - : this.formGroup.value.orgName; - request.billingEmail = this.formGroup.get("billingEmail").disabled - ? this.org.billingEmail - : this.formGroup.value.billingEmail; + // The server ignores any undefined values, so it's ok to reference disabled form fields here + const request: OrganizationUpdateRequest = { + name: this.formGroup.value.orgName, + billingEmail: this.formGroup.value.billingEmail, + }; // Backfill pub/priv key if necessary if (!this.org.hasPublicAndPrivateKeys) { diff --git a/apps/web/src/app/admin-console/organizations/settings/components/delete-organization-dialog.component.ts b/apps/web/src/app/admin-console/organizations/settings/components/delete-organization-dialog.component.ts index 1b41dc31a62..8cf1530cb7d 100644 --- a/apps/web/src/app/admin-console/organizations/settings/components/delete-organization-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/components/delete-organization-dialog.component.ts @@ -78,6 +78,8 @@ export enum DeleteOrganizationDialogResult { Canceled = "canceled", } +// 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-delete-organization", imports: [SharedModule, UserVerificationModule], diff --git a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts index 020a16dd932..95081af3a53 100644 --- a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts @@ -5,27 +5,30 @@ import { ActivatedRoute } from "@angular/router"; import { concatMap, takeUntil, map, lastValueFrom, firstValueFrom } from "rxjs"; import { first, tap } from "rxjs/operators"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { getOrganizationById, OrganizationService, } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { DialogRef, DialogService } from "@bitwarden/components"; +import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { TwoFactorSetupDuoComponent } from "../../../auth/settings/two-factor/two-factor-setup-duo.component"; import { TwoFactorSetupComponent as BaseTwoFactorSetupComponent } from "../../../auth/settings/two-factor/two-factor-setup.component"; import { TwoFactorVerifyComponent } from "../../../auth/settings/two-factor/two-factor-verify.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-two-factor-setup", templateUrl: "../../../auth/settings/two-factor/two-factor-setup.component.html", @@ -35,7 +38,7 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme tabbedHeader = false; constructor( dialogService: DialogService, - apiService: ApiService, + twoFactorService: TwoFactorService, messagingService: MessagingService, policyService: PolicyService, private route: ActivatedRoute, @@ -44,16 +47,20 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme protected accountService: AccountService, configService: ConfigService, i18nService: I18nService, + protected userVerificationService: UserVerificationService, + protected toastService: ToastService, ) { super( dialogService, - apiService, + twoFactorService, messagingService, policyService, billingAccountProfileStateService, accountService, configService, i18nService, + userVerificationService, + toastService, ); } @@ -116,7 +123,7 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme } protected getTwoFactorProviders() { - return this.apiService.getTwoFactorOrganizationProviders(this.organizationId); + return this.twoFactorService.getTwoFactorOrganizationProviders(this.organizationId); } protected filterProvider(type: TwoFactorProviderType): boolean { diff --git a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector-dialog.stories.ts b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector-dialog.stories.ts index 5cb61197b99..3e23eff13a9 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector-dialog.stories.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector-dialog.stories.ts @@ -25,7 +25,7 @@ const render: Story["render"] = (args) => ({ ...args, }, template: ` - + Access selector

    {{ permissionLabelId(item.readonlyPermission) | i18n }} diff --git a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.ts index 43843314ce5..89ecfd07174 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.ts @@ -45,6 +45,8 @@ export enum PermissionMode { Edit = "edit", } +// 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-access-selector", templateUrl: "access-selector.component.html", @@ -139,6 +141,8 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On /** * List of all selectable items that. Sorted internally. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get items(): AccessItemView[] { return this.selectionList.allItems; @@ -160,6 +164,8 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On /** * Permission mode that controls if the permission form controls and column should be present. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get permissionMode(): PermissionMode { return this._permissionMode; @@ -175,41 +181,64 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On /** * Column header for the selected items table */ - @Input() columnHeader: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() + columnHeader: string; /** * Label used for the ng selector */ - @Input() selectorLabelText: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() + selectorLabelText: string; /** * Helper text displayed under the ng selector */ - @Input() selectorHelpText: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() + selectorHelpText: string; /** * Text that is shown in the table when no items are selected */ - @Input() emptySelectionText: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() + emptySelectionText: string; /** * Flag for if the member roles column should be present */ - @Input() showMemberRoles: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() + showMemberRoles: boolean; /** * Flag for if the group column should be present */ - @Input() showGroupColumn: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() + showGroupColumn: boolean; /** * Hide the multi-select so that new items cannot be added */ - @Input() hideMultiSelect = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() + hideMultiSelect = false; /** * The initial permission that will be selected in the dialog, defaults to View. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() protected initialPermission: CollectionPermission = CollectionPermission.View; diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts index ea1a47d85cc..7b189270e1b 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts @@ -116,6 +116,8 @@ export enum CollectionDialogAction { Upgrade = "upgrade", } +// 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: "collection-dialog.component.html", imports: [SharedModule, AccessSelectorModule, SelectModule], diff --git a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts index c4fe0350006..c34073b2a04 100644 --- a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts +++ b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts @@ -18,6 +18,8 @@ import { BaseAcceptComponent } from "../../../common/base.accept.component"; * "Bitwarden allows all members of Enterprise Organizations to redeem a complimentary Families Plan with their * personal email address." - https://bitwarden.com/learning/free-families-plan-for-enterprise/ */ +// 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: "accept-family-sponsorship.component.html", imports: [CommonModule, I18nPipe, IconModule], diff --git a/apps/web/src/app/admin-console/organizations/sponsorships/families-for-enterprise-setup.component.ts b/apps/web/src/app/admin-console/organizations/sponsorships/families-for-enterprise-setup.component.ts index 30c0ba159c1..568c4922337 100644 --- a/apps/web/src/app/admin-console/organizations/sponsorships/families-for-enterprise-setup.component.ts +++ b/apps/web/src/app/admin-console/organizations/sponsorships/families-for-enterprise-setup.component.ts @@ -15,8 +15,9 @@ import { PreValidateSponsorshipResponse } from "@bitwarden/common/admin-console/ import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { PlanSponsorshipType, PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -28,18 +29,22 @@ import { openDeleteOrganizationDialog, } from "../settings/components"; +// 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: "families-for-enterprise-setup.component.html", imports: [SharedModule, OrganizationPlansComponent], }) export class FamiliesForEnterpriseSetupComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(OrganizationPlansComponent, { static: false }) set organizationPlansComponent(value: OrganizationPlansComponent) { if (!value) { return; } - value.plan = PlanType.FamiliesAnnually; + value.plan = this._familyPlan; value.productTier = ProductTierType.Families; value.acceptingSponsorship = true; value.planSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise; @@ -59,13 +64,14 @@ export class FamiliesForEnterpriseSetupComponent implements OnInit, OnDestroy { _selectedFamilyOrganizationId = ""; private _destroy = new Subject(); + private _familyPlan: PlanType; formGroup = this.formBuilder.group({ selectedFamilyOrganizationId: ["", Validators.required], }); constructor( private router: Router, - private platformUtilsService: PlatformUtilsService, + private configService: ConfigService, private i18nService: I18nService, private route: ActivatedRoute, private apiService: ApiService, @@ -116,6 +122,13 @@ export class FamiliesForEnterpriseSetupComponent implements OnInit, OnDestroy { this.badToken = !this.preValidateSponsorshipResponse.isTokenValid; } + const milestone3FeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM26462_Milestone_3, + ); + this._familyPlan = milestone3FeatureEnabled + ? PlanType.FamiliesAnnually + : PlanType.FamiliesAnnually2025; + this.loading = false; }); diff --git a/apps/web/src/app/admin-console/settings/create-organization.component.ts b/apps/web/src/app/admin-console/settings/create-organization.component.ts index f87e9ec5b72..78398fd8897 100644 --- a/apps/web/src/app/admin-console/settings/create-organization.component.ts +++ b/apps/web/src/app/admin-console/settings/create-organization.component.ts @@ -1,29 +1,47 @@ // 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 { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; import { first } from "rxjs/operators"; import { PlanType, ProductTierType, ProductType } 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 { OrganizationPlansComponent } from "../../billing"; import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "create-organization.component.html", imports: [SharedModule, OrganizationPlansComponent, HeaderModule], }) -export class CreateOrganizationComponent { +export class CreateOrganizationComponent implements OnInit, OnDestroy { protected secretsManager = false; protected plan: PlanType = PlanType.Free; protected productTier: ProductTierType = ProductTierType.Free; - constructor(private route: ActivatedRoute) { - this.route.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((qParams) => { + constructor( + private route: ActivatedRoute, + private configService: ConfigService, + ) {} + + private destroy$ = new Subject(); + + async ngOnInit(): Promise { + const milestone3FeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM26462_Milestone_3, + ); + const familyPlan = milestone3FeatureEnabled + ? PlanType.FamiliesAnnually + : PlanType.FamiliesAnnually2025; + + this.route.queryParams.pipe(first(), takeUntil(this.destroy$)).subscribe((qParams) => { if (qParams.plan === "families" || qParams.productTier == ProductTierType.Families) { - this.plan = PlanType.FamiliesAnnually; + this.plan = familyPlan; this.productTier = ProductTierType.Families; } else if (qParams.plan === "teams" || qParams.productTier == ProductTierType.Teams) { this.plan = PlanType.TeamsAnnually; @@ -45,4 +63,9 @@ export class CreateOrganizationComponent { this.secretsManager = qParams.product == ProductType.SecretsManager; }); } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 2fc81fe2119..c312b4edd7e 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -8,6 +8,7 @@ import { Subject, filter, firstValueFrom, map, timeout } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction"; import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n"; +import { LockService } from "@bitwarden/auth/common"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -16,7 +17,6 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service" import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; -import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -27,12 +27,14 @@ import { StateEventRunnerService } from "@bitwarden/common/platform/state"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; -import { DialogService, ToastService } from "@bitwarden/components"; +import { DialogService, RouterFocusManagerService, ToastService } from "@bitwarden/components"; import { KeyService, BiometricStateService } from "@bitwarden/key-management"; const BroadcasterSubscriptionId = "AppComponent"; const IdleTimeout = 60000 * 10; // 10 minutes +// 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-root", templateUrl: "app.component.html", @@ -56,8 +58,8 @@ export class AppComponent implements OnDestroy, OnInit { private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private ngZone: NgZone, - private vaultTimeoutService: VaultTimeoutService, private keyService: KeyService, + private lockService: LockService, private collectionService: CollectionService, private searchService: SearchService, private serverNotificationsService: ServerNotificationsService, @@ -74,11 +76,17 @@ export class AppComponent implements OnDestroy, OnInit { private readonly destroy: DestroyRef, private readonly documentLangSetter: DocumentLangSetter, private readonly tokenService: TokenService, + private readonly routerFocusManager: RouterFocusManagerService, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); const langSubscription = this.documentLangSetter.start(); - this.destroy.onDestroy(() => langSubscription.unsubscribe()); + + this.routerFocusManager.start$.pipe(takeUntilDestroyed()).subscribe(); + + this.destroy.onDestroy(() => { + langSubscription.unsubscribe(); + }); } ngOnInit() { @@ -111,11 +119,13 @@ export class AppComponent implements OnDestroy, OnInit { // note: the message.logoutReason isn't consumed anymore because of the process reload clearing any toasts. await this.logOut(message.redirect); break; - case "lockVault": - await this.vaultTimeoutService.lock(); + case "lockVault": { + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + await this.lockService.lock(userId); break; + } case "locked": - await this.processReloadService.startProcessReload(this.authService); + await this.processReloadService.startProcessReload(); break; case "lockedUrl": break; @@ -145,18 +155,6 @@ export class AppComponent implements OnDestroy, OnInit { } break; } - case "premiumRequired": { - const premiumConfirmed = await this.dialogService.openSimpleDialog({ - title: { key: "premiumRequired" }, - content: { key: "premiumRequiredDesc" }, - acceptButtonText: { key: "upgrade" }, - type: "success", - }); - if (premiumConfirmed) { - await this.router.navigate(["settings/subscription/premium"]); - } - break; - } case "emailVerificationRequired": { const emailVerificationConfirmed = await this.dialogService.openSimpleDialog({ title: { key: "emailVerificationRequired" }, @@ -258,7 +256,6 @@ export class AppComponent implements OnDestroy, OnInit { await Promise.all([ this.keyService.clearKeys(userId), this.cipherService.clear(userId), - // ! DO NOT REMOVE folderService.clear ! For more information see PM-25660 this.folderService.clear(userId), this.biometricStateService.logout(userId), ]); @@ -278,7 +275,7 @@ export class AppComponent implements OnDestroy, OnInit { await this.router.navigate(["/"]); } - await this.processReloadService.startProcessReload(this.authService); + await this.processReloadService.startProcessReload(); // Normally we would need to reset the loading state to false or remove the layout_frontend // class from the body here, but the process reload completely reloads the app so diff --git a/apps/web/src/app/auth/constants/auth-web-route.constant.ts b/apps/web/src/app/auth/constants/auth-web-route.constant.ts new file mode 100644 index 00000000000..c1e714786e9 --- /dev/null +++ b/apps/web/src/app/auth/constants/auth-web-route.constant.ts @@ -0,0 +1,35 @@ +// Web route segments auth owns under shared infrastructure +export const AuthWebRouteSegment = Object.freeze({ + // settings routes + Account: "account", + EmergencyAccess: "emergency-access", + + // settings/security routes + Password: "password", + TwoFactor: "two-factor", + SecurityKeys: "security-keys", + DeviceManagement: "device-management", +} as const); + +export type AuthWebRouteSegment = (typeof AuthWebRouteSegment)[keyof typeof AuthWebRouteSegment]; + +// Full routes that auth owns in the web app +export const AuthWebRoute = Object.freeze({ + SignUpLinkExpired: "signup-link-expired", + RecoverTwoFactor: "recover-2fa", + AcceptEmergencyAccessInvite: "accept-emergency", + RecoverDeleteAccount: "recover-delete", + VerifyRecoverDeleteAccount: "verify-recover-delete", + AcceptOrganizationInvite: "accept-organization", + + // Composed routes from segments (allowing for router.navigate / routerLink usage) + AccountSettings: `settings/${AuthWebRouteSegment.Account}`, + EmergencyAccessSettings: `settings/${AuthWebRouteSegment.EmergencyAccess}`, + + PasswordSettings: `settings/security/${AuthWebRouteSegment.Password}`, + TwoFactorSettings: `settings/security/${AuthWebRouteSegment.TwoFactor}`, + SecurityKeysSettings: `settings/security/${AuthWebRouteSegment.SecurityKeys}`, + DeviceManagement: `settings/security/${AuthWebRouteSegment.DeviceManagement}`, +} as const); + +export type AuthWebRoute = (typeof AuthWebRoute)[keyof typeof AuthWebRoute]; diff --git a/apps/web/src/app/auth/constants/index.ts b/apps/web/src/app/auth/constants/index.ts new file mode 100644 index 00000000000..3d84e3729de --- /dev/null +++ b/apps/web/src/app/auth/constants/index.ts @@ -0,0 +1 @@ +export * from "./auth-web-route.constant"; diff --git a/apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts b/apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts index 3073917e57b..02f870f094d 100644 --- a/apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts +++ b/apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts @@ -1,7 +1,16 @@ // FIXME: update to use a const object instead of a typescript enum // eslint-disable-next-line @bitwarden/platform/no-enums export enum WebauthnLoginCredentialPrfStatus { + /** + * Encrypted user key present, PRF function is supported. + */ Enabled = 0, + /** + * PRF function is supported. + */ Supported = 1, + /** + * PRF function is not supported. + */ Unsupported = 2, } diff --git a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts index 70f7686a2cd..647c9ae83d9 100644 --- a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts +++ b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts @@ -123,7 +123,9 @@ describe("WebSetInitialPasswordService", () => { userDecryptionOptions = new UserDecryptionOptions({ hasMasterPassword: true }); userDecryptionOptionsSubject = new BehaviorSubject(userDecryptionOptions); - userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject; + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + userDecryptionOptionsSubject, + ); setPasswordRequest = new SetPasswordRequest( credentials.newServerMasterKeyHash, diff --git a/apps/web/src/app/auth/core/services/rotateable-key-set.service.spec.ts b/apps/web/src/app/auth/core/services/rotateable-key-set.service.spec.ts deleted file mode 100644 index 8579c4c1dc8..00000000000 --- a/apps/web/src/app/auth/core/services/rotateable-key-set.service.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { TestBed } from "@angular/core/testing"; -import { mock, MockProxy } from "jest-mock-extended"; - -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { KeyService } from "@bitwarden/key-management"; - -import { RotateableKeySetService } from "./rotateable-key-set.service"; - -describe("RotateableKeySetService", () => { - let testBed!: TestBed; - let keyService!: MockProxy; - let encryptService!: MockProxy; - let service!: RotateableKeySetService; - - beforeEach(() => { - keyService = mock(); - encryptService = mock(); - testBed = TestBed.configureTestingModule({ - providers: [ - { provide: KeyService, useValue: keyService }, - { provide: EncryptService, useValue: encryptService }, - ], - }); - service = testBed.inject(RotateableKeySetService); - }); - - describe("createKeySet", () => { - it("should create a new key set", async () => { - const externalKey = createSymmetricKey(); - const userKey = createSymmetricKey(); - const encryptedUserKey = Symbol(); - const encryptedPublicKey = Symbol(); - const encryptedPrivateKey = Symbol(); - keyService.makeKeyPair.mockResolvedValue(["publicKey", encryptedPrivateKey as any]); - keyService.getUserKey.mockResolvedValue({ key: userKey.key } as any); - encryptService.encapsulateKeyUnsigned.mockResolvedValue(encryptedUserKey as any); - encryptService.wrapEncapsulationKey.mockResolvedValue(encryptedPublicKey as any); - - const result = await service.createKeySet(externalKey as any); - - expect(result).toEqual({ - encryptedUserKey, - encryptedPublicKey, - encryptedPrivateKey, - }); - }); - }); -}); - -function createSymmetricKey() { - const key = Utils.fromB64ToArray( - "1h-TuPwSbX5qoX0aVgjmda_Lfq85qAcKssBlXZnPIsQC3HNDGIecunYqXhJnp55QpdXRh-egJiLH3a0wqlVQsQ", - ); - return new SymmetricCryptoKey(key); -} diff --git a/apps/web/src/app/auth/core/services/rotateable-key-set.service.ts b/apps/web/src/app/auth/core/services/rotateable-key-set.service.ts deleted file mode 100644 index 0a150b26ae2..00000000000 --- a/apps/web/src/app/auth/core/services/rotateable-key-set.service.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { inject, Injectable } from "@angular/core"; - -import { RotateableKeySet } from "@bitwarden/auth/common"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { KeyService } from "@bitwarden/key-management"; - -@Injectable({ providedIn: "root" }) -export class RotateableKeySetService { - private readonly keyService = inject(KeyService); - private readonly encryptService = inject(EncryptService); - - /** - * Create a new rotateable key set for the current user, using the provided external key. - * For more information on rotateable key sets, see {@link RotateableKeySet} - * - * @param externalKey The `ExternalKey` used to encrypt {@link RotateableKeySet.encryptedPrivateKey} - * @returns RotateableKeySet containing the current users `UserKey` - */ - async createKeySet( - externalKey: ExternalKey, - ): Promise> { - const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(externalKey); - - const userKey = await this.keyService.getUserKey(); - const rawPublicKey = Utils.fromB64ToArray(publicKey); - const encryptedUserKey = await this.encryptService.encapsulateKeyUnsigned( - userKey, - rawPublicKey, - ); - const encryptedPublicKey = await this.encryptService.wrapEncapsulationKey( - rawPublicKey, - userKey, - ); - return new RotateableKeySet(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey); - } - - /** - * Rotates the current user's `UserKey` and updates the provided `RotateableKeySet` with the new keys. - * - * @param keySet The current `RotateableKeySet` for the user - * @returns The updated `RotateableKeySet` with the new `UserKey` - */ - async rotateKeySet( - keySet: RotateableKeySet, - oldUserKey: SymmetricCryptoKey, - newUserKey: SymmetricCryptoKey, - ): Promise> { - // validate parameters - if (!keySet) { - throw new Error("failed to rotate key set: keySet is required"); - } - if (!oldUserKey) { - throw new Error("failed to rotate key set: oldUserKey is required"); - } - if (!newUserKey) { - throw new Error("failed to rotate key set: newUserKey is required"); - } - - const publicKey = await this.encryptService.unwrapEncapsulationKey( - keySet.encryptedPublicKey, - oldUserKey, - ); - if (publicKey == null) { - throw new Error("failed to rotate key set: could not decrypt public key"); - } - const newEncryptedPublicKey = await this.encryptService.wrapEncapsulationKey( - publicKey, - newUserKey, - ); - const newEncryptedUserKey = await this.encryptService.encapsulateKeyUnsigned( - newUserKey, - publicKey, - ); - - const newRotateableKeySet = new RotateableKeySet( - newEncryptedUserKey, - newEncryptedPublicKey, - keySet.encryptedPrivateKey, - ); - - return newRotateableKeySet; - } -} diff --git a/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts b/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts index 85e7a7368e0..603e0f2a77d 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { RotateableKeySet } from "@bitwarden/auth/common"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { RotateableKeySet } from "@bitwarden/common/key-management/keys/models/rotateable-key-set"; import { BaseResponse } from "@bitwarden/common/models/response/base.response"; import { WebauthnLoginCredentialPrfStatus } from "../../../enums/webauthn-login-credential-prf-status.enum"; @@ -40,6 +40,6 @@ export class WebauthnLoginCredentialResponse extends BaseResponse { } hasPrfKeyset(): boolean { - return this.encryptedUserKey != null && this.encryptedPublicKey != null; + return this.prfStatus === WebauthnLoginCredentialPrfStatus.Enabled; } } diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts index c2a9946ea38..7e263b638e0 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts @@ -3,22 +3,29 @@ import { randomBytes } from "crypto"; import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; -import { RotateableKeySet } from "@bitwarden/auth/common"; 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 { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view"; import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { RotateableKeySet } from "@bitwarden/common/key-management/keys/models/rotateable-key-set"; +import { RotateableKeySetService } from "@bitwarden/common/key-management/keys/services/abstractions/rotateable-key-set.service"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { makeSymmetricCryptoKey } from "@bitwarden/common/spec"; +import { makeEncString, makeSymmetricCryptoKey } from "@bitwarden/common/spec"; import { PrfKey, UserKey } from "@bitwarden/common/types/key"; +import { newGuid } from "@bitwarden/guid"; +import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; +import { WebauthnLoginCredentialPrfStatus } from "../../enums/webauthn-login-credential-prf-status.enum"; import { CredentialCreateOptionsView } from "../../views/credential-create-options.view"; import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn-login-credential.view"; -import { RotateableKeySetService } from "../rotateable-key-set.service"; import { EnableCredentialEncryptionRequest } from "./request/enable-credential-encryption.request"; +import { WebauthnLoginCredentialResponse } from "./response/webauthn-login-credential.response"; import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service"; import { WebauthnLoginAdminService } from "./webauthn-login-admin.service"; @@ -28,9 +35,12 @@ describe("WebauthnAdminService", () => { let rotateableKeySetService!: MockProxy; let webAuthnLoginPrfKeyService!: MockProxy; let credentials: MockProxy; + let keyService: MockProxy; let service!: WebauthnLoginAdminService; let originalAuthenticatorAssertionResponse!: AuthenticatorAssertionResponse | any; + const mockUserId = newGuid() as UserId; + const mockUserKey = makeSymmetricCryptoKey(64) as UserKey; beforeAll(() => { // Polyfill missing class @@ -41,12 +51,14 @@ describe("WebauthnAdminService", () => { userVerificationService = mock(); rotateableKeySetService = mock(); webAuthnLoginPrfKeyService = mock(); + keyService = mock(); credentials = mock(); service = new WebauthnLoginAdminService( apiService, userVerificationService, rotateableKeySetService, webAuthnLoginPrfKeyService, + keyService, credentials, ); @@ -54,6 +66,8 @@ describe("WebauthnAdminService", () => { originalAuthenticatorAssertionResponse = global.AuthenticatorAssertionResponse; // Mock the global AuthenticatorAssertionResponse class b/c the class is only available in secure contexts global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse; + + keyService.userKey$.mockReturnValue(of(mockUserKey)); }); beforeEach(() => { @@ -120,7 +134,7 @@ describe("WebauthnAdminService", () => { const request = new EnableCredentialEncryptionRequest(); request.token = assertionOptions.token; request.deviceResponse = assertionOptions.deviceResponse; - request.encryptedUserKey = prfKeySet.encryptedUserKey.encryptedString; + request.encryptedUserKey = prfKeySet.encapsulatedDownstreamKey.encryptedString; request.encryptedPublicKey = prfKeySet.encryptedPublicKey.encryptedString; request.encryptedPrivateKey = prfKeySet.encryptedPrivateKey.encryptedString; @@ -131,10 +145,10 @@ describe("WebauthnAdminService", () => { const updateCredentialMock = jest.spyOn(apiService, "updateCredential").mockResolvedValue(); // Act - await service.enableCredentialEncryption(assertionOptions); + await service.enableCredentialEncryption(assertionOptions, mockUserId); // Assert - expect(createKeySetMock).toHaveBeenCalledWith(assertionOptions.prfKey); + expect(createKeySetMock).toHaveBeenCalledWith(assertionOptions.prfKey, mockUserKey); expect(updateCredentialMock).toHaveBeenCalledWith(request); }); @@ -157,7 +171,7 @@ describe("WebauthnAdminService", () => { // Act try { - await service.enableCredentialEncryption(assertionOptions); + await service.enableCredentialEncryption(assertionOptions, mockUserId); } catch (error) { // Assert expect(error).toEqual(new Error("invalid credential")); @@ -166,6 +180,19 @@ describe("WebauthnAdminService", () => { } }); + test.each([null, undefined, ""])("should throw an error when userId is %p", async (userId) => { + const response = new MockPublicKeyCredential(); + const assertionOptions: WebAuthnLoginCredentialAssertionView = + new WebAuthnLoginCredentialAssertionView( + "enable_credential_encryption_test_token", + new WebAuthnLoginAssertionResponseRequest(response), + {} as PrfKey, + ); + await expect( + service.enableCredentialEncryption(assertionOptions, userId as any), + ).rejects.toThrow("userId is required"); + }); + it("should throw error when WehAuthnLoginCredentialAssertionView is undefined", async () => { // Arrange const assertionOptions: WebAuthnLoginCredentialAssertionView = undefined; @@ -178,7 +205,7 @@ describe("WebauthnAdminService", () => { // Act try { - await service.enableCredentialEncryption(assertionOptions); + await service.enableCredentialEncryption(assertionOptions, mockUserId); } catch (error) { // Assert expect(error).toEqual(new Error("invalid credential")); @@ -248,6 +275,79 @@ describe("WebauthnAdminService", () => { expect(rotateKeySetMock).not.toHaveBeenCalled(); }); }); + + describe("getRotatedData", () => { + const mockRotatedPublicKey = makeEncString("rotated_encryptedPublicKey"); + const mockRotatedUserKey = makeEncString("rotated_encryptedUserKey"); + const oldUserKey = makeSymmetricCryptoKey(64) as UserKey; + const newUserKey = makeSymmetricCryptoKey(64) as UserKey; + const userId = Utils.newGuid() as UserId; + + it("should only include credentials with PRF keysets", async () => { + const responseUnsupported = new WebauthnLoginCredentialResponse({ + id: "test-credential-id-1", + name: "Test Credential 1", + prfStatus: WebauthnLoginCredentialPrfStatus.Unsupported, + encryptedPublicKey: null, + encryptedUserKey: null, + }); + const responseSupported = new WebauthnLoginCredentialResponse({ + id: "test-credential-id-2", + name: "Test Credential 2", + prfStatus: WebauthnLoginCredentialPrfStatus.Supported, + encryptedPublicKey: null, + encryptedUserKey: null, + }); + const responseEnabled = new WebauthnLoginCredentialResponse({ + id: "test-credential-id-3", + name: "Test Credential 3", + prfStatus: WebauthnLoginCredentialPrfStatus.Enabled, + encryptedPublicKey: makeEncString("encryptedPublicKey").toJSON(), + encryptedUserKey: makeEncString("encryptedUserKey").toJSON(), + }); + + apiService.getCredentials.mockResolvedValue( + new ListResponse( + { + data: [responseUnsupported, responseSupported, responseEnabled], + }, + WebauthnLoginCredentialResponse, + ), + ); + + rotateableKeySetService.rotateKeySet.mockResolvedValue( + new RotateableKeySet(mockRotatedUserKey, mockRotatedPublicKey), + ); + + const result = await service.getRotatedData(oldUserKey, newUserKey, userId); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual( + expect.objectContaining({ + id: "test-credential-id-3", + encryptedPublicKey: mockRotatedPublicKey, + encryptedUserKey: mockRotatedUserKey, + }), + ); + expect(rotateableKeySetService.rotateKeySet).toHaveBeenCalledTimes(1); + expect(rotateableKeySetService.rotateKeySet).toHaveBeenCalledWith( + responseEnabled.getRotateableKeyset(), + oldUserKey, + newUserKey, + ); + }); + + it("should error when getCredentials fails", async () => { + const expectedError = "API connection failed"; + apiService.getCredentials.mockRejectedValue(new Error(expectedError)); + + await expect(service.getRotatedData(oldUserKey, newUserKey, userId)).rejects.toThrow( + expectedError, + ); + + expect(rotateableKeySetService.rotateKeySet).not.toHaveBeenCalled(); + }); + }); }); function createCredentialCreateOptions(): CredentialCreateOptionsView { diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts index edcf521efb8..7765d01f75c 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts @@ -1,24 +1,34 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Injectable, Optional } from "@angular/core"; -import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, tap } from "rxjs"; +import { + BehaviorSubject, + filter, + firstValueFrom, + from, + map, + Observable, + shareReplay, + switchMap, + tap, +} from "rxjs"; -import { PrfKeySet } from "@bitwarden/auth/common"; 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 { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request"; import { WebAuthnLoginCredentialAssertionOptionsView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view"; import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view"; import { Verification } from "@bitwarden/common/auth/types/verification"; +import { PrfKeySet } from "@bitwarden/common/key-management/keys/models/rotateable-key-set"; +import { RotateableKeySetService } from "@bitwarden/common/key-management/keys/services/abstractions/rotateable-key-set.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 { UserKeyRotationDataProvider } from "@bitwarden/key-management"; +import { KeyService, UserKeyRotationDataProvider } from "@bitwarden/key-management"; import { CredentialCreateOptionsView } from "../../views/credential-create-options.view"; import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn-login-credential.view"; import { WebauthnLoginCredentialView } from "../../views/webauthn-login-credential.view"; -import { RotateableKeySetService } from "../rotateable-key-set.service"; import { EnableCredentialEncryptionRequest } from "./request/enable-credential-encryption.request"; import { SaveCredentialRequest } from "./request/save-credential.request"; @@ -55,6 +65,7 @@ export class WebauthnLoginAdminService private userVerificationService: UserVerificationService, private rotateableKeySetService: RotateableKeySetService, private webAuthnLoginPrfKeyService: WebAuthnLoginPrfKeyServiceAbstraction, + private keyService: KeyService, @Optional() navigatorCredentials?: CredentialsContainer, @Optional() private logService?: LogService, ) { @@ -131,10 +142,12 @@ export class WebauthnLoginAdminService * This will trigger the browsers WebAuthn API to generate a PRF-output. * * @param pendingCredential A credential created using `createCredential`. + * @param userId The target users id. * @returns A key set that can be saved to the server. Undefined is returned if the credential doesn't support PRF. */ async createKeySet( pendingCredential: PendingWebauthnLoginCredentialView, + userId: UserId, ): Promise { const nativeOptions: CredentialRequestOptions = { publicKey: { @@ -166,7 +179,8 @@ export class WebauthnLoginAdminService const symmetricPrfKey = await this.webAuthnLoginPrfKeyService.createSymmetricKeyFromPrf(prfResult); - return await this.rotateableKeySetService.createKeySet(symmetricPrfKey); + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + return await this.rotateableKeySetService.createKeySet(symmetricPrfKey, userKey); } catch (error) { this.logService?.error(error); return undefined; @@ -190,7 +204,7 @@ export class WebauthnLoginAdminService request.token = credential.createOptions.token; request.name = name; request.supportsPrf = credential.supportsPrf; - request.encryptedUserKey = prfKeySet?.encryptedUserKey.encryptedString; + request.encryptedUserKey = prfKeySet?.encapsulatedDownstreamKey.encryptedString; request.encryptedPublicKey = prfKeySet?.encryptedPublicKey.encryptedString; request.encryptedPrivateKey = prfKeySet?.encryptedPrivateKey.encryptedString; await this.apiService.saveCredential(request); @@ -204,23 +218,31 @@ export class WebauthnLoginAdminService * if there was a problem with the Credential Assertion. * * @param assertionOptions Options received from the server using `getCredentialAssertOptions`. + * @param userId The target users id. * @returns void */ async enableCredentialEncryption( assertionOptions: WebAuthnLoginCredentialAssertionView, + userId: UserId, ): Promise { if (assertionOptions === undefined || assertionOptions?.prfKey === undefined) { throw new Error("invalid credential"); } + if (!userId) { + throw new Error("userId is required"); + } + + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); const prfKeySet: PrfKeySet = await this.rotateableKeySetService.createKeySet( assertionOptions.prfKey, + userKey, ); const request = new EnableCredentialEncryptionRequest(); request.token = assertionOptions.token; request.deviceResponse = assertionOptions.deviceResponse; - request.encryptedUserKey = prfKeySet.encryptedUserKey.encryptedString; + request.encryptedUserKey = prfKeySet.encapsulatedDownstreamKey.encryptedString; request.encryptedPublicKey = prfKeySet.encryptedPublicKey.encryptedString; request.encryptedPrivateKey = prfKeySet.encryptedPrivateKey.encryptedString; await this.apiService.updateCredential(request); @@ -317,7 +339,7 @@ export class WebauthnLoginAdminService const request = new WebauthnRotateCredentialRequest( response.id, rotatedKeyset.encryptedPublicKey, - rotatedKeyset.encryptedUserKey, + rotatedKeyset.encapsulatedDownstreamKey, ); return request; }), diff --git a/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts b/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts index e1b7329504c..d1491e6d782 100644 --- a/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts +++ b/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts @@ -12,6 +12,8 @@ import { SharedModule } from "../../../shared"; import { EmergencyAccessModule } from "../emergency-access.module"; import { EmergencyAccessService } from "../services/emergency-access.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({ imports: [SharedModule, EmergencyAccessModule], templateUrl: "accept-emergency.component.html", diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts index 2ff38f6eab0..05d6094745c 100644 --- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts +++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts @@ -17,6 +17,7 @@ import { CsprngArray } from "@bitwarden/common/types/csprng"; import { UserId } from "@bitwarden/common/types/guid"; import { UserKey, MasterKey, UserPrivateKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { newGuid } from "@bitwarden/guid"; import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type"; @@ -44,6 +45,7 @@ describe("EmergencyAccessService", () => { const mockNewUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("trustedPublicKey")]; + const mockUserId = newGuid() as UserId; beforeAll(() => { emergencyAccessApiService = mock(); @@ -125,7 +127,7 @@ describe("EmergencyAccessService", () => { "mockUserPublicKeyEncryptedUserKey", ); - keyService.getUserKey.mockResolvedValueOnce(mockUserKey); + keyService.userKey$.mockReturnValue(of(mockUserKey)); encryptService.encapsulateKeyUnsigned.mockResolvedValueOnce( mockUserPublicKeyEncryptedUserKey, @@ -134,7 +136,7 @@ describe("EmergencyAccessService", () => { emergencyAccessApiService.postEmergencyAccessConfirm.mockResolvedValueOnce(); // Act - await emergencyAccessService.confirm(id, granteeId, publicKey); + await emergencyAccessService.confirm(id, granteeId, publicKey, mockUserId); // Assert expect(emergencyAccessApiService.postEmergencyAccessConfirm).toHaveBeenCalledWith(id, { diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts index cce8d9345b2..b91bc932e83 100644 --- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts +++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts @@ -175,11 +175,17 @@ export class EmergencyAccessService * Step 3 of the 3 step setup flow. * Intended for grantor. * @param id emergency access id - * @param token secret token provided in email + * @param granteeId id of the grantee * @param publicKey public key of grantee + * @param activeUserId the active user's id */ - async confirm(id: string, granteeId: string, publicKey: Uint8Array): Promise { - const userKey = await this.keyService.getUserKey(); + async confirm( + id: string, + granteeId: string, + publicKey: Uint8Array, + activeUserId: UserId, + ): Promise { + const userKey = await firstValueFrom(this.keyService.userKey$(activeUserId)); if (!userKey) { throw new Error("No user key found"); } diff --git a/apps/web/src/app/auth/guards/deep-link/deep-link.guard.spec.ts b/apps/web/src/app/auth/guards/deep-link/deep-link.guard.spec.ts index dba4dbd8357..31033b29154 100644 --- a/apps/web/src/app/auth/guards/deep-link/deep-link.guard.spec.ts +++ b/apps/web/src/app/auth/guards/deep-link/deep-link.guard.spec.ts @@ -11,18 +11,24 @@ import { RouterService } from "../../../core/router.service"; import { deepLinkGuard } from "./deep-link.guard"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "", standalone: false, }) export class GuardedRouteTestComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "", standalone: false, }) export class LockTestComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "", standalone: false, diff --git a/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.html b/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.html deleted file mode 100644 index 94dfac42976..00000000000 --- a/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.html +++ /dev/null @@ -1,54 +0,0 @@ - diff --git a/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.ts b/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.ts deleted file mode 100644 index 695e935b919..00000000000 --- a/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Component } from "@angular/core"; - -import { BaseLoginViaWebAuthnComponent } from "@bitwarden/angular/auth/components/base-login-via-webauthn.component"; -import { - TwoFactorAuthSecurityKeyIcon, - TwoFactorAuthSecurityKeyFailedIcon, -} from "@bitwarden/assets/svg"; - -@Component({ - selector: "app-login-via-webauthn", - templateUrl: "login-via-webauthn.component.html", - standalone: false, -}) -export class LoginViaWebAuthnComponent extends BaseLoginViaWebAuthnComponent { - protected readonly Icons = { - TwoFactorAuthSecurityKeyIcon, - TwoFactorAuthSecurityKeyFailedIcon, - }; -} diff --git a/apps/web/src/app/auth/login/login.module.ts b/apps/web/src/app/auth/login/login.module.ts deleted file mode 100644 index 9a99c84f727..00000000000 --- a/apps/web/src/app/auth/login/login.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NgModule } from "@angular/core"; - -import { CheckboxModule } from "@bitwarden/components"; - -import { SharedModule } from "../../../app/shared"; - -import { LoginViaWebAuthnComponent } from "./login-via-webauthn/login-via-webauthn.component"; - -@NgModule({ - imports: [SharedModule, CheckboxModule], - declarations: [LoginViaWebAuthnComponent], - exports: [LoginViaWebAuthnComponent], -}) -export class LoginModule {} diff --git a/apps/web/src/app/auth/organization-invite/accept-organization.component.ts b/apps/web/src/app/auth/organization-invite/accept-organization.component.ts index 09d4fc3e9ef..cb1175a7002 100644 --- a/apps/web/src/app/auth/organization-invite/accept-organization.component.ts +++ b/apps/web/src/app/auth/organization-invite/accept-organization.component.ts @@ -11,11 +11,14 @@ import { OrganizationInvite } from "@bitwarden/common/auth/services/organization import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ToastService } from "@bitwarden/components"; import { BaseAcceptComponent } from "../../common/base.accept.component"; import { AcceptOrganizationInviteService } from "./accept-organization.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({ templateUrl: "accept-organization.component.html", standalone: false, @@ -33,6 +36,7 @@ export class AcceptOrganizationComponent extends BaseAcceptComponent { private acceptOrganizationInviteService: AcceptOrganizationInviteService, private organizationInviteService: OrganizationInviteService, private accountService: AccountService, + private toastService: ToastService, ) { super(router, platformUtilsService, i18nService, route, authService); } @@ -49,14 +53,13 @@ export class AcceptOrganizationComponent extends BaseAcceptComponent { return; } - this.platformUtilService.showToast( - "success", - this.i18nService.t("inviteAccepted"), - invite.initOrganization + this.toastService.showToast({ + message: invite.initOrganization ? this.i18nService.t("inviteInitAcceptedDesc") - : this.i18nService.t("inviteAcceptedDesc"), - { timeout: 10000 }, - ); + : this.i18nService.t("invitationAcceptedDesc"), + variant: "success", + timeout: 10000, + }); await this.router.navigate(["/vault"]); } diff --git a/apps/web/src/app/auth/recover-delete.component.ts b/apps/web/src/app/auth/recover-delete.component.ts index 7381d526879..00b14f9a402 100644 --- a/apps/web/src/app/auth/recover-delete.component.ts +++ b/apps/web/src/app/auth/recover-delete.component.ts @@ -10,6 +10,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ToastService } from "@bitwarden/components"; +// 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-recover-delete", templateUrl: "recover-delete.component.html", diff --git a/apps/web/src/app/auth/recover-two-factor.component.ts b/apps/web/src/app/auth/recover-two-factor.component.ts index f606e803df3..9c033b88a75 100644 --- a/apps/web/src/app/auth/recover-two-factor.component.ts +++ b/apps/web/src/app/auth/recover-two-factor.component.ts @@ -16,6 +16,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ToastService } from "@bitwarden/components"; +// 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-recover-two-factor", templateUrl: "recover-two-factor.component.html", @@ -111,14 +113,37 @@ export class RecoverTwoFactorComponent implements OnInit { await this.router.navigate(["/settings/security/two-factor"]); } catch (error: unknown) { if (error instanceof ErrorResponse) { - this.logService.error("Error logging in automatically: ", error.message); - - if (error.message.includes("Two-step token is invalid")) { - this.formGroup.get("recoveryCode")?.setErrors({ - invalidRecoveryCode: { message: this.i18nService.t("invalidRecoveryCode") }, + if ( + error.message.includes( + "Two-factor recovery has been performed. SSO authentication is required.", + ) + ) { + // [PM-21153]: Organization users with as SSO requirement need to be able to recover 2FA, + // but still be bound by the SSO requirement to log in. Therefore, we show a success toast for recovering 2FA, + // but then inform them that they need to log in via SSO and redirect them to the login page. + // The response tested here is a specific message for this scenario from request validation. + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("twoStepRecoverDisabled"), }); + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("ssoLoginIsRequired"), + }); + + await this.router.navigate(["/login"]); } else { - this.validationService.showError(error.message); + this.logService.error("Error logging in automatically: ", error.message); + + if (error.message.includes("Two-step token is invalid")) { + this.formGroup.get("recoveryCode")?.setErrors({ + invalidRecoveryCode: { message: this.i18nService.t("invalidRecoveryCode") }, + }); + } else { + this.validationService.showError(error.message); + } } } else { this.logService.error("Error logging in automatically: ", error); diff --git a/apps/web/src/app/auth/settings/account/account.component.ts b/apps/web/src/app/auth/settings/account/account.component.ts index 921db19bc49..3e618b89dbe 100644 --- a/apps/web/src/app/auth/settings/account/account.component.ts +++ b/apps/web/src/app/auth/settings/account/account.component.ts @@ -1,11 +1,10 @@ import { Component, OnInit, OnDestroy } from "@angular/core"; -import { firstValueFrom, from, lastValueFrom, map, Observable, Subject, takeUntil } from "rxjs"; +import { firstValueFrom, lastValueFrom, map, Observable, Subject, takeUntil } from "rxjs"; +import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { DialogService } from "@bitwarden/components"; import { HeaderModule } from "../../../layouts/header/header.module"; @@ -19,6 +18,8 @@ import { DeleteAccountDialogComponent } from "./delete-account-dialog.component" import { ProfileComponent } from "./profile.component"; import { SetAccountVerifyDevicesDialogComponent } from "./set-account-verify-devices-dialog.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({ templateUrl: "account.component.html", imports: [ @@ -40,8 +41,7 @@ export class AccountComponent implements OnInit, OnDestroy { constructor( private accountService: AccountService, private dialogService: DialogService, - private userVerificationService: UserVerificationService, - private configService: ConfigService, + private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, private organizationService: OrganizationService, ) {} @@ -54,7 +54,7 @@ export class AccountComponent implements OnInit, OnDestroy { map((organizations) => organizations.some((o) => o.userIsManagedByOrganization === true)), ); - const hasMasterPassword$ = from(this.userVerificationService.hasMasterPassword()); + const hasMasterPassword$ = this.userDecryptionOptionsService.hasMasterPasswordById$(userId); this.showChangeEmail$ = hasMasterPassword$; diff --git a/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts b/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts index 6bb785fb8f5..6e6fac1404e 100644 --- a/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts +++ b/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts @@ -32,6 +32,8 @@ type ChangeAvatarDialogData = { profile: ProfileResponse; }; +// 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: "change-avatar-dialog.component.html", encapsulation: ViewEncapsulation.None, @@ -40,6 +42,8 @@ type ChangeAvatarDialogData = { export class ChangeAvatarDialogComponent implements OnInit, OnDestroy { profile: ProfileResponse; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("colorPicker") colorPickerElement: ElementRef; loading = false; diff --git a/apps/web/src/app/auth/settings/account/change-email.component.html b/apps/web/src/app/auth/settings/account/change-email.component.html index d4462c3b056..279ecdcfadf 100644 --- a/apps/web/src/app/auth/settings/account/change-email.component.html +++ b/apps/web/src/app/auth/settings/account/change-email.component.html @@ -3,7 +3,7 @@ {{ "changeEmailTwoFactorWarning" | i18n }} -
    +
    {{ "masterPass" | i18n }} { let fixture: ComponentFixture; let apiService: MockProxy; + let twoFactorService: MockProxy; let accountService: FakeAccountService; let keyService: MockProxy; let kdfConfigService: MockProxy; beforeEach(async () => { apiService = mock(); + twoFactorService = mock(); keyService = mock(); kdfConfigService = mock(); accountService = mockAccountServiceWith("UserId" as UserId); @@ -37,6 +40,7 @@ describe("ChangeEmailComponent", () => { providers: [ { provide: AccountService, useValue: accountService }, { provide: ApiService, useValue: apiService }, + { provide: TwoFactorService, useValue: twoFactorService }, { provide: I18nService, useValue: { t: (key: string) => key } }, { provide: KeyService, useValue: keyService }, { provide: MessagingService, useValue: mock() }, @@ -57,7 +61,7 @@ describe("ChangeEmailComponent", () => { describe("ngOnInit", () => { beforeEach(() => { - apiService.getTwoFactorProviders.mockResolvedValue({ + twoFactorService.getEnabledTwoFactorProviders.mockResolvedValue({ data: [{ type: TwoFactorProviderType.Email, enabled: true } as TwoFactorProviderResponse], } as ListResponse); }); diff --git a/apps/web/src/app/auth/settings/account/change-email.component.ts b/apps/web/src/app/auth/settings/account/change-email.component.ts index a55846a5c0f..3daf2240fb2 100644 --- a/apps/web/src/app/auth/settings/account/change-email.component.ts +++ b/apps/web/src/app/auth/settings/account/change-email.component.ts @@ -8,6 +8,7 @@ import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-p import { EmailTokenRequest } from "@bitwarden/common/auth/models/request/email-token.request"; import { EmailRequest } from "@bitwarden/common/auth/models/request/email.request"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { UserId } from "@bitwarden/common/types/guid"; @@ -16,6 +17,8 @@ import { KdfConfigService, KeyService } from "@bitwarden/key-management"; import { SharedModule } from "../../../shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-change-email", templateUrl: "change-email.component.html", @@ -37,6 +40,7 @@ export class ChangeEmailComponent implements OnInit { constructor( private accountService: AccountService, private apiService: ApiService, + private twoFactorService: TwoFactorService, private i18nService: I18nService, private keyService: KeyService, private messagingService: MessagingService, @@ -48,7 +52,7 @@ export class ChangeEmailComponent implements OnInit { async ngOnInit() { this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - const twoFactorProviders = await this.apiService.getTwoFactorProviders(); + const twoFactorProviders = await this.twoFactorService.getEnabledTwoFactorProviders(); this.showTwoFactorEmailWarning = twoFactorProviders.data.some( (p) => p.type === TwoFactorProviderType.Email && p.enabled, ); diff --git a/apps/web/src/app/auth/settings/account/danger-zone.component.html b/apps/web/src/app/auth/settings/account/danger-zone.component.html index 68173e12fd9..3bd934a2439 100644 --- a/apps/web/src/app/auth/settings/account/danger-zone.component.html +++ b/apps/web/src/app/auth/settings/account/danger-zone.component.html @@ -1,7 +1,10 @@

    {{ "dangerZone" | i18n }}

    -
    +
    diff --git a/apps/web/src/app/auth/settings/account/danger-zone.component.ts b/apps/web/src/app/auth/settings/account/danger-zone.component.ts index 05fd22d087d..e60ff6ec03d 100644 --- a/apps/web/src/app/auth/settings/account/danger-zone.component.ts +++ b/apps/web/src/app/auth/settings/account/danger-zone.component.ts @@ -9,6 +9,8 @@ import { I18nPipe } from "@bitwarden/ui-common"; /** * Component for the Danger Zone section of the Account/Organization Settings page. */ +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-danger-zone", templateUrl: "danger-zone.component.html", diff --git a/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.ts b/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.ts index f75320e8335..b792963ae9b 100644 --- a/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.ts +++ b/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.ts @@ -12,6 +12,8 @@ import { DialogService, ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "deauthorize-sessions.component.html", imports: [SharedModule, UserVerificationFormInputComponent], diff --git a/apps/web/src/app/auth/settings/account/delete-account-dialog.component.ts b/apps/web/src/app/auth/settings/account/delete-account-dialog.component.ts index 7e8f169994f..76eb067fdd2 100644 --- a/apps/web/src/app/auth/settings/account/delete-account-dialog.component.ts +++ b/apps/web/src/app/auth/settings/account/delete-account-dialog.component.ts @@ -12,6 +12,8 @@ import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "delete-account-dialog.component.html", imports: [SharedModule, UserVerificationFormInputComponent], diff --git a/apps/web/src/app/auth/settings/account/profile.component.html b/apps/web/src/app/auth/settings/account/profile.component.html index a49e6c31d2e..b3972925598 100644 --- a/apps/web/src/app/auth/settings/account/profile.component.html +++ b/apps/web/src/app/auth/settings/account/profile.component.html @@ -8,7 +8,7 @@
    -
    +
    {{ "name" | i18n }} @@ -18,7 +18,7 @@
    -
    +
    diff --git a/apps/web/src/app/auth/settings/account/profile.component.ts b/apps/web/src/app/auth/settings/account/profile.component.ts index 54f9ac58291..fd96f343b3a 100644 --- a/apps/web/src/app/auth/settings/account/profile.component.ts +++ b/apps/web/src/app/auth/settings/account/profile.component.ts @@ -23,6 +23,8 @@ import { AccountFingerprintComponent } from "../../../shared/components/account- import { ChangeAvatarDialogComponent } from "./change-avatar-dialog.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-profile", templateUrl: "profile.component.html", @@ -56,7 +58,9 @@ export class ProfileComponent implements OnInit, OnDestroy { this.profile = await this.apiService.getProfile(); const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); this.fingerprintMaterial = userId; - const publicKey = await firstValueFrom(this.keyService.userPublicKey$(userId)); + const publicKey = (await firstValueFrom( + this.keyService.userPublicKey$(userId), + )) as UserPublicKey; if (publicKey == null) { this.logService.error( "[ProfileComponent] No public key available for the user: " + diff --git a/apps/web/src/app/auth/settings/account/selectable-avatar.component.ts b/apps/web/src/app/auth/settings/account/selectable-avatar.component.ts index 630c0e949ad..b74cf8beb59 100644 --- a/apps/web/src/app/auth/settings/account/selectable-avatar.component.ts +++ b/apps/web/src/app/auth/settings/account/selectable-avatar.component.ts @@ -5,6 +5,8 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; import { AvatarModule } from "@bitwarden/components"; +// 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: "selectable-avatar", template: `(); onFire() { diff --git a/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts b/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts index 63a26f08eee..97f50df24c8 100644 --- a/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts +++ b/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts @@ -5,11 +5,11 @@ import { firstValueFrom, Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { SetVerifyDevicesRequest } from "@bitwarden/common/auth/models/request/set-verify-devices.request"; +import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; import { Verification } from "@bitwarden/common/auth/types/verification"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -27,6 +27,8 @@ import { ToastService, } from "@bitwarden/components"; +// 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: "./set-account-verify-devices-dialog.component.html", imports: [ @@ -64,7 +66,7 @@ export class SetAccountVerifyDevicesDialogComponent implements OnInit, OnDestroy private userVerificationService: UserVerificationService, private dialogRef: DialogRef, private toastService: ToastService, - private apiService: ApiService, + private twoFactorService: TwoFactorService, ) { this.accountService.accountVerifyNewDeviceLogin$ .pipe(takeUntil(this.destroy$)) @@ -74,7 +76,7 @@ export class SetAccountVerifyDevicesDialogComponent implements OnInit, OnDestroy } async ngOnInit() { - const twoFactorProviders = await this.apiService.getTwoFactorProviders(); + const twoFactorProviders = await this.twoFactorService.getEnabledTwoFactorProviders(); this.has2faConfigured = twoFactorProviders.data.length > 0; } diff --git a/apps/web/src/app/auth/settings/emergency-access/confirm/emergency-access-confirm.component.ts b/apps/web/src/app/auth/settings/emergency-access/confirm/emergency-access-confirm.component.ts index 641dde66cc4..a5fdd5212fa 100644 --- a/apps/web/src/app/auth/settings/emergency-access/confirm/emergency-access-confirm.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/confirm/emergency-access-confirm.component.ts @@ -25,6 +25,8 @@ type EmergencyAccessConfirmDialogData = { /** user public key */ publicKey: Uint8Array; }; +// 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: "emergency-access-confirm.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/auth/settings/emergency-access/emergency-access-add-edit.component.ts b/apps/web/src/app/auth/settings/emergency-access/emergency-access-add-edit.component.ts index 04b549e7f05..2e8d02a0c4f 100644 --- a/apps/web/src/app/auth/settings/emergency-access/emergency-access-add-edit.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/emergency-access-add-edit.component.ts @@ -35,6 +35,8 @@ export enum EmergencyAccessAddEditDialogResult { Canceled = "canceled", Deleted = "deleted", } +// 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: "emergency-access-add-edit.component.html", imports: [SharedModule, PremiumBadgeComponent], diff --git a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts index de30205e6fe..7ef94706ef6 100644 --- a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, OnInit } from "@angular/core"; -import { lastValueFrom, Observable, firstValueFrom, switchMap } from "rxjs"; +import { lastValueFrom, Observable, firstValueFrom, switchMap, map } from "rxjs"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; @@ -42,6 +42,8 @@ import { EmergencyAccessTakeoverDialogResultType, } from "./takeover/emergency-access-takeover-dialog.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({ templateUrl: "emergency-access.component.html", imports: [SharedModule, HeaderModule, PremiumBadgeComponent], @@ -94,15 +96,6 @@ export class EmergencyAccessComponent implements OnInit { this.loaded = true; } - async premiumRequired() { - const canAccessPremium = await firstValueFrom(this.canAccessPremium$); - - if (!canAccessPremium) { - this.messagingService.send("premiumRequired"); - return; - } - } - edit = async (details: GranteeEmergencyAccess) => { const canAccessPremium = await firstValueFrom(this.canAccessPremium$); const dialogRef = EmergencyAccessAddEditComponent.open(this.dialogService, { @@ -165,7 +158,15 @@ export class EmergencyAccessComponent implements OnInit { }); const result = await lastValueFrom(dialogRef.closed); if (result === EmergencyAccessConfirmDialogResult.Confirmed) { - await this.emergencyAccessService.confirm(contact.id, contact.granteeId, publicKey); + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(getUserId), + ); + await this.emergencyAccessService.confirm( + contact.id, + contact.granteeId, + publicKey, + activeUserId, + ); updateUser(); this.toastService.showToast({ variant: "success", @@ -176,10 +177,14 @@ export class EmergencyAccessComponent implements OnInit { return; } + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); this.actionPromise = this.emergencyAccessService.confirm( contact.id, contact.granteeId, publicKey, + activeUserId, ); await this.actionPromise; updateUser(); diff --git a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts index e5c21fb82b9..743f41537e9 100644 --- a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts @@ -48,6 +48,8 @@ export type EmergencyAccessTakeoverDialogResultType = * * @link https://bitwarden.com/help/emergency-access/ */ +// 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-emergency-access-takeover-dialog", templateUrl: "./emergency-access-takeover-dialog.component.html", @@ -61,6 +63,8 @@ export type EmergencyAccessTakeoverDialogResultType = ], }) export class EmergencyAccessTakeoverDialogComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(InputPasswordComponent) inputPasswordComponent: InputPasswordComponent | undefined = undefined; diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts index 250261fb0e7..1d96a19ca74 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts @@ -14,6 +14,8 @@ import { EmergencyAccessService } from "../../../emergency-access"; import { EmergencyViewDialogComponent } from "./emergency-view-dialog.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({ templateUrl: "emergency-access-view.component.html", providers: [{ provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }], diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts index 60993924ded..d13987f2e8b 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts @@ -8,6 +8,7 @@ import { CollectionService } from "@bitwarden/admin-console/common"; 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 { 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"; @@ -16,6 +17,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId, EmergencyAccessId } from "@bitwarden/common/types/guid"; +import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -68,6 +70,12 @@ describe("EmergencyViewDialogComponent", () => { useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) }, }, { provide: DomainSettingsService, useValue: { showFavicons$: of(true) } }, + { provide: CipherRiskService, useValue: mock() }, + { + provide: BillingAccountProfileStateService, + useValue: mock(), + }, + { provide: ConfigService, useValue: mock() }, ], }) .overrideComponent(EmergencyViewDialogComponent, { @@ -78,7 +86,6 @@ describe("EmergencyViewDialogComponent", () => { provide: ChangeLoginPasswordService, useValue: ChangeLoginPasswordService, }, - { provide: ConfigService, useValue: ConfigService }, { provide: CipherService, useValue: mock() }, ], }, @@ -89,7 +96,6 @@ describe("EmergencyViewDialogComponent", () => { provide: ChangeLoginPasswordService, useValue: mock(), }, - { provide: ConfigService, useValue: mock() }, { provide: CipherService, useValue: mock() }, ], }, diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts index 656ec894f27..62cfd95ecfa 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts @@ -35,6 +35,8 @@ class PremiumUpgradePromptNoop implements PremiumUpgradePromptService { } } +// 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-emergency-view-dialog", templateUrl: "emergency-view-dialog.component.html", diff --git a/apps/web/src/app/auth/settings/security/api-key.component.ts b/apps/web/src/app/auth/settings/security/api-key.component.ts index 82d1010f020..af49ca556ab 100644 --- a/apps/web/src/app/auth/settings/security/api-key.component.ts +++ b/apps/web/src/app/auth/settings/security/api-key.component.ts @@ -23,6 +23,8 @@ export type ApiKeyDialogData = { apiKeyWarning: string; apiKeyDescription: string; }; +// 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: "api-key.component.html", imports: [SharedModule, UserVerificationFormInputComponent], diff --git a/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.ts b/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.ts index 0698ffe1f8d..ee283d26415 100644 --- a/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.ts +++ b/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.ts @@ -5,11 +5,15 @@ import { firstValueFrom } from "rxjs"; import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password"; import { InputPasswordFlow } from "@bitwarden/auth/angular"; import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { CalloutModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { WebauthnLoginSettingsModule } from "../../webauthn-login-settings"; +// 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-password-settings", templateUrl: "password-settings.component.html", @@ -22,12 +26,15 @@ export class PasswordSettingsComponent implements OnInit { constructor( private router: Router, private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + private accountService: AccountService, ) {} async ngOnInit() { + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const userHasMasterPassword = await firstValueFrom( - this.userDecryptionOptionsService.hasMasterPassword$, + this.userDecryptionOptionsService.hasMasterPasswordById$(userId), ); + if (!userHasMasterPassword) { await this.router.navigate(["/settings/security/two-factor"]); return; diff --git a/apps/web/src/app/auth/settings/security/security-keys.component.ts b/apps/web/src/app/auth/settings/security/security-keys.component.ts index 9d16d4380eb..b62828a2783 100644 --- a/apps/web/src/app/auth/settings/security/security-keys.component.ts +++ b/apps/web/src/app/auth/settings/security/security-keys.component.ts @@ -1,11 +1,10 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, OnInit } from "@angular/core"; import { firstValueFrom, map } from "rxjs"; +import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { DialogService } from "@bitwarden/components"; import { ChangeKdfModule } from "../../../key-management/change-kdf/change-kdf.module"; @@ -13,6 +12,8 @@ import { SharedModule } from "../../../shared"; import { ApiKeyComponent } from "./api-key.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({ templateUrl: "security-keys.component.html", imports: [SharedModule, ChangeKdfModule], @@ -21,20 +22,28 @@ export class SecurityKeysComponent implements OnInit { showChangeKdf = true; constructor( - private userVerificationService: UserVerificationService, + private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, private accountService: AccountService, private apiService: ApiService, private dialogService: DialogService, ) {} async ngOnInit() { - this.showChangeKdf = await this.userVerificationService.hasMasterPassword(); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + this.showChangeKdf = await firstValueFrom( + this.userDecryptionOptionsService.hasMasterPasswordById$(userId), + ); } async viewUserApiKey() { const entityId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); + + if (!entityId) { + throw new Error("Active account not found"); + } + await ApiKeyComponent.open(this.dialogService, { data: { keyType: "user", @@ -53,6 +62,11 @@ export class SecurityKeysComponent implements OnInit { const entityId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); + + if (!entityId) { + throw new Error("Active account not found"); + } + await ApiKeyComponent.open(this.dialogService, { data: { keyType: "user", diff --git a/apps/web/src/app/auth/settings/security/security-routing.module.ts b/apps/web/src/app/auth/settings/security/security-routing.module.ts index ba476dc9106..dbcfc7cb18b 100644 --- a/apps/web/src/app/auth/settings/security/security-routing.module.ts +++ b/apps/web/src/app/auth/settings/security/security-routing.module.ts @@ -2,7 +2,10 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-management/device-management.component"; +import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { SessionTimeoutComponent } from "../../../key-management/session-timeout/session-timeout.component"; import { TwoFactorSetupComponent } from "../two-factor/two-factor-setup.component"; import { PasswordSettingsComponent } from "./password-settings/password-settings.component"; @@ -15,7 +18,20 @@ const routes: Routes = [ component: SecurityComponent, data: { titleId: "security" }, children: [ - { path: "", pathMatch: "full", redirectTo: "password" }, + { path: "", pathMatch: "full", redirectTo: "session-timeout" }, + { + path: "session-timeout", + component: SessionTimeoutComponent, + canActivate: [ + canAccessFeature( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + true, + "/settings/security/password", + false, + ), + ], + data: { titleId: "sessionTimeoutHeader" }, + }, { path: "password", component: PasswordSettingsComponent, diff --git a/apps/web/src/app/auth/settings/security/security.component.html b/apps/web/src/app/auth/settings/security/security.component.html index 355a33d4427..6942713443f 100644 --- a/apps/web/src/app/auth/settings/security/security.component.html +++ b/apps/web/src/app/auth/settings/security/security.component.html @@ -1,8 +1,11 @@ - + @if (consolidatedSessionTimeoutComponent$ | async) { + {{ "sessionTimeoutHeader" | i18n }} + } + @if (showChangePassword) { {{ "masterPassword" | i18n }} - + } {{ "twoStepLogin" | i18n }} {{ "devices" | i18n }} {{ "keys" | i18n }} diff --git a/apps/web/src/app/auth/settings/security/security.component.ts b/apps/web/src/app/auth/settings/security/security.component.ts index 2a237bf6d01..85bc29fac63 100644 --- a/apps/web/src/app/auth/settings/security/security.component.ts +++ b/apps/web/src/app/auth/settings/security/security.component.ts @@ -1,10 +1,17 @@ import { Component, OnInit } from "@angular/core"; +import { firstValueFrom, Observable } from "rxjs"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; +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 { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "security.component.html", imports: [SharedModule, HeaderModule], @@ -12,10 +19,22 @@ import { SharedModule } from "../../../shared"; export class SecurityComponent implements OnInit { showChangePassword = true; changePasswordRoute = "password"; + consolidatedSessionTimeoutComponent$: Observable; - constructor(private userVerificationService: UserVerificationService) {} + constructor( + private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + private accountService: AccountService, + private configService: ConfigService, + ) { + this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + ); + } async ngOnInit() { - this.showChangePassword = await this.userVerificationService.hasMasterPassword(); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + this.showChangePassword = userId + ? await firstValueFrom(this.userDecryptionOptionsService.hasMasterPasswordById$(userId)) + : false; } } diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-recovery.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-recovery.component.ts index 37d94bfae0e..543c4236d89 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-recovery.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-recovery.component.ts @@ -15,6 +15,8 @@ import { } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +// 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-two-factor-recovery", templateUrl: "two-factor-recovery.component.html", diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts index 698e0911b04..d93a5947445 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts @@ -6,13 +6,13 @@ import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angu import { firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request"; import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request"; import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response"; +import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -53,6 +53,8 @@ declare global { } } +// 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-two-factor-setup-authenticator", templateUrl: "two-factor-setup-authenticator.component.html", @@ -76,6 +78,8 @@ export class TwoFactorSetupAuthenticatorComponent extends TwoFactorSetupMethodBaseComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onChangeStatus = new EventEmitter(); type = TwoFactorProviderType.Authenticator; key: string; @@ -92,7 +96,7 @@ export class TwoFactorSetupAuthenticatorComponent constructor( @Inject(DIALOG_DATA) protected data: AuthResponse, private dialogRef: DialogRef, - apiService: ApiService, + twoFactorService: TwoFactorService, i18nService: I18nService, userVerificationService: UserVerificationService, private formBuilder: FormBuilder, @@ -104,7 +108,7 @@ export class TwoFactorSetupAuthenticatorComponent protected toastService: ToastService, ) { super( - apiService, + twoFactorService, i18nService, platformUtilsService, logService, @@ -154,7 +158,7 @@ export class TwoFactorSetupAuthenticatorComponent request.key = this.key; request.userVerificationToken = this.userVerificationToken; - const response = await this.apiService.putTwoFactorAuthenticator(request); + const response = await this.twoFactorService.putTwoFactorAuthenticator(request); await this.processResponse(response); this.onUpdated.emit(true); } @@ -174,7 +178,7 @@ export class TwoFactorSetupAuthenticatorComponent request.type = this.type; request.key = this.key; request.userVerificationToken = this.userVerificationToken; - await this.apiService.deleteTwoFactorAuthenticator(request); + await this.twoFactorService.deleteTwoFactorAuthenticator(request); this.enabled = false; this.toastService.showToast({ variant: "success", diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts index 0efd0c79b4e..b4c8ece92a7 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts @@ -2,11 +2,11 @@ import { CommonModule } from "@angular/common"; import { Component, EventEmitter, Inject, OnInit, Output } from "@angular/core"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { UpdateTwoFactorDuoRequest } from "@bitwarden/common/auth/models/request/update-two-factor-duo.request"; import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response"; +import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -30,6 +30,8 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-base.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-two-factor-setup-duo", templateUrl: "two-factor-setup-duo.component.html", @@ -51,6 +53,8 @@ export class TwoFactorSetupDuoComponent extends TwoFactorSetupMethodBaseComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onChangeStatus: EventEmitter = new EventEmitter(); type = TwoFactorProviderType.Duo; @@ -63,7 +67,7 @@ export class TwoFactorSetupDuoComponent constructor( @Inject(DIALOG_DATA) protected data: TwoFactorDuoComponentConfig, - apiService: ApiService, + twoFactorService: TwoFactorService, i18nService: I18nService, platformUtilsService: PlatformUtilsService, logService: LogService, @@ -74,7 +78,7 @@ export class TwoFactorSetupDuoComponent protected toastService: ToastService, ) { super( - apiService, + twoFactorService, i18nService, platformUtilsService, logService, @@ -139,9 +143,12 @@ export class TwoFactorSetupDuoComponent let response: TwoFactorDuoResponse; if (this.organizationId != null) { - response = await this.apiService.putTwoFactorOrganizationDuo(this.organizationId, request); + response = await this.twoFactorService.putTwoFactorOrganizationDuo( + this.organizationId, + request, + ); } else { - response = await this.apiService.putTwoFactorDuo(request); + response = await this.twoFactorService.putTwoFactorDuo(request); } this.processResponse(response); diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts index 544f3850ea6..1402d6b8969 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts @@ -3,13 +3,13 @@ import { Component, EventEmitter, Inject, OnInit, Output } from "@angular/core"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import { firstValueFrom, map } from "rxjs"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; import { UpdateTwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/update-two-factor-email.request"; import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response"; +import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -33,6 +33,8 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-base.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-two-factor-setup-email", templateUrl: "two-factor-setup-email.component.html", @@ -54,6 +56,8 @@ export class TwoFactorSetupEmailComponent extends TwoFactorSetupMethodBaseComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onChangeStatus: EventEmitter = new EventEmitter(); type = TwoFactorProviderType.Email; sentEmail: string = ""; @@ -66,7 +70,7 @@ export class TwoFactorSetupEmailComponent constructor( @Inject(DIALOG_DATA) protected data: AuthResponse, - apiService: ApiService, + twoFactorService: TwoFactorService, i18nService: I18nService, platformUtilsService: PlatformUtilsService, logService: LogService, @@ -78,7 +82,7 @@ export class TwoFactorSetupEmailComponent protected toastService: ToastService, ) { super( - apiService, + twoFactorService, i18nService, platformUtilsService, logService, @@ -131,7 +135,7 @@ export class TwoFactorSetupEmailComponent sendEmail = async () => { const request = await this.buildRequestModel(TwoFactorEmailRequest); request.email = this.email; - this.emailPromise = this.apiService.postTwoFactorEmailSetup(request); + this.emailPromise = this.twoFactorService.postTwoFactorEmailSetup(request); await this.emailPromise; this.sentEmail = this.email; }; @@ -141,7 +145,7 @@ export class TwoFactorSetupEmailComponent request.email = this.email; request.token = this.token; - const response = await this.apiService.putTwoFactorEmail(request); + const response = await this.twoFactorService.putTwoFactorEmail(request); await this.processResponse(response); this.onUpdated.emit(true); } diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts index 7569577e781..5494353449d 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts @@ -1,11 +1,11 @@ import { Directive, EventEmitter, Output } from "@angular/core"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request"; +import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; import { AuthResponseBase } from "@bitwarden/common/auth/types/auth-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -17,6 +17,8 @@ import { DialogService, ToastService } from "@bitwarden/components"; */ @Directive({}) export abstract class TwoFactorSetupMethodBaseComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onUpdated = new EventEmitter(); type: TwoFactorProviderType | undefined; @@ -25,12 +27,12 @@ export abstract class TwoFactorSetupMethodBaseComponent { enabled = false; authed = false; - protected hashedSecret: string | undefined; + protected secret: string | undefined; protected verificationType: VerificationType | undefined; protected componentName = ""; constructor( - protected apiService: ApiService, + protected twoFactorService: TwoFactorService, protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, protected logService: LogService, @@ -40,60 +42,11 @@ export abstract class TwoFactorSetupMethodBaseComponent { ) {} protected auth(authResponse: AuthResponseBase) { - this.hashedSecret = authResponse.secret; + this.secret = authResponse.secret; this.verificationType = authResponse.verificationType; this.authed = true; } - /** @deprecated used for formPromise flows.*/ - protected async enable(enableFunction: () => Promise) { - try { - await enableFunction(); - this.onUpdated.emit(true); - } catch (e) { - this.logService.error(e); - } - } - - /** - * @deprecated used for formPromise flows. - * TODO: Remove this method when formPromises are removed from all flows. - * */ - protected async disable(promise: Promise) { - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "disable" }, - content: { key: "twoStepDisableDesc" }, - type: "warning", - }); - - if (!confirmed) { - return; - } - - try { - const request = await this.buildRequestModel(TwoFactorProviderRequest); - if (this.type === undefined) { - throw new Error("Two-factor provider type is required"); - } - request.type = this.type; - if (this.organizationId != null) { - promise = this.apiService.putTwoFactorOrganizationDisable(this.organizationId, request); - } else { - promise = this.apiService.putTwoFactorDisable(request); - } - await promise; - this.enabled = false; - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("twoStepDisabled"), - }); - this.onUpdated.emit(false); - } catch (e) { - this.logService.error(e); - } - } - protected async disableMethod() { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "disable" }, @@ -111,9 +64,9 @@ export abstract class TwoFactorSetupMethodBaseComponent { } request.type = this.type; if (this.organizationId != null) { - await this.apiService.putTwoFactorOrganizationDisable(this.organizationId, request); + await this.twoFactorService.putTwoFactorOrganizationDisable(this.organizationId, request); } else { - await this.apiService.putTwoFactorDisable(request); + await this.twoFactorService.putTwoFactorDisable(request); } this.enabled = false; this.toastService.showToast({ @@ -127,12 +80,12 @@ export abstract class TwoFactorSetupMethodBaseComponent { protected async buildRequestModel( requestClass: new () => T, ) { - if (this.hashedSecret === undefined || this.verificationType === undefined) { + if (this.secret === undefined || this.verificationType === undefined) { throw new Error("User verification data is missing"); } return this.userVerificationService.buildRequest( { - secret: this.hashedSecret, + secret: this.secret, type: this.verificationType, }, requestClass, diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html index eec9f74dd60..c272a8e5b70 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html @@ -17,10 +17,10 @@
    • - + {{ "webAuthnkeyX" | i18n: (i + 1).toString() }} - + {{ k.name }} diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts index 66cd3596063..11ba5955902 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts @@ -3,7 +3,6 @@ import { Component, Inject, NgZone } from "@angular/core"; import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; @@ -13,6 +12,7 @@ import { ChallengeResponse, TwoFactorWebAuthnResponse, } from "@bitwarden/common/auth/models/response/two-factor-web-authn.response"; +import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -43,6 +43,8 @@ interface Key { removePromise: Promise | null; } +// 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-two-factor-setup-webauthn", templateUrl: "two-factor-setup-webauthn.component.html", @@ -70,7 +72,6 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom webAuthnListening: boolean = false; webAuthnResponse: PublicKeyCredential | null = null; challengePromise: Promise | undefined; - formPromise: Promise | undefined; override componentName = "app-two-factor-webauthn"; @@ -79,7 +80,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom constructor( @Inject(DIALOG_DATA) protected data: AuthResponse, private dialogRef: DialogRef, - apiService: ApiService, + twoFactorService: TwoFactorService, i18nService: I18nService, platformUtilsService: PlatformUtilsService, private ngZone: NgZone, @@ -89,7 +90,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom toastService: ToastService, ) { super( - apiService, + twoFactorService, i18nService, platformUtilsService, logService, @@ -127,7 +128,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom request.id = this.keyIdAvailable; request.name = this.formGroup.value.name || ""; - const response = await this.apiService.putTwoFactorWebAuthn(request); + const response = await this.twoFactorService.putTwoFactorWebAuthn(request); this.processResponse(response); this.toastService.showToast({ title: this.i18nService.t("success"), @@ -163,7 +164,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom const request = await this.buildRequestModel(UpdateTwoFactorWebAuthnDeleteRequest); request.id = key.id; try { - key.removePromise = this.apiService.deleteTwoFactorWebAuthn(request); + key.removePromise = this.twoFactorService.deleteTwoFactorWebAuthn(request); const response = await key.removePromise; key.removePromise = null; await this.processResponse(response); @@ -177,7 +178,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom return; } const request = await this.buildRequestModel(SecretVerificationRequest); - this.challengePromise = this.apiService.getTwoFactorWebAuthnChallenge(request); + this.challengePromise = this.twoFactorService.getTwoFactorWebAuthnChallenge(request); const challenge = await this.challengePromise; this.readDevice(challenge); }; diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html index dbad422a32e..8baf304969f 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html @@ -25,27 +25,25 @@
    • {{ "twoFactorYubikeySaveForm" | i18n }}

    • -
      -
      -
      - {{ "yubikeyX" | i18n: (i + 1).toString() }} - - - -
      - {{ keys[i].existingKey }} - -
      +
      +
      + {{ "yubikeyX" | i18n: (i + 1).toString() }} + + + +
      + {{ keys[i].existingKey }} +
      -

      {{ "nfcSupport" | i18n }}

      +

      {{ "nfcSupport" | i18n }}

      {{ "twoFactorYubikeySupportsNfc" | i18n }} diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts index 0b85d219928..a58c659796d 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts @@ -9,11 +9,11 @@ import { } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { UpdateTwoFactorYubikeyOtpRequest } from "@bitwarden/common/auth/models/request/update-two-factor-yubikey-otp.request"; import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response"; +import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -44,6 +44,8 @@ interface Key { existingKey: string; } +// 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-two-factor-setup-yubikey", templateUrl: "two-factor-setup-yubikey.component.html", @@ -72,9 +74,6 @@ export class TwoFactorSetupYubiKeyComponent keys: Key[] = []; anyKeyHasNfc = false; - formPromise: Promise | undefined; - disablePromise: Promise | undefined; - override componentName = "app-two-factor-yubikey"; formGroup: | FormGroup<{ @@ -93,7 +92,7 @@ export class TwoFactorSetupYubiKeyComponent constructor( @Inject(DIALOG_DATA) protected data: AuthResponse, - apiService: ApiService, + twoFactorService: TwoFactorService, i18nService: I18nService, platformUtilsService: PlatformUtilsService, logService: LogService, @@ -103,7 +102,7 @@ export class TwoFactorSetupYubiKeyComponent protected toastService: ToastService, ) { super( - apiService, + twoFactorService, i18nService, platformUtilsService, logService, @@ -176,7 +175,7 @@ export class TwoFactorSetupYubiKeyComponent request.key5 = keys != null && keys.length > 4 ? (keys[4]?.key ?? "") : ""; request.nfc = this.formGroup.value.anyKeyHasNfc ?? false; - this.processResponse(await this.apiService.putTwoFactorYubiKey(request)); + this.processResponse(await this.twoFactorService.putTwoFactorYubiKey(request)); this.refreshFormArrayData(); this.toastService.showToast({ title: this.i18nService.t("success"), diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.html index 16c3dcb3cda..69a0dbf4145 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.html +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.html @@ -53,7 +53,7 @@

      {{ p.name }} @@ -71,15 +71,26 @@
      {{ p.description }}
      - + @if (p.premium && p.enabled && !(canAccessPremium$ | async)) { + + } @else { + + } diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts index 043c27998cd..a85bf65ab74 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts @@ -3,7 +3,6 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { first, - firstValueFrom, lastValueFrom, Observable, Subject, @@ -13,26 +12,28 @@ import { } from "rxjs"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { UserVerificationDialogComponent } from "@bitwarden/auth/angular"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; +import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request"; import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response"; import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response"; import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response"; import { TwoFactorWebAuthnResponse } from "@bitwarden/common/auth/models/response/two-factor-web-authn.response"; import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.service"; +import { TwoFactorService, TwoFactorProviders } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { DialogRef, DialogService, ItemModule } from "@bitwarden/components"; +import { DialogRef, DialogService, ItemModule, ToastService } from "@bitwarden/components"; import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared/shared.module"; @@ -45,6 +46,8 @@ import { TwoFactorSetupWebAuthnComponent } from "./two-factor-setup-webauthn.com import { TwoFactorSetupYubiKeyComponent } from "./two-factor-setup-yubikey.component"; import { TwoFactorVerifyComponent } from "./two-factor-verify.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-two-factor-setup", templateUrl: "two-factor-setup.component.html", @@ -58,7 +61,6 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { recoveryCodeWarningMessage: string; showPolicyWarning = false; loading = true; - formPromise: Promise; tabbedHeader = true; @@ -68,13 +70,15 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { constructor( protected dialogService: DialogService, - protected apiService: ApiService, + protected twoFactorService: TwoFactorService, protected messagingService: MessagingService, protected policyService: PolicyService, billingAccountProfileStateService: BillingAccountProfileStateService, protected accountService: AccountService, protected configService: ConfigService, protected i18nService: I18nService, + protected userVerificationService: UserVerificationService, + protected toastService: ToastService, ) { this.canAccessPremium$ = this.accountService.activeAccount$.pipe( switchMap((account) => @@ -149,6 +153,50 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { return await lastValueFrom(twoFactorVerifyDialogRef.closed); } + /** + * For users who enabled a premium-only 2fa provider, + * they should still be allowed to disable that provider + * (without otherwise modifying) if they no longer have + * premium access [PM-21204] + * @param type the 2FA Provider Type + */ + async disablePremium2faTypeForNonPremiumUser(type: TwoFactorProviderType) { + // Use UserVerificationDialogComponent instead of TwoFactorVerifyComponent + // because the latter makes GET API calls that require premium for YubiKey/Duo. + // The disable endpoint only requires user verification, not provider configuration. + const result = await UserVerificationDialogComponent.open(this.dialogService, { + title: "twoStepLogin", + verificationType: { + type: "custom", + verificationFn: async (secret) => { + const request = await this.userVerificationService.buildRequest( + secret, + TwoFactorProviderRequest, + ); + request.type = type; + + await this.twoFactorService.putTwoFactorDisable(request); + return true; + }, + }, + }); + + if (result.userAction === "cancel") { + return; + } + + if (!result.verificationSuccess) { + return; + } + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("twoStepDisabled"), + }); + this.updateStatus(false, type); + } + async manage(type: TwoFactorProviderType) { // clear any existing subscriptions before creating a new one this.twoFactorSetupSubscription?.unsubscribe(); @@ -262,15 +310,8 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { } } - async premiumRequired() { - if (!(await firstValueFrom(this.canAccessPremium$))) { - this.messagingService.send("premiumRequired"); - return; - } - } - protected getTwoFactorProviders() { - return this.apiService.getTwoFactorProviders(); + return this.twoFactorService.getEnabledTwoFactorProviders(); } protected filterProvider(type: TwoFactorProviderType): boolean { diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts index 07939db7eff..04c84ca0197 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts @@ -1,15 +1,14 @@ -import { Component, EventEmitter, Inject, Output } from "@angular/core"; +import { Component, Inject } from "@angular/core"; import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; -import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; +import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { TwoFactorResponse } from "@bitwarden/common/auth/types/two-factor-response"; -import { Verification } from "@bitwarden/common/auth/types/verification"; +import { VerificationWithSecret } from "@bitwarden/common/auth/types/verification"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { @@ -28,6 +27,8 @@ type TwoFactorVerifyDialogData = { organizationId: string; }; +// 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-two-factor-verify", templateUrl: "two-factor-verify.component.html", @@ -43,19 +44,17 @@ type TwoFactorVerifyDialogData = { export class TwoFactorVerifyComponent { type: TwoFactorProviderType; organizationId: string; - @Output() onAuthed = new EventEmitter>(); - formPromise: Promise | undefined; protected formGroup = new FormGroup({ - secret: new FormControl(null), + secret: new FormControl(null), }); invalidSecret: boolean = false; constructor( @Inject(DIALOG_DATA) protected data: TwoFactorVerifyDialogData, private dialogRef: DialogRef, - private apiService: ApiService, + private twoFactorService: TwoFactorService, private i18nService: I18nService, private userVerificationService: UserVerificationService, ) { @@ -65,24 +64,19 @@ export class TwoFactorVerifyComponent { submit = async () => { try { - let hashedSecret = ""; if (!this.formGroup.value.secret) { throw new Error("Secret is required"); } const secret = this.formGroup.value.secret!; this.formPromise = this.userVerificationService.buildRequest(secret).then((request) => { - hashedSecret = - secret.type === VerificationType.MasterPassword - ? request.masterPasswordHash - : request.otp; return this.apiCall(request); }); const response = await this.formPromise; this.dialogRef.close({ response: response, - secret: hashedSecret, + secret: secret.secret, verificationType: secret.type, }); } catch (e) { @@ -116,22 +110,22 @@ export class TwoFactorVerifyComponent { private apiCall(request: SecretVerificationRequest): Promise { switch (this.type) { case -1 as TwoFactorProviderType: - return this.apiService.getTwoFactorRecover(request); + return this.twoFactorService.getTwoFactorRecover(request); case TwoFactorProviderType.Duo: case TwoFactorProviderType.OrganizationDuo: if (this.organizationId != null) { - return this.apiService.getTwoFactorOrganizationDuo(this.organizationId, request); + return this.twoFactorService.getTwoFactorOrganizationDuo(this.organizationId, request); } else { - return this.apiService.getTwoFactorDuo(request); + return this.twoFactorService.getTwoFactorDuo(request); } case TwoFactorProviderType.Email: - return this.apiService.getTwoFactorEmail(request); + return this.twoFactorService.getTwoFactorEmail(request); case TwoFactorProviderType.WebAuthn: - return this.apiService.getTwoFactorWebAuthn(request); + return this.twoFactorService.getTwoFactorWebAuthn(request); case TwoFactorProviderType.Authenticator: - return this.apiService.getTwoFactorAuthenticator(request); + return this.twoFactorService.getTwoFactorAuthenticator(request); case TwoFactorProviderType.Yubikey: - return this.apiService.getTwoFactorYubiKey(request); + return this.twoFactorService.getTwoFactorYubiKey(request); default: throw new Error(`Unknown two-factor type: ${this.type}`); } diff --git a/apps/web/src/app/auth/settings/verify-email.component.ts b/apps/web/src/app/auth/settings/verify-email.component.ts index 7088dae8d0f..a63d0b18b36 100644 --- a/apps/web/src/app/auth/settings/verify-email.component.ts +++ b/apps/web/src/app/auth/settings/verify-email.component.ts @@ -16,6 +16,8 @@ import { ToastService, } from "@bitwarden/components"; +// 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-verify-email", templateUrl: "verify-email.component.html", @@ -24,7 +26,11 @@ import { export class VerifyEmailComponent { actionPromise: Promise; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onVerified = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onDismiss = new EventEmitter(); constructor( diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts index 04b148e8a0a..8ccf99f1aef 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts @@ -8,12 +8,13 @@ import { TwoFactorAuthSecurityKeyFailedIcon, TwoFactorAuthSecurityKeyIcon, } from "@bitwarden/assets/svg"; -import { PrfKeySet } from "@bitwarden/auth/common"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { Verification } from "@bitwarden/common/auth/types/verification"; +import { PrfKeySet } from "@bitwarden/common/key-management/keys/models/rotateable-key-set"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { WebauthnLoginAdminService } from "../../../core"; @@ -32,6 +33,8 @@ type Step = | "credentialCreationFailed" | "credentialNaming"; +// 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: "create-credential-dialog.component.html", standalone: false, @@ -65,10 +68,10 @@ export class CreateCredentialDialogComponent implements OnInit { private formBuilder: FormBuilder, private dialogRef: DialogRef, private webauthnService: WebauthnLoginAdminService, - private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private logService: LogService, private toastService: ToastService, + private accountService: AccountService, ) {} ngOnInit(): void { @@ -144,13 +147,14 @@ export class CreateCredentialDialogComponent implements OnInit { if (this.formGroup.controls.credentialNaming.controls.name.invalid) { return; } + const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); let keySet: PrfKeySet | undefined; if ( this.pendingCredential.supportsPrf && this.formGroup.value.credentialNaming.useForEncryption ) { - keySet = await this.webauthnService.createKeySet(this.pendingCredential); + keySet = await this.webauthnService.createKeySet(this.pendingCredential, userId); if (keySet === undefined) { this.formGroup.controls.credentialNaming.controls.useForEncryption?.setErrors({ diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.ts index ea766a302ca..af4b7c497fb 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.ts +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.ts @@ -24,6 +24,8 @@ export interface DeleteCredentialDialogParams { credentialId: string; } +// 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: "delete-credential-dialog.component.html", standalone: false, diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/enable-encryption-dialog/enable-encryption-dialog.component.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/enable-encryption-dialog/enable-encryption-dialog.component.ts index dd1ac45a9b6..053da609345 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/enable-encryption-dialog/enable-encryption-dialog.component.ts +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/enable-encryption-dialog/enable-encryption-dialog.component.ts @@ -2,11 +2,13 @@ // @ts-strict-ignore import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; -import { Subject } from "rxjs"; +import { firstValueFrom, Subject } from "rxjs"; import { takeUntil } from "rxjs/operators"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; import { WebAuthnLoginCredentialAssertionOptionsView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { Verification } from "@bitwarden/common/auth/types/verification"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { DIALOG_DATA, DialogConfig, DialogRef } from "@bitwarden/components"; @@ -21,6 +23,8 @@ export interface EnableEncryptionDialogParams { credentialId: string; } +// 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: "enable-encryption-dialog.component.html", standalone: false, @@ -45,6 +49,7 @@ export class EnableEncryptionDialogComponent implements OnInit, OnDestroy { private dialogRef: DialogRef, private webauthnService: WebauthnLoginAdminService, private webauthnLoginService: WebAuthnLoginServiceAbstraction, + private accountService: AccountService, ) {} ngOnInit(): void { @@ -58,6 +63,7 @@ export class EnableEncryptionDialogComponent implements OnInit, OnDestroy { if (this.credential === undefined) { return; } + const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); this.dialogRef.disableClose = true; try { @@ -66,6 +72,7 @@ export class EnableEncryptionDialogComponent implements OnInit, OnDestroy { ); await this.webauthnService.enableCredentialEncryption( await this.webauthnLoginService.assertCredential(this.credentialOptions), + userId, ); } catch (error) { if (error instanceof ErrorResponse && error.statusCode === 400) { diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html index 7b1d859fb69..2ef177922a9 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html @@ -19,7 +19,6 @@ - {{ "beta" | i18n }} @@ -34,7 +33,7 @@ - + - - - - - + {{ "pendingCancellation" | i18n }} + + +
      +
      {{ "nextChargeHeader" | i18n }}
      +
      + + +
      + + {{ + (sub.subscription.periodEndDate | date: "MMM d, y") + + ", " + + (discountedSubscriptionAmount | currency: "$") + }} + + +
      +
      + +
      + + {{ + (sub.subscription.periodEndDate | date: "MMM d, y") + + ", " + + (subscriptionAmount | currency: "$") + }} + +
      +
      +
      + - +
      +
      @@ -90,8 +112,27 @@ - -
      +
      +

      {{ "storage" | i18n }}

      +

      + {{ "subscriptionStorage" | i18n: sub.maxStorageGb || 0 : sub.storageName || "0 MB" }} +

      + + +
      +
      + + +
      +
      +
      +

      {{ "additionalOptions" | i18n }}

      +

      {{ "additionalOptionsDesc" | i18n }}

      +
      -

      {{ "storage" | i18n }}

      -

      - {{ "subscriptionStorage" | i18n: sub.maxStorageGb || 0 : sub.storageName || "0 MB" }} -

      - - -
      -
      - - -
      -
      -
      - +
      diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index 4d1fa97785b..c39b5d153b1 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -7,13 +7,17 @@ import { firstValueFrom, lastValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { BillingCustomerDiscount } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { SubscriptionResponse } from "@bitwarden/common/billing/models/response/subscription.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { DiscountInfo } from "@bitwarden/pricing"; import { AdjustStorageDialogComponent, @@ -26,6 +30,8 @@ import { import { UpdateLicenseDialogComponent } from "../shared/update-license-dialog.component"; import { UpdateLicenseDialogResult } from "../shared/update-license-types"; +// 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: "user-subscription.component.html", standalone: false, @@ -40,6 +46,10 @@ export class UserSubscriptionComponent implements OnInit { cancelPromise: Promise; reinstatePromise: Promise; + protected enableDiscountDisplay$ = this.configService.getFeatureFlag$( + FeatureFlag.PM23341_Milestone_2, + ); + constructor( private apiService: ApiService, private platformUtilsService: PlatformUtilsService, @@ -52,6 +62,7 @@ export class UserSubscriptionComponent implements OnInit { private billingAccountProfileStateService: BillingAccountProfileStateService, private toastService: ToastService, private accountService: AccountService, + private configService: ConfigService, ) { this.selfHosted = this.platformUtilsService.isSelfHost(); } @@ -185,6 +196,28 @@ export class UserSubscriptionComponent implements OnInit { return this.sub != null ? this.sub.upcomingInvoice : null; } + get subscriptionAmount(): number { + if (!this.subscription?.items || this.subscription.items.length === 0) { + return 0; + } + + return this.subscription.items.reduce( + (sum, item) => sum + (item.amount || 0) * (item.quantity || 0), + 0, + ); + } + + get discountedSubscriptionAmount(): number { + // Use the upcoming invoice amount from the server as it already includes discounts, + // taxes, prorations, and all other adjustments. Fall back to subscription amount + // if upcoming invoice is not available. + if (this.nextInvoice?.amount != null) { + return this.nextInvoice.amount; + } + + return this.subscriptionAmount; + } + get storagePercentage() { return this.sub != null && this.sub.maxStorageGb ? +(100 * (this.sub.storageGb / this.sub.maxStorageGb)).toFixed(2) @@ -215,4 +248,15 @@ export class UserSubscriptionComponent implements OnInit { return this.subscription.status; } } + + getDiscountInfo(discount: BillingCustomerDiscount | null): DiscountInfo | null { + if (!discount) { + return null; + } + return { + active: discount.active, + percentOff: discount.percentOff, + amountOff: discount.amountOff, + }; + } } diff --git a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts index 38ae39cabfe..971cfb5704b 100644 --- a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts +++ b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts @@ -38,6 +38,8 @@ interface AddSponsorshipDialogParams { organizationKey: OrgKey; } +// 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: "add-sponsorship-dialog.component.html", imports: [ diff --git a/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.spec.ts b/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.spec.ts new file mode 100644 index 00000000000..f7bb510f579 --- /dev/null +++ b/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.spec.ts @@ -0,0 +1,461 @@ +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { of } from "rxjs"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { + ChangePlanDialogResultType, + openChangePlanDialog, +} from "../../organizations/change-plan-dialog.component"; + +import { BillingConstraintService, SeatLimitResult } from "./billing-constraint.service"; + +jest.mock("../../organizations/change-plan-dialog.component"); + +describe("BillingConstraintService", () => { + let service: BillingConstraintService; + let i18nService: jest.Mocked; + let dialogService: jest.Mocked; + let toastService: jest.Mocked; + let router: jest.Mocked; + let organizationMetadataService: jest.Mocked; + + const mockOrganizationId = "org-123" as OrganizationId; + + const createMockOrganization = (overrides: Partial = {}): Organization => { + const org = new Organization(); + org.id = mockOrganizationId; + org.seats = 10; + org.productTierType = ProductTierType.Teams; + + Object.defineProperty(org, "hasReseller", { + value: false, + writable: true, + configurable: true, + }); + + Object.defineProperty(org, "canEditSubscription", { + value: true, + writable: true, + configurable: true, + }); + + return Object.assign(org, overrides); + }; + + const createMockBillingMetadata = ( + overrides: Partial = {}, + ): OrganizationBillingMetadataResponse => { + return { + organizationOccupiedSeats: 5, + ...overrides, + } as OrganizationBillingMetadataResponse; + }; + + beforeEach(() => { + const mockDialogRef = { + closed: of(true), + }; + + const mockSimpleDialogRef = { + closed: of(true), + }; + + i18nService = { + t: jest.fn().mockReturnValue("translated-text"), + } as any; + + dialogService = { + openSimpleDialogRef: jest.fn().mockReturnValue(mockSimpleDialogRef), + } as any; + + toastService = { + showToast: jest.fn(), + } as any; + + router = { + navigate: jest.fn().mockResolvedValue(true), + } as any; + + organizationMetadataService = { + getOrganizationMetadata$: jest.fn(), + refreshMetadataCache: jest.fn(), + } as any; + + (openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef); + + TestBed.configureTestingModule({ + providers: [ + BillingConstraintService, + { provide: I18nService, useValue: i18nService }, + { provide: DialogService, useValue: dialogService }, + { provide: ToastService, useValue: toastService }, + { provide: Router, useValue: router }, + { provide: OrganizationMetadataServiceAbstraction, useValue: organizationMetadataService }, + ], + }); + + service = TestBed.inject(BillingConstraintService); + }); + + describe("checkSeatLimit", () => { + it("should allow users when occupied seats are less than total seats", () => { + const organization = createMockOrganization({ seats: 10 }); + const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 5 }); + + const result = service.checkSeatLimit(organization, billingMetadata); + + expect(result).toEqual({ canAddUsers: true }); + }); + + it("should allow users when occupied seats equal total seats for non-fixed seat plans", () => { + const organization = createMockOrganization({ + seats: 10, + productTierType: ProductTierType.Teams, + }); + const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 10 }); + + const result = service.checkSeatLimit(organization, billingMetadata); + + expect(result).toEqual({ canAddUsers: true }); + }); + + it("should block users with reseller-limit reason when organization has reseller", () => { + const organization = createMockOrganization({ + seats: 10, + hasReseller: true, + }); + const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 10 }); + + const result = service.checkSeatLimit(organization, billingMetadata); + + expect(result).toEqual({ + canAddUsers: false, + reason: "reseller-limit", + }); + }); + + it("should block users with fixed-seat-limit reason for fixed seat plans", () => { + const organization = createMockOrganization({ + seats: 10, + productTierType: ProductTierType.Free, + canEditSubscription: true, + }); + const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 10 }); + + const result = service.checkSeatLimit(organization, billingMetadata); + + expect(result).toEqual({ + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: true, + }); + }); + + it("should not show upgrade dialog when organization cannot edit subscription", () => { + const organization = createMockOrganization({ + seats: 10, + productTierType: ProductTierType.TeamsStarter, + canEditSubscription: false, + }); + const billingMetadata = createMockBillingMetadata({ organizationOccupiedSeats: 10 }); + + const result = service.checkSeatLimit(organization, billingMetadata); + + expect(result).toEqual({ + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }); + }); + + it("shoud throw if missing billingMetadata", () => { + const organization = createMockOrganization({ seats: 10 }); + const billingMetadata = createMockBillingMetadata({ + organizationOccupiedSeats: undefined as any, + }); + + const err = () => service.checkSeatLimit(organization, billingMetadata); + + expect(err).toThrow("Cannot check seat limit: billingMetadata is null or undefined."); + }); + }); + + describe("seatLimitReached", () => { + it("should return false when canAddUsers is true", async () => { + const result: SeatLimitResult = { canAddUsers: true }; + const organization = createMockOrganization(); + + const seatLimitReached = await service.seatLimitReached(result, organization); + + expect(seatLimitReached).toBe(false); + }); + + it("should show toast and return true for reseller-limit", async () => { + const result: SeatLimitResult = { canAddUsers: false, reason: "reseller-limit" }; + const organization = createMockOrganization(); + + const seatLimitReached = await service.seatLimitReached(result, organization); + + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "translated-text", + message: "translated-text", + }); + expect(i18nService.t).toHaveBeenCalledWith("seatLimitReached"); + expect(i18nService.t).toHaveBeenCalledWith("contactYourProvider"); + expect(seatLimitReached).toBe(true); + }); + + it("should return true when upgrade dialog is cancelled", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: true, + }; + const organization = createMockOrganization(); + const mockDialogRef = { closed: of(ChangePlanDialogResultType.Closed) }; + (openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef); + + const seatLimitReached = await service.seatLimitReached(result, organization); + + expect(openChangePlanDialog).toHaveBeenCalledWith(dialogService, { + data: { + organizationId: organization.id, + productTierType: organization.productTierType, + }, + }); + expect(seatLimitReached).toBe(true); + }); + + it("should return false when upgrade dialog is submitted", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: true, + }; + const organization = createMockOrganization(); + const mockDialogRef = { closed: of(ChangePlanDialogResultType.Submitted) }; + (openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef); + + const seatLimitReached = await service.seatLimitReached(result, organization); + + expect(seatLimitReached).toBe(false); + }); + + it("should show seat limit dialog when shouldShowUpgradeDialog is false", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + canEditSubscription: false, + productTierType: ProductTierType.Free, + }); + + const seatLimitReached = await service.seatLimitReached(result, organization); + + expect(dialogService.openSimpleDialogRef).toHaveBeenCalled(); + expect(seatLimitReached).toBe(true); + }); + + it("should return true for unknown reasons", async () => { + const result: SeatLimitResult = { canAddUsers: false }; + const organization = createMockOrganization(); + + const seatLimitReached = await service.seatLimitReached(result, organization); + + expect(seatLimitReached).toBe(true); + }); + }); + + describe("navigateToPaymentMethod", () => { + it("should navigate to payment method with correct parameters", async () => { + const organization = createMockOrganization(); + + await service.navigateToPaymentMethod(organization); + + expect(router.navigate).toHaveBeenCalledWith( + ["organizations", organization.id, "billing", "payment-method"], + { + state: { launchPaymentModalAutomatically: true }, + }, + ); + }); + }); + + describe("private methods through public method coverage", () => { + describe("getDialogContent via showSeatLimitReachedDialog", () => { + it("should get correct dialog content for Free organization", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + productTierType: ProductTierType.Free, + canEditSubscription: false, + seats: 5, + }); + + await service.seatLimitReached(result, organization); + + expect(i18nService.t).toHaveBeenCalledWith("freeOrgInvLimitReachedNoManageBilling", 5); + }); + + it("should get correct dialog content for TeamsStarter organization", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + productTierType: ProductTierType.TeamsStarter, + canEditSubscription: false, + seats: 3, + }); + + await service.seatLimitReached(result, organization); + + expect(i18nService.t).toHaveBeenCalledWith( + "teamsStarterPlanInvLimitReachedNoManageBilling", + 3, + ); + }); + + it("should get correct dialog content for Families organization", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + productTierType: ProductTierType.Families, + canEditSubscription: false, + seats: 6, + }); + + await service.seatLimitReached(result, organization); + + expect(i18nService.t).toHaveBeenCalledWith("familiesPlanInvLimitReachedNoManageBilling", 6); + }); + + it("should throw error for unsupported product type in getProductKey", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + productTierType: ProductTierType.Enterprise, + canEditSubscription: false, + }); + + await expect(service.seatLimitReached(result, organization)).rejects.toThrow( + `Unsupported product type: ${ProductTierType.Enterprise}`, + ); + }); + }); + + describe("getAcceptButtonText via showSeatLimitReachedDialog", () => { + it("should return 'ok' when organization cannot edit subscription", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + canEditSubscription: false, + productTierType: ProductTierType.Free, + }); + + await service.seatLimitReached(result, organization); + + expect(i18nService.t).toHaveBeenCalledWith("ok"); + }); + + it("should return 'upgrade' when organization can edit subscription", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + canEditSubscription: true, + productTierType: ProductTierType.Free, + }); + const mockSimpleDialogRef = { closed: of(false) }; + dialogService.openSimpleDialogRef.mockReturnValue(mockSimpleDialogRef); + + await service.seatLimitReached(result, organization); + + expect(i18nService.t).toHaveBeenCalledWith("upgrade"); + }); + + it("should throw error for unsupported product type in getAcceptButtonText", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + canEditSubscription: true, + productTierType: ProductTierType.Enterprise, + }); + + await expect(service.seatLimitReached(result, organization)).rejects.toThrow( + `Unsupported product type: ${ProductTierType.Enterprise}`, + ); + }); + }); + + describe("handleUpgradeNavigation", () => { + it("should navigate to billing subscription with upgrade query param", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + canEditSubscription: true, + productTierType: ProductTierType.Free, + }); + const mockSimpleDialogRef = { closed: of(true) }; + dialogService.openSimpleDialogRef.mockReturnValue(mockSimpleDialogRef); + + await service.seatLimitReached(result, organization); + + expect(router.navigate).toHaveBeenCalledWith( + ["/organizations", organization.id, "billing", "subscription"], + { queryParams: { upgrade: true } }, + ); + }); + + it("should throw error for non-self-upgradable product type", async () => { + const result: SeatLimitResult = { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: false, + }; + const organization = createMockOrganization({ + canEditSubscription: true, + productTierType: ProductTierType.Enterprise, + }); + const mockSimpleDialogRef = { closed: of(true) }; + dialogService.openSimpleDialogRef.mockReturnValue(mockSimpleDialogRef); + + await expect(service.seatLimitReached(result, organization)).rejects.toThrow( + `Unsupported product type: ${ProductTierType.Enterprise}`, + ); + }); + }); + }); +}); diff --git a/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.ts b/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.ts new file mode 100644 index 00000000000..d43c2e68497 --- /dev/null +++ b/apps/web/src/app/billing/members/billing-constraint/billing-constraint.service.ts @@ -0,0 +1,192 @@ +import { Injectable } from "@angular/core"; +import { Router } from "@angular/router"; +import { lastValueFrom } from "rxjs"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums"; +import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { isFixedSeatPlan } from "../../../admin-console/organizations/members/components/member-dialog/validators/org-seat-limit-reached.validator"; +import { + ChangePlanDialogResultType, + openChangePlanDialog, +} from "../../organizations/change-plan-dialog.component"; + +export interface SeatLimitResult { + canAddUsers: boolean; + reason?: "reseller-limit" | "fixed-seat-limit" | "no-billing-permission"; + shouldShowUpgradeDialog?: boolean; +} + +@Injectable() +export class BillingConstraintService { + constructor( + private i18nService: I18nService, + private dialogService: DialogService, + private toastService: ToastService, + private router: Router, + ) {} + + checkSeatLimit( + organization: Organization, + billingMetadata: OrganizationBillingMetadataResponse, + ): SeatLimitResult { + const occupiedSeats = billingMetadata?.organizationOccupiedSeats; + if (occupiedSeats == null) { + throw new Error("Cannot check seat limit: billingMetadata is null or undefined."); + } + const totalSeats = organization.seats; + + if (occupiedSeats < totalSeats) { + return { canAddUsers: true }; + } + + if (organization.hasReseller) { + return { + canAddUsers: false, + reason: "reseller-limit", + }; + } + + if (isFixedSeatPlan(organization.productTierType)) { + return { + canAddUsers: false, + reason: "fixed-seat-limit", + shouldShowUpgradeDialog: organization.canEditSubscription, + }; + } + + return { canAddUsers: true }; + } + + async seatLimitReached(result: SeatLimitResult, organization: Organization): Promise { + if (result.canAddUsers) { + return false; + } + + switch (result.reason) { + case "reseller-limit": + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("seatLimitReached"), + message: this.i18nService.t("contactYourProvider"), + }); + return true; + + case "fixed-seat-limit": + if (result.shouldShowUpgradeDialog) { + const dialogResult = await this.showChangePlanDialog(organization); + // If the plan was successfully changed, the seat limit is no longer blocking + return dialogResult !== ChangePlanDialogResultType.Submitted; + } else { + await this.showSeatLimitReachedDialog(organization); + return true; + } + + default: + return true; + } + } + + private async showChangePlanDialog( + organization: Organization, + ): Promise { + const reference = openChangePlanDialog(this.dialogService, { + data: { + organizationId: organization.id, + productTierType: organization.productTierType, + }, + }); + + const result = await lastValueFrom(reference.closed); + if (result == null) { + throw new Error("ChangePlanDialog result is null or undefined."); + } + + return result; + } + + private async showSeatLimitReachedDialog(organization: Organization): Promise { + const dialogContent = this.getSeatLimitReachedDialogContent(organization); + const acceptButtonText = this.getSeatLimitReachedDialogAcceptButtonText(organization); + + const orgUpgradeSimpleDialogOpts = { + title: this.i18nService.t("upgradeOrganization"), + content: dialogContent, + type: "primary" as const, + acceptButtonText, + cancelButtonText: organization.canEditSubscription ? undefined : (null as string | null), + }; + + const simpleDialog = this.dialogService.openSimpleDialogRef(orgUpgradeSimpleDialogOpts); + const result = await lastValueFrom(simpleDialog.closed); + + if (result && organization.canEditSubscription) { + await this.handleUpgradeNavigation(organization); + } + } + + private async handleUpgradeNavigation(organization: Organization): Promise { + const productType = organization.productTierType; + + if (isNotSelfUpgradable(productType)) { + throw new Error(`Unsupported product type: ${organization.productTierType}`); + } + + await this.router.navigate(["/organizations", organization.id, "billing", "subscription"], { + queryParams: { upgrade: true }, + }); + } + + private getSeatLimitReachedDialogContent(organization: Organization): string { + const productKey = this.getProductKey(organization); + return this.i18nService.t(productKey, organization.seats); + } + + private getSeatLimitReachedDialogAcceptButtonText(organization: Organization): string { + if (!organization.canEditSubscription) { + return this.i18nService.t("ok"); + } + + const productType = organization.productTierType; + + if (isNotSelfUpgradable(productType)) { + throw new Error(`Unsupported product type: ${productType}`); + } + + return this.i18nService.t("upgrade"); + } + + private getProductKey(organization: Organization): string { + const manageBillingText = organization.canEditSubscription + ? "ManageBilling" + : "NoManageBilling"; + + let product = ""; + switch (organization.productTierType) { + case ProductTierType.Free: + product = "freeOrg"; + break; + case ProductTierType.TeamsStarter: + product = "teamsStarterPlan"; + break; + case ProductTierType.Families: + product = "familiesPlan"; + break; + default: + throw new Error(`Unsupported product type: ${organization.productTierType}`); + } + return `${product}InvLimitReached${manageBillingText}`; + } + + async navigateToPaymentMethod(organization: Organization): Promise { + await this.router.navigate( + ["organizations", `${organization.id}`, "billing", "payment-method"], + { + state: { launchPaymentModalAutomatically: true }, + }, + ); + } +} diff --git a/apps/web/src/app/billing/members/free-bitwarden-families.component.ts b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts index dc4a2f6df9b..474e513da6b 100644 --- a/apps/web/src/app/billing/members/free-bitwarden-families.component.ts +++ b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts @@ -20,13 +20,15 @@ import { KeyService } from "@bitwarden/key-management"; import { AddSponsorshipDialogComponent } from "./add-sponsorship-dialog.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-free-bitwarden-families", templateUrl: "free-bitwarden-families.component.html", standalone: false, }) export class FreeBitwardenFamiliesComponent implements OnInit { - loading = signal(true); + readonly loading = signal(true); tabIndex = 0; sponsoredFamilies: OrganizationSponsorshipInvitesResponse[] = []; diff --git a/apps/web/src/app/billing/organizations/adjust-subscription.component.ts b/apps/web/src/app/billing/organizations/adjust-subscription.component.ts index d1086a6646b..255e1ef544c 100644 --- a/apps/web/src/app/billing/organizations/adjust-subscription.component.ts +++ b/apps/web/src/app/billing/organizations/adjust-subscription.component.ts @@ -16,17 +16,33 @@ import { OrganizationSubscriptionUpdateRequest } from "@bitwarden/common/billing import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ToastService } from "@bitwarden/components"; +// 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-adjust-subscription", templateUrl: "adjust-subscription.component.html", standalone: false, }) +// FIXME(https://bitwarden.atlassian.net/browse/PM-28231): Use Component suffix +// eslint-disable-next-line @angular-eslint/component-class-suffix export class AdjustSubscription implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() organizationId: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() maxAutoscaleSeats: number; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() currentSeatCount: number; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() seatPrice = 0; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() interval = "year"; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onAdjusted = new EventEmitter(); private destroy$ = new Subject(); diff --git a/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html b/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html index 465a50ec8c3..83a857886cf 100644 --- a/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html +++ b/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html @@ -37,7 +37,7 @@ >
      - {{ "lastSync" | i18n }}: + {{ "lastSync" | i18n }}: {{ lastSyncDate | date: "medium" }}
      diff --git a/apps/web/src/app/billing/organizations/billing-sync-api-key.component.ts b/apps/web/src/app/billing/organizations/billing-sync-api-key.component.ts index 55687f00052..52a7fab60f5 100644 --- a/apps/web/src/app/billing/organizations/billing-sync-api-key.component.ts +++ b/apps/web/src/app/billing/organizations/billing-sync-api-key.component.ts @@ -20,6 +20,8 @@ export interface BillingSyncApiModalData { hasBillingToken: boolean; } +// 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: "billing-sync-api-key.component.html", standalone: false, diff --git a/apps/web/src/app/billing/organizations/billing-sync-key.component.ts b/apps/web/src/app/billing/organizations/billing-sync-key.component.ts index 37ebefc803a..c6c2bf379eb 100644 --- a/apps/web/src/app/billing/organizations/billing-sync-key.component.ts +++ b/apps/web/src/app/billing/organizations/billing-sync-key.component.ts @@ -19,6 +19,8 @@ export interface BillingSyncKeyModalData { setParentConnection: (connection: OrganizationConnectionResponse) => void; } +// 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: "billing-sync-key.component.html", standalone: false, diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index f899b8eccb4..a7b9196cc5e 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -1,12 +1,12 @@
      - + {{ dialogHeaderName }}

      {{ "upgradePlans" | i18n }}

      - {{ + {{ "selectAPlan" | i18n }} @@ -57,7 +57,7 @@ selectableProduct.productTier === productTypes.Enterprise && !isSubscriptionCanceled " - class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-bold tw-py-1" + class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-medium tw-py-1" [ngClass]="{ 'tw-bg-primary-700 !tw-text-contrast': selectableProduct === selectedPlan, 'tw-bg-secondary-100': !(selectableProduct === selectedPlan), @@ -73,7 +73,7 @@ }" >

      {{ selectableProduct.nameLocalizationKey | i18n @@ -91,7 +91,7 @@ - + {{ (selectableProduct.isAnnual ? selectableProduct.PasswordManager.basePrice / 12 @@ -106,7 +106,7 @@ : ("monthPerMember" | i18n) }} - + @@ -128,7 +128,7 @@ selectableProduct.PasswordManager.hasAdditionalSeatsOption " > - {{ "costPerMember" | i18n @@ -155,7 +155,7 @@ " >

      {{ "bitwardenPasswordManager" | i18n }} @@ -182,7 +182,7 @@

      {{ "bitwardenSecretsManager" | i18n }} @@ -222,7 +222,7 @@

      {{ "bitwardenPasswordManager" | i18n }} @@ -274,7 +274,7 @@

      {{ "paymentMethod" | i18n }}

      -

      - - {{ paymentSource?.description }} - - {{ "changePaymentMethod" | i18n }} - +

      + @switch (paymentMethod.type) { + @case ("bankAccount") { + + {{ paymentMethod.bankName }}, *{{ paymentMethod.last4 }} + @if (paymentMethod.hostedVerificationUrl) { + - {{ "unverified" | i18n }} + } + + {{ "changePaymentMethod" | i18n }} + + } + @case ("card") { +

      + @let cardBrandIcon = getCardBrandIcon(); + @if (cardBrandIcon !== null) { + + } @else { + + } + {{ paymentMethod.brand | titlecase }}, *{{ paymentMethod.last4 }}, + {{ paymentMethod.expiration }} + + {{ "changePaymentMethod" | i18n }} + +

      + } + @case ("payPal") { + + {{ paymentMethod.email }} + + {{ "changePaymentMethod" | i18n }} + + } + }

      - - + + + +

      - {{ "total" | i18n }}: {{ total - calculateTotalAppliedDiscount(total) | currency: "USD" : "$" }} USD @@ -366,7 +402,7 @@

      -

      +

      {{ "passwordManager" | i18n }}

      -

      +

      {{ "secretsManager" | i18n }}

      -

      +

      {{ "passwordManager" | i18n }}

      -

      +

      {{ "secretsManager" | i18n }}

      -

      +

      {{ "secretsManager" | i18n }}

      {{ additionalServiceAccountTotal(selectedPlan) | currency: "$" }}

      -

      +

      {{ "passwordManager" | i18n }}

      -

      +

      {{ "secretsManager" | i18n }}

      {{ additionalServiceAccountTotal(selectedPlan) | currency: "$" }}

      -

      +

      {{ "passwordManager" | i18n }}

      - + {{ "estimatedTax" | i18n }} @@ -950,14 +986,12 @@

      - + {{ "total" | i18n }} {{ total - calculateTotalAppliedDiscount(total) | currency: "USD" : "$" }} - - / {{ selectedPlanInterval | i18n }} + / {{ selectedPlanInterval | i18n }}

      diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index 6fc2dc57ba2..0fd7746fc9d 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -12,9 +12,9 @@ import { } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { Router } from "@angular/router"; -import { firstValueFrom, map, Subject, switchMap, takeUntil } from "rxjs"; +import { combineLatest, firstValueFrom, map, Subject, switchMap, takeUntil } from "rxjs"; +import { debounceTime } from "rxjs/operators"; -import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { @@ -28,35 +28,18 @@ import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/ import { OrganizationUpgradeRequest } from "@bitwarden/common/admin-console/models/request/organization-upgrade.request"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { - BillingApiServiceAbstraction, - BillingInformation, - OrganizationBillingServiceAbstraction as OrganizationBillingService, - OrganizationInformation, - PaymentInformation, - PlanInformation, -} from "@bitwarden/common/billing/abstractions"; -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { - PaymentMethodType, - PlanInterval, - PlanType, - ProductTierType, -} from "@bitwarden/common/billing/enums"; -import { TaxInformation } from "@bitwarden/common/billing/models/domain"; -import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; -import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request"; -import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request"; -import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response"; +import { PlanInterval, PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; -import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { + CardComponent, DIALOG_DATA, DialogConfig, DialogRef, @@ -64,11 +47,26 @@ import { ToastService, } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; -import { UserId } from "@bitwarden/user-core"; +import { + OrganizationSubscriptionPlan, + SubscriberBillingClient, + TaxClient, +} from "@bitwarden/web-vault/app/billing/clients"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, + getBillingAddressFromForm, +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { + BillingAddress, + getCardBrandIcon, + MaskedPaymentMethod, +} from "@bitwarden/web-vault/app/billing/payment/types"; +import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types"; import { BillingNotificationService } from "../services/billing-notification.service"; import { BillingSharedModule } from "../shared/billing-shared.module"; -import { PaymentComponent } from "../shared/payment/payment.component"; type ChangePlanDialogParams = { organizationId: string; @@ -109,19 +107,38 @@ interface OnSuccessArgs { organizationId: string; } +// 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: "./change-plan-dialog.component.html", - imports: [BillingSharedModule], + imports: [ + BillingSharedModule, + EnterPaymentMethodComponent, + EnterBillingAddressComponent, + CardComponent, + ], + providers: [SubscriberBillingClient, TaxClient], }) export class ChangePlanDialogComponent implements OnInit, OnDestroy { - @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; - @ViewChild(ManageTaxInformationComponent) taxComponent: ManageTaxInformationComponent; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent: EnterPaymentMethodComponent; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() acceptingSponsorship = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() organizationId: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showFree = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showCancel = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get productTier(): ProductTierType { return this._productTier; @@ -134,7 +151,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { protected estimatedTax: number = 0; private _productTier = ProductTierType.Free; + private _familyPlan: PlanType; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get plan(): PlanType { return this._plan; @@ -146,9 +166,17 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } private _plan = PlanType.Free; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() providerId?: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSuccess = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onCanceled = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onTrialBillingSuccess = new EventEmitter(); protected discountPercentageFromSub: number; @@ -172,7 +200,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { clientOwnerEmail: ["", [Validators.email]], plan: [this.plan], productTier: [this.productTier], - // planInterval: [1], + }); + + billingFormGroup = this.formBuilder.group({ + paymentMethod: EnterPaymentMethodComponent.getFormGroup(), + billingAddress: EnterBillingAddressComponent.getFormGroup(), }); planType: string; @@ -183,7 +215,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { secretsManagerPlans: PlanResponse[]; organization: Organization; sub: OrganizationSubscriptionResponse; - billing: BillingResponse; dialogHeaderName: string; currentPlanName: string; showPayment: boolean = false; @@ -191,15 +222,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { currentPlan: PlanResponse; isCardStateDisabled = false; focusedIndex: number | null = null; - accountCredit: number; - paymentSource?: PaymentSourceResponse; plans: ListResponse; isSubscriptionCanceled: boolean = false; secretsManagerTotal: number; - private destroy$ = new Subject(); + paymentMethod: MaskedPaymentMethod | null; + billingAddress: BillingAddress | null; - protected taxInformation: TaxInformation; + private destroy$ = new Subject(); constructor( @Inject(DIALOG_DATA) private dialogParams: ChangePlanDialogParams, @@ -215,11 +245,12 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { private messagingService: MessagingService, private formBuilder: FormBuilder, private organizationApiService: OrganizationApiServiceAbstraction, - private billingApiService: BillingApiServiceAbstraction, - private taxService: TaxServiceAbstraction, private accountService: AccountService, - private organizationBillingService: OrganizationBillingService, private billingNotificationService: BillingNotificationService, + private subscriberBillingClient: SubscriberBillingClient, + private taxClient: TaxClient, + private organizationWarningsService: OrganizationWarningsService, + private configService: ConfigService, ) {} async ngOnInit(): Promise { @@ -242,10 +273,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ); if (this.sub?.subscription?.status !== "canceled") { try { - const { accountCredit, paymentSource } = - await this.billingApiService.getOrganizationPaymentMethod(this.organizationId); - this.accountCredit = accountCredit; - this.paymentSource = paymentSource; + const subscriber: BitwardenSubscriber = { type: "organization", data: this.organization }; + const [paymentMethod, billingAddress] = await Promise.all([ + this.subscriberBillingClient.getPaymentMethod(subscriber), + this.subscriberBillingClient.getBillingAddress(subscriber), + ]); + + this.paymentMethod = paymentMethod; + this.billingAddress = billingAddress; } catch (error) { this.billingNotificationService.handleError(error); } @@ -265,10 +300,16 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } } + const milestone3FeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM26462_Milestone_3, + ); + this._familyPlan = milestone3FeatureEnabled + ? PlanType.FamiliesAnnually + : PlanType.FamiliesAnnually2025; if (this.currentPlan && this.currentPlan.productTier !== ProductTierType.Enterprise) { const upgradedPlan = this.passwordManagerPlans.find((plan) => this.currentPlan.productTier === ProductTierType.Free - ? plan.type === PlanType.FamiliesAnnually + ? plan.type === this._familyPlan : plan.upgradeSortOrder == this.currentPlan.upgradeSortOrder + 1, ); @@ -307,15 +348,24 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ? 0 : (this.sub?.customerDiscount?.percentOff ?? 0); - this.setInitialPlanSelection(); - this.loading = false; - - const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId); - this.taxInformation = TaxInformation.from(taxInfo); - + await this.setInitialPlanSelection(); if (!this.isSubscriptionCanceled) { - this.refreshSalesTax(); + await this.refreshSalesTax(); } + + combineLatest([ + this.billingFormGroup.controls.billingAddress.controls.country.valueChanges, + this.billingFormGroup.controls.billingAddress.controls.postalCode.valueChanges, + this.billingFormGroup.controls.billingAddress.controls.taxId.valueChanges, + ]) + .pipe( + debounceTime(1000), + switchMap(async () => await this.refreshSalesTax()), + takeUntil(this.destroy$), + ) + .subscribe(); + + this.loading = false; } resolveHeaderName(subscription: OrganizationSubscriptionResponse): string { @@ -333,10 +383,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ); } - setInitialPlanSelection() { + async setInitialPlanSelection() { this.focusedIndex = this.selectableProducts.length - 1; if (!this.isSubscriptionCanceled) { - this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); + await this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); } } @@ -344,10 +394,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return this.selectableProducts.find((product) => product.productTier === productTier); } - isPaymentSourceEmpty() { - return this.paymentSource === null || this.paymentSource === undefined; - } - isSecretsManagerTrial(): boolean { return ( this.sub?.subscription?.items?.some((item) => @@ -356,13 +402,13 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ); } - planTypeChanged() { - this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); + async planTypeChanged() { + await this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); } - updateInterval(event: number) { + async updateInterval(event: number) { this.selectedInterval = event; - this.planTypeChanged(); + await this.planTypeChanged(); } protected getPlanIntervals() { @@ -415,9 +461,9 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { "tw-border-solid", "tw-border-primary-600", "hover:tw-border-primary-700", - "focus:tw-border-2", - "focus:tw-border-primary-700", - "focus:tw-rounded-lg", + "tw-border-2", + "!tw-border-primary-700", + "tw-rounded-lg", ]; } case PlanCardState.NotSelected: { @@ -460,7 +506,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } } - protected selectPlan(plan: PlanResponse) { + protected async selectPlan(plan: PlanResponse) { if ( this.selectedInterval === PlanInterval.Monthly && plan.productTier == ProductTierType.Families @@ -475,7 +521,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.formGroup.patchValue({ productTier: plan.productTier }); try { - this.refreshSalesTax(); + await this.refreshSalesTax(); } catch { this.estimatedTax = 0; } @@ -489,19 +535,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { get upgradeRequiresPaymentMethod() { const isFreeTier = this.organization?.productTierType === ProductTierType.Free; const shouldHideFree = !this.showFree; - const hasNoPaymentSource = !this.paymentSource; + const hasNoPaymentSource = !this.paymentMethod; return isFreeTier && shouldHideFree && hasNoPaymentSource; } - get selectedSecretsManagerPlan() { - let planResponse: PlanResponse; - if (this.secretsManagerPlans) { - return this.secretsManagerPlans.find((plan) => plan.type === this.selectedPlan.type); - } - return planResponse; - } - get selectedPlanInterval() { if (this.isSubscriptionCanceled) { return this.currentPlan.isAnnual ? "year" : "month"; @@ -516,9 +554,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } if (this.acceptingSponsorship) { - const familyPlan = this.passwordManagerPlans.find( - (plan) => plan.type === PlanType.FamiliesAnnually, - ); + const familyPlan = this.passwordManagerPlans.find((plan) => plan.type === this._familyPlan); this.discount = familyPlan.PasswordManager.basePrice; return [familyPlan]; } @@ -534,6 +570,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { plan.productTier === ProductTierType.TeamsStarter || (this.selectedInterval === PlanInterval.Annually && plan.isAnnual) || (this.selectedInterval === PlanInterval.Monthly && !plan.isAnnual)) && + (plan.productTier !== ProductTierType.Families || plan.type === this._familyPlan) && (!this.currentPlan || this.currentPlan.upgradeSortOrder < plan.upgradeSortOrder) && this.planIsEnabled(plan), ); @@ -591,8 +628,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return 0; } - const result = plan.PasswordManager.seatPrice * Math.abs(this.sub?.seats || 0); - return result; + return plan.PasswordManager.seatPrice * Math.abs(this.sub?.seats || 0); } secretsManagerSeatTotal(plan: PlanResponse, seats: number): number { @@ -643,6 +679,9 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { if (this.selectedPlan.PasswordManager.hasPremiumAccessOption) { subTotal += this.selectedPlan.PasswordManager.premiumAccessOptionPrice; } + if (this.selectedPlan.PasswordManager.hasAdditionalStorageOption) { + subTotal += this.additionalStorageTotal(this.selectedPlan); + } return subTotal - this.discount; } @@ -680,18 +719,9 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } if (this.organization.useSecretsManager) { - return ( - this.passwordManagerSubtotal + - this.additionalStorageTotal(this.selectedPlan) + - this.secretsManagerSubtotal() + - this.estimatedTax - ); + return this.passwordManagerSubtotal + this.secretsManagerSubtotal() + this.estimatedTax; } - return ( - this.passwordManagerSubtotal + - this.additionalStorageTotal(this.selectedPlan) + - this.estimatedTax - ); + return this.passwordManagerSubtotal + this.estimatedTax; } get teamsStarterPlanIsAvailable() { @@ -746,39 +776,22 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.formGroup.controls.additionalSeats.setValue(1); } - changedCountry() { - this.paymentComponent.showBankAccount = this.taxInformation.country === "US"; - - if ( - !this.paymentComponent.showBankAccount && - this.paymentComponent.selected === PaymentMethodType.BankAccount - ) { - this.paymentComponent.select(PaymentMethodType.Card); - } - } - - protected taxInformationChanged(event: TaxInformation): void { - this.taxInformation = event; - this.changedCountry(); - this.refreshSalesTax(); - } - submit = async () => { - if (this.taxComponent !== undefined && !this.taxComponent.validate()) { - this.taxComponent.markAllAsTouched(); + this.formGroup.markAllAsTouched(); + this.billingFormGroup.markAllAsTouched(); + if (this.formGroup.invalid || (this.billingFormGroup.invalid && !this.paymentMethod)) { return; } const doSubmit = async (): Promise => { - const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - let orgId: string = null; + let orgId: string; const sub = this.sub?.subscription; const isCanceled = sub?.status === "canceled"; const isCancelledDowngradedToFreeOrg = sub?.cancelled && this.organization.productTierType === ProductTierType.Free; if (isCanceled || isCancelledDowngradedToFreeOrg) { - await this.restartSubscription(activeUserId); + await this.restartSubscription(); orgId = this.organizationId; } else { orgId = await this.updateOrganization(); @@ -791,13 +804,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { : this.i18nService.t("organizationUpgraded"), }); - await this.apiService.refreshIdentityToken(); await this.syncService.fullSync(true); if (!this.acceptingSponsorship && !this.isInTrialFlow) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/organizations/" + orgId + "/billing/subscription"]); + await this.router.navigate(["/organizations/" + orgId + "/billing/subscription"]); } if (this.isInTrialFlow) { @@ -818,47 +828,15 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.dialogRef.close(); }; - private async restartSubscription(activeUserId: UserId) { - const org = await this.organizationApiService.get(this.organizationId); - const organization: OrganizationInformation = { - name: org.name, - billingEmail: org.billingEmail, - }; - - const filteredPlan = this.plans.data - .filter((plan) => plan.productTier === this.selectedPlan.productTier && !plan.legacyYear) - .find((plan) => { - const isSameBillingCycle = plan.isAnnual === this.selectedPlan.isAnnual; - return isSameBillingCycle; - }); - - const plan: PlanInformation = { - type: filteredPlan.type, - passwordManagerSeats: org.seats, - }; - - if (org.useSecretsManager) { - plan.subscribeToSecretsManager = true; - plan.secretsManagerSeats = org.smSeats; - } - - const { type, token } = await this.paymentComponent.tokenize(); - const paymentMethod: [string, PaymentMethodType] = [token, type]; - - const payment: PaymentInformation = { + private async restartSubscription() { + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + const billingAddress = getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress); + await this.subscriberBillingClient.restartSubscription( + { type: "organization", data: this.organization }, paymentMethod, - billing: this.getBillingInformationFromTaxInfoComponent(), - }; - - await this.organizationBillingService.restartSubscription( - this.organization.id, - { - organization, - plan, - payment, - }, - activeUserId, + billingAddress, ); + this.organizationWarningsService.refreshInactiveSubscriptionWarning(); } private async updateOrganization() { @@ -875,25 +853,24 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.formGroup.controls.premiumAccessAddon.value; request.planType = this.selectedPlan.type; if (this.showPayment) { - request.billingAddressCountry = this.taxInformation.country; - request.billingAddressPostalCode = this.taxInformation.postalCode; + request.billingAddressCountry = this.billingFormGroup.controls.billingAddress.value.country; + request.billingAddressPostalCode = + this.billingFormGroup.controls.billingAddress.value.postalCode; } // Secrets Manager this.buildSecretsManagerRequest(request); - if (this.upgradeRequiresPaymentMethod || this.showPayment || this.isPaymentSourceEmpty()) { - const tokenizedPaymentSource = await this.paymentComponent.tokenize(); - const updatePaymentMethodRequest = new UpdatePaymentMethodRequest(); - updatePaymentMethodRequest.paymentSource = tokenizedPaymentSource; - updatePaymentMethodRequest.taxInformation = ExpandedTaxInfoUpdateRequest.From( - this.taxInformation, + if (this.upgradeRequiresPaymentMethod || this.showPayment || !this.paymentMethod) { + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + const billingAddress = getBillingAddressFromForm( + this.billingFormGroup.controls.billingAddress, ); - await this.billingApiService.updateOrganizationPaymentMethod( - this.organizationId, - updatePaymentMethodRequest, - ); + const subscriber: BitwardenSubscriber = { type: "organization", data: this.organization }; + // These need to be synchronous so one of them can create the Customer in the case we're upgrading from Free. + await this.subscriberBillingClient.updateBillingAddress(subscriber, billingAddress); + await this.subscriberBillingClient.updatePaymentMethod(subscriber, paymentMethod, null); } // Backfill pub/priv key if necessary @@ -931,18 +908,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return text; } - private getBillingInformationFromTaxInfoComponent(): BillingInformation { - return { - country: this.taxInformation.country, - postalCode: this.taxInformation.postalCode, - taxId: this.taxInformation.taxId, - addressLine1: this.taxInformation.line1, - addressLine2: this.taxInformation.line2, - city: this.taxInformation.city, - state: this.taxInformation.state, - }; - } - private buildSecretsManagerRequest(request: OrganizationUpgradeRequest): void { request.useSecretsManager = this.organization.useSecretsManager; if (!this.organization.useSecretsManager) { @@ -970,7 +935,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { if (this.currentPlan && this.currentPlan.productTier !== ProductTierType.Enterprise) { const upgradedPlan = this.passwordManagerPlans.find((plan) => { if (this.currentPlan.productTier === ProductTierType.Free) { - return plan.type === PlanType.FamiliesAnnually; + return plan.type === this._familyPlan; } if ( @@ -1002,25 +967,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } calculateTotalAppliedDiscount(total: number) { - const discountedTotal = total * (this.discountPercentageFromSub / 100); - return discountedTotal; - } - - get paymentSourceClasses() { - if (this.paymentSource == null) { - return []; - } - switch (this.paymentSource.type) { - case PaymentMethodType.Card: - return ["bwi-credit-card"]; - case PaymentMethodType.BankAccount: - case PaymentMethodType.Check: - return ["bwi-billing"]; - case PaymentMethodType.PayPal: - return ["bwi-paypal text-primary"]; - default: - return []; - } + return total * (this.discountPercentageFromSub / 100); } resolvePlanName(productTier: ProductTierType) { @@ -1064,9 +1011,9 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } } - onFocus(index: number) { + async onFocus(index: number) { this.focusedIndex = index; - this.selectPlan(this.selectableProducts[index]); + await this.selectPlan(this.selectableProducts[index]); } isCardDisabled(index: number): boolean { @@ -1078,58 +1025,45 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return index; } - private refreshSalesTax(): void { - if ( - this.taxInformation === undefined || - !this.taxInformation.country || - !this.taxInformation.postalCode - ) { + private async refreshSalesTax(): Promise { + if (this.billingFormGroup.controls.billingAddress.invalid && !this.billingAddress) { return; } - const request: PreviewOrganizationInvoiceRequest = { - organizationId: this.organizationId, - passwordManager: { - additionalStorage: 0, - plan: this.selectedPlan?.type, - seats: this.sub.seats, - }, - taxInformation: { - postalCode: this.taxInformation.postalCode, - country: this.taxInformation.country, - taxId: this.taxInformation.taxId, - }, + const getPlanFromLegacyEnum = (planType: PlanType): OrganizationSubscriptionPlan => { + switch (planType) { + case PlanType.FamiliesAnnually: + case PlanType.FamiliesAnnually2025: + return { tier: "families", cadence: "annually" }; + case PlanType.TeamsMonthly: + return { tier: "teams", cadence: "monthly" }; + case PlanType.TeamsAnnually: + return { tier: "teams", cadence: "annually" }; + case PlanType.EnterpriseMonthly: + return { tier: "enterprise", cadence: "monthly" }; + case PlanType.EnterpriseAnnually: + return { tier: "enterprise", cadence: "annually" }; + } }; - if (this.organization.useSecretsManager) { - request.secretsManager = { - seats: this.sub.smSeats, - additionalMachineAccounts: - this.sub.smServiceAccounts - this.sub.plan.SecretsManager.baseServiceAccount, - }; - } + const billingAddress = this.billingFormGroup.controls.billingAddress.valid + ? getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress) + : this.billingAddress; - this.taxService - .previewOrganizationInvoice(request) - .then((invoice) => { - this.estimatedTax = invoice.taxAmount; - }) - .catch((error) => { - const translatedMessage = this.i18nService.t(error.message); - this.toastService.showToast({ - title: "", - variant: "error", - message: - !translatedMessage || translatedMessage === "" ? error.message : translatedMessage, - }); - }); + const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPlanChange( + this.organizationId, + getPlanFromLegacyEnum(this.selectedPlan.type), + billingAddress, + ); + + this.estimatedTax = taxAmounts.tax; } protected canUpdatePaymentInformation(): boolean { return ( this.upgradeRequiresPaymentMethod || this.showPayment || - this.isPaymentSourceEmpty() || + !this.paymentMethod || this.isSubscriptionCanceled ); } @@ -1146,4 +1080,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return this.i18nService.t("upgrade"); } } + + get supportsTaxId() { + return this.formGroup.value.productTier !== ProductTierType.Families; + } + + getCardBrandIcon = () => getCardBrandIcon(this.paymentMethod); } diff --git a/apps/web/src/app/billing/organizations/change-plan.component.ts b/apps/web/src/app/billing/organizations/change-plan.component.ts index 31cbf4e94bf..a3f14f5ce29 100644 --- a/apps/web/src/app/billing/organizations/change-plan.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan.component.ts @@ -6,16 +6,28 @@ import { ProductTierType } from "@bitwarden/common/billing/enums"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { LogService } from "@bitwarden/common/platform/abstractions/log.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: "app-change-plan", templateUrl: "change-plan.component.html", standalone: false, }) export class ChangePlanComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() organizationId: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() currentPlan: PlanResponse; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() preSelectedProductTier: ProductTierType; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onChanged = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onCanceled = new EventEmitter(); formPromise: Promise; diff --git a/apps/web/src/app/billing/organizations/download-license.component.ts b/apps/web/src/app/billing/organizations/download-license.component.ts index 8ada57e8377..e93ae5028dc 100644 --- a/apps/web/src/app/billing/organizations/download-license.component.ts +++ b/apps/web/src/app/billing/organizations/download-license.component.ts @@ -18,6 +18,8 @@ type DownloadLicenseDialogData = { organizationId: string; }; +// 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: "download-license.component.html", standalone: false, diff --git a/apps/web/src/app/billing/organizations/organization-billing-history-view.component.ts b/apps/web/src/app/billing/organizations/organization-billing-history-view.component.ts index ce4678ad8ef..a654ac272fe 100644 --- a/apps/web/src/app/billing/organizations/organization-billing-history-view.component.ts +++ b/apps/web/src/app/billing/organizations/organization-billing-history-view.component.ts @@ -10,6 +10,8 @@ import { BillingTransactionResponse, } from "@bitwarden/common/billing/models/response/billing.response"; +// 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: "organization-billing-history-view.component.html", standalone: false, diff --git a/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts b/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts index 692791db855..5c8df483587 100644 --- a/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts +++ b/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts @@ -11,7 +11,6 @@ import { WebPlatformUtilsService } from "../../core/web-platform-utils.service"; import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component"; import { OrganizationSubscriptionCloudComponent } from "./organization-subscription-cloud.component"; import { OrganizationSubscriptionSelfhostComponent } from "./organization-subscription-selfhost.component"; -import { OrganizationPaymentMethodComponent } from "./payment-method/organization-payment-method.component"; const routes: Routes = [ { @@ -26,17 +25,6 @@ const routes: Routes = [ : OrganizationSubscriptionCloudComponent, data: { titleId: "subscription" }, }, - { - path: "payment-method", - component: OrganizationPaymentMethodComponent, - canActivate: [ - organizationPermissionsGuard((org) => org.canEditPaymentMethods), - organizationIsUnmanaged, - ], - data: { - titleId: "paymentMethod", - }, - }, { path: "payment-details", component: OrganizationPaymentDetailsComponent, diff --git a/apps/web/src/app/billing/organizations/organization-billing.module.ts b/apps/web/src/app/billing/organizations/organization-billing.module.ts index 707a854de02..90ba04c4fa4 100644 --- a/apps/web/src/app/billing/organizations/organization-billing.module.ts +++ b/apps/web/src/app/billing/organizations/organization-billing.module.ts @@ -17,7 +17,6 @@ import { OrganizationBillingRoutingModule } from "./organization-billing-routing import { OrganizationPlansComponent } from "./organization-plans.component"; import { OrganizationSubscriptionCloudComponent } from "./organization-subscription-cloud.component"; import { OrganizationSubscriptionSelfhostComponent } from "./organization-subscription-selfhost.component"; -import { OrganizationPaymentMethodComponent } from "./payment-method/organization-payment-method.component"; import { SecretsManagerAdjustSubscriptionComponent } from "./sm-adjust-subscription.component"; import { SecretsManagerSubscribeStandaloneComponent } from "./sm-subscribe-standalone.component"; import { SubscriptionHiddenComponent } from "./subscription-hidden.component"; @@ -45,7 +44,6 @@ import { SubscriptionStatusComponent } from "./subscription-status.component"; SecretsManagerSubscribeStandaloneComponent, SubscriptionHiddenComponent, SubscriptionStatusComponent, - OrganizationPaymentMethodComponent, ], }) export class OrganizationBillingModule {} diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.html b/apps/web/src/app/billing/organizations/organization-plans.component.html index 3b765927c3c..6234fc6e6e3 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.html +++ b/apps/web/src/app/billing/organizations/organization-plans.component.html @@ -404,17 +404,16 @@

      {{ paymentDesc }}

      - - - + + } + + > +
      {{ "passwordManagerPlanPrice" | i18n }}: {{ passwordManagerSubtotal | currency: "USD $" }} diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 820bee950eb..561a3e03deb 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -11,10 +11,9 @@ import { } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { Router } from "@angular/router"; -import { Subject, firstValueFrom, takeUntil } from "rxjs"; +import { firstValueFrom, merge, Subject, takeUntil } from "rxjs"; import { debounceTime, map, switchMap } from "rxjs/operators"; -import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { @@ -32,21 +31,12 @@ import { ProviderOrganizationCreateRequest } from "@bitwarden/common/admin-conso import { ProviderResponse } from "@bitwarden/common/admin-console/models/response/provider/provider.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { - PaymentMethodType, - PlanSponsorshipType, - PlanType, - ProductTierType, -} from "@bitwarden/common/billing/enums"; -import { TaxInformation } from "@bitwarden/common/billing/models/domain"; -import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; -import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request"; -import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request"; +import { assertNonNullish } from "@bitwarden/common/auth/utils"; +import { PlanSponsorshipType, PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +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"; @@ -54,15 +44,26 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationId, ProviderId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { + OrganizationSubscriptionPlan, + OrganizationSubscriptionPurchase, + SubscriberBillingClient, + TaxClient, +} from "@bitwarden/web-vault/app/billing/clients"; +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, + getBillingAddressFromForm, +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { tokenizablePaymentMethodToLegacyEnum } from "@bitwarden/web-vault/app/billing/payment/types"; import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module"; import { BillingSharedModule, secretsManagerSubscribeFormFactory } from "../shared"; -import { PaymentComponent } from "../shared/payment/payment.component"; interface OnSuccessArgs { organizationId: string; @@ -75,24 +76,47 @@ const Allowed2020PlansForLegacyProviders = [ PlanType.EnterpriseMonthly2020, ]; +// 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-organization-plans", templateUrl: "organization-plans.component.html", - imports: [BillingSharedModule, OrganizationCreateModule], + imports: [ + BillingSharedModule, + OrganizationCreateModule, + EnterPaymentMethodComponent, + EnterBillingAddressComponent, + ], + providers: [SubscriberBillingClient, TaxClient], }) export class OrganizationPlansComponent implements OnInit, OnDestroy { - @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; - @ViewChild(ManageTaxInformationComponent) taxComponent: ManageTaxInformationComponent; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() organizationId?: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showFree = true; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showCancel = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() acceptingSponsorship = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() planSponsorshipType?: PlanSponsorshipType; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() currentPlan: PlanResponse; selectedFile: File; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get productTier(): ProductTierType { return this._productTier; @@ -104,9 +128,10 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } private _productTier = ProductTierType.Free; + private _familyPlan: PlanType; - protected taxInformation: TaxInformation; - + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get plan(): PlanType { return this._plan; @@ -116,13 +141,25 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this._plan = plan; this.formGroup?.controls?.plan?.setValue(plan); } + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() enableSecretsManagerByDefault: boolean; private _plan = PlanType.Free; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() providerId?: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() preSelectedProductTier?: ProductTierType; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSuccess = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onCanceled = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onTrialBillingSuccess = new EventEmitter(); loading = true; @@ -135,10 +172,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { secretsManagerSubscription = secretsManagerSubscribeFormFactory(this.formBuilder); - selfHostedForm = this.formBuilder.group({ - file: [null, [Validators.required]], - }); - formGroup = this.formBuilder.group({ name: [""], billingEmail: ["", [Validators.email]], @@ -152,6 +185,11 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { secretsManager: this.secretsManagerSubscription, }); + billingFormGroup = this.formBuilder.group({ + paymentMethod: EnterPaymentMethodComponent.getFormGroup(), + billingAddress: EnterBillingAddressComponent.getFormGroup(), + }); + passwordManagerPlans: PlanResponse[]; secretsManagerPlans: PlanResponse[]; organization: Organization; @@ -179,10 +217,10 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { private organizationApiService: OrganizationApiServiceAbstraction, private providerApiService: ProviderApiServiceAbstraction, private toastService: ToastService, - private configService: ConfigService, - private billingApiService: BillingApiServiceAbstraction, - private taxService: TaxServiceAbstraction, private accountService: AccountService, + private subscriberBillingClient: SubscriberBillingClient, + private taxClient: TaxClient, + private configService: ConfigService, ) { this.selfHosted = this.platformUtilsService.isSelfHost(); } @@ -199,9 +237,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { ); this.billing = await this.organizationApiService.getBilling(this.organizationId); this.sub = await this.organizationApiService.getSubscription(this.organizationId); - this.taxInformation = await this.organizationApiService.getTaxInfo(this.organizationId); - } else if (!this.selfHosted) { - this.taxInformation = await this.apiService.getTaxInfo(); + const billingAddress = await this.subscriberBillingClient.getBillingAddress({ + type: "organization", + data: this.organization, + }); + this.billingFormGroup.controls.billingAddress.patchValue({ + ...billingAddress, + taxId: billingAddress?.taxId?.value, + }); } if (!this.selfHosted) { @@ -217,10 +260,16 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } } + const milestone3FeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM26462_Milestone_3, + ); + this._familyPlan = milestone3FeatureEnabled + ? PlanType.FamiliesAnnually + : PlanType.FamiliesAnnually2025; if (this.currentPlan && this.currentPlan.productTier !== ProductTierType.Enterprise) { const upgradedPlan = this.passwordManagerPlans.find((plan) => this.currentPlan.productTier === ProductTierType.Free - ? plan.type === PlanType.FamiliesAnnually + ? plan.type === this._familyPlan : plan.upgradeSortOrder == this.currentPlan.upgradeSortOrder + 1, ); @@ -268,15 +317,17 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.loading = false; - this.formGroup.valueChanges.pipe(debounceTime(1000), takeUntil(this.destroy$)).subscribe(() => { - this.refreshSalesTax(); - }); - - this.secretsManagerForm.valueChanges - .pipe(debounceTime(1000), takeUntil(this.destroy$)) - .subscribe(() => { - this.refreshSalesTax(); - }); + merge( + this.formGroup.valueChanges, + this.billingFormGroup.valueChanges, + this.secretsManagerForm.valueChanges, + ) + .pipe( + debounceTime(1000), + switchMap(async () => await this.refreshSalesTax()), + takeUntil(this.destroy$), + ) + .subscribe(); if (this.enableSecretsManagerByDefault && this.selectedSecretsManagerPlan) { this.secretsManagerSubscription.patchValue({ @@ -337,9 +388,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { get selectableProducts() { if (this.acceptingSponsorship) { - const familyPlan = this.passwordManagerPlans.find( - (plan) => plan.type === PlanType.FamiliesAnnually, - ); + const familyPlan = this.passwordManagerPlans.find((plan) => plan.type === this._familyPlan); this.discount = familyPlan.PasswordManager.basePrice; return [familyPlan]; } @@ -356,6 +405,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { plan.productTier === ProductTierType.TeamsStarter) && (!this.currentPlan || this.currentPlan.upgradeSortOrder < plan.upgradeSortOrder) && (!this.hasProvider || plan.productTier !== ProductTierType.TeamsStarter) && + (plan.productTier !== ProductTierType.Families || plan.type === this._familyPlan) && ((!this.isProviderQualifiedFor2020Plan() && this.planIsEnabled(plan)) || (this.isProviderQualifiedFor2020Plan() && Allowed2020PlansForLegacyProviders.includes(plan.type))), @@ -372,6 +422,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.passwordManagerPlans?.filter( (plan) => plan.productTier === selectedProductTierType && + (plan.productTier !== ProductTierType.Families || plan.type === this._familyPlan) && ((!this.isProviderQualifiedFor2020Plan() && this.planIsEnabled(plan)) || (this.isProviderQualifiedFor2020Plan() && Allowed2020PlansForLegacyProviders.includes(plan.type))), @@ -438,7 +489,10 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } get passwordManagerSubtotal() { - let subTotal = this.selectedPlan.PasswordManager.basePrice; + const basePriceAfterDiscount = this.acceptingSponsorship + ? Math.max(this.selectedPlan.PasswordManager.basePrice - this.discount, 0) + : this.selectedPlan.PasswordManager.basePrice; + let subTotal = basePriceAfterDiscount; if ( this.selectedPlan.PasswordManager.hasAdditionalSeatsOption && this.formGroup.controls.additionalSeats.value @@ -448,19 +502,19 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.formGroup.value.additionalSeats, ); } - if ( - this.selectedPlan.PasswordManager.hasAdditionalStorageOption && - this.formGroup.controls.additionalStorage.value - ) { - subTotal += this.additionalStorageTotal(this.selectedPlan); - } if ( this.selectedPlan.PasswordManager.hasPremiumAccessOption && this.formGroup.controls.premiumAccessAddon.value ) { subTotal += this.selectedPlan.PasswordManager.premiumAccessOptionPrice; } - return subTotal - this.discount; + if ( + this.selectedPlan.PasswordManager.hasAdditionalStorageOption && + this.formGroup.controls.additionalStorage.value + ) { + subTotal += this.additionalStorageTotal(this.selectedPlan); + } + return subTotal; } get secretsManagerSubtotal() { @@ -587,34 +641,13 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.changedProduct(); } - protected changedCountry(): void { - this.paymentComponent.showBankAccount = this.taxInformation?.country === "US"; - if ( - !this.paymentComponent.showBankAccount && - this.paymentComponent.selected === PaymentMethodType.BankAccount - ) { - this.paymentComponent.select(PaymentMethodType.Card); - } - } - - protected onTaxInformationChanged(event: TaxInformation): void { - this.taxInformation = event; - this.changedCountry(); - this.refreshSalesTax(); - } - protected cancel(): void { this.onCanceled.emit(); } - protected setSelectedFile(event: Event): void { - const fileInputEl = event.target; - this.selectedFile = fileInputEl.files.length > 0 ? fileInputEl.files[0] : null; - } - submit = async () => { - if (this.taxComponent && !this.taxComponent.validate()) { - this.taxComponent.markAllAsTouched(); + this.formGroup.markAllAsTouched(); + if (this.formGroup.invalid) { return; } @@ -636,7 +669,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { orgId = this.selfHosted ? await this.createSelfHosted(key, collectionCt, orgKeys) - : await this.createCloudHosted(key, collectionCt, orgKeys, orgKey[1]); + : await this.createCloudHosted(key, collectionCt, orgKeys, orgKey[1], activeUserId); this.toastService.showToast({ variant: "success", @@ -652,7 +685,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { }); } - await this.apiService.refreshIdentityToken(); await this.syncService.fullSync(true); if (!this.acceptingSponsorship && !this.isInTrialFlow) { @@ -688,46 +720,91 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } } - private refreshSalesTax(): void { - if (!this.taxComponent.validate()) { + private getPlanFromLegacyEnum(): OrganizationSubscriptionPlan { + switch (this.formGroup.value.plan) { + case PlanType.FamiliesAnnually: + case PlanType.FamiliesAnnually2025: + return { tier: "families", cadence: "annually" }; + case PlanType.TeamsMonthly: + return { tier: "teams", cadence: "monthly" }; + case PlanType.TeamsAnnually: + return { tier: "teams", cadence: "annually" }; + case PlanType.EnterpriseMonthly: + return { tier: "enterprise", cadence: "monthly" }; + case PlanType.EnterpriseAnnually: + return { tier: "enterprise", cadence: "annually" }; + } + } + + private buildTaxPreviewRequest( + additionalStorage: number, + sponsored: boolean, + ): OrganizationSubscriptionPurchase { + const passwordManagerSeats = this.selectedPlan.PasswordManager.hasAdditionalSeatsOption + ? this.formGroup.value.additionalSeats + : 1; + + return { + ...this.getPlanFromLegacyEnum(), + passwordManager: { + seats: passwordManagerSeats, + additionalStorage, + sponsored, + }, + secretsManager: this.formGroup.value.secretsManager.enabled + ? { + seats: this.secretsManagerForm.value.userSeats, + additionalServiceAccounts: this.secretsManagerForm.value.additionalServiceAccounts, + standalone: false, + } + : undefined, + }; + } + + private async refreshSalesTax(): Promise { + if (this.billingFormGroup.controls.billingAddress.invalid) { return; } - const request: PreviewOrganizationInvoiceRequest = { - organizationId: this.organizationId, - passwordManager: { - additionalStorage: this.formGroup.controls.additionalStorage.value, - plan: this.formGroup.controls.plan.value, - sponsoredPlan: this.planSponsorshipType, - seats: this.formGroup.controls.additionalSeats.value, - }, - taxInformation: { - postalCode: this.taxInformation.postalCode, - country: this.taxInformation.country, - taxId: this.taxInformation.taxId, - }, - }; + const billingAddress = getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress); - if (this.secretsManagerForm.controls.enabled.value === true) { - request.secretsManager = { - seats: this.secretsManagerForm.controls.userSeats.value, - additionalMachineAccounts: this.secretsManagerForm.controls.additionalServiceAccounts.value, - }; + // should still be taxed. We mark the plan as NOT sponsored when there is additional storage + // so the server calculates tax, but we'll adjust the calculation to only tax the storage. + const hasPaidStorage = (this.formGroup.value.additionalStorage || 0) > 0; + const sponsoredForTaxPreview = this.acceptingSponsorship && !hasPaidStorage; + + if (this.acceptingSponsorship && hasPaidStorage) { + // For sponsored plans with paid storage, calculate tax only on storage + // by comparing tax on base+storage vs tax on base only + //TODO: Move this logic to PreviewOrganizationTaxCommand - https://bitwarden.atlassian.net/browse/PM-27585 + const [baseTaxAmounts, fullTaxAmounts] = await Promise.all([ + this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + this.buildTaxPreviewRequest(0, false), + billingAddress, + ), + this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + this.buildTaxPreviewRequest(this.formGroup.value.additionalStorage, false), + billingAddress, + ), + ]); + + // Tax on storage = Tax on (base + storage) - Tax on (base only) + this.estimatedTax = fullTaxAmounts.tax - baseTaxAmounts.tax; + } else { + const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + this.buildTaxPreviewRequest(this.formGroup.value.additionalStorage, sponsoredForTaxPreview), + billingAddress, + ); + + this.estimatedTax = taxAmounts.tax; } - this.taxService - .previewOrganizationInvoice(request) - .then((invoice) => { - this.estimatedTax = invoice.taxAmount; - this.total = invoice.totalAmount; - }) - .catch((error) => { - this.toastService.showToast({ - title: "", - variant: "error", - message: this.i18nService.t(error.message), - }); - }); + const subtotal = + this.passwordManagerSubtotal + + (this.planOffersSecretsManager && this.secretsManagerForm.value.enabled + ? this.secretsManagerSubtotal + : 0); + this.total = subtotal + this.estimatedTax; } private async updateOrganization() { @@ -738,21 +815,24 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.selectedPlan.PasswordManager.hasPremiumAccessOption && this.formGroup.controls.premiumAccessAddon.value; request.planType = this.selectedPlan.type; - request.billingAddressCountry = this.taxInformation?.country; - request.billingAddressPostalCode = this.taxInformation?.postalCode; + request.billingAddressCountry = this.billingFormGroup.value.billingAddress.country; + request.billingAddressPostalCode = this.billingFormGroup.value.billingAddress.postalCode; // Secrets Manager this.buildSecretsManagerRequest(request); if (this.upgradeRequiresPaymentMethod) { - const updatePaymentMethodRequest = new UpdatePaymentMethodRequest(); - updatePaymentMethodRequest.paymentSource = await this.paymentComponent.tokenize(); - updatePaymentMethodRequest.taxInformation = ExpandedTaxInfoUpdateRequest.From( - this.taxInformation, - ); - await this.billingApiService.updateOrganizationPaymentMethod( - this.organizationId, - updatePaymentMethodRequest, + if (this.billingFormGroup.invalid) { + return; + } + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + await this.subscriberBillingClient.updatePaymentMethod( + { type: "organization", data: this.organization }, + paymentMethod, + { + country: this.billingFormGroup.value.billingAddress.country, + postalCode: this.billingFormGroup.value.billingAddress.postalCode, + }, ); } @@ -779,6 +859,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { collectionCt: string, orgKeys: [string, EncString], orgKey: SymmetricCryptoKey, + activeUserId: UserId, ): Promise { const request = new OrganizationCreateRequest(); request.key = key; @@ -791,23 +872,31 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { if (this.selectedPlan.type === PlanType.Free) { request.planType = PlanType.Free; } else { - const { type, token } = await this.paymentComponent.tokenize(); + if (this.billingFormGroup.invalid) { + return; + } - request.paymentToken = token; - request.paymentMethodType = type; + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + + const billingAddress = getBillingAddressFromForm( + this.billingFormGroup.controls.billingAddress, + ); + + request.paymentToken = paymentMethod.token; + request.paymentMethodType = tokenizablePaymentMethodToLegacyEnum(paymentMethod.type); request.additionalSeats = this.formGroup.controls.additionalSeats.value; request.additionalStorageGb = this.formGroup.controls.additionalStorage.value; request.premiumAccessAddon = this.selectedPlan.PasswordManager.hasPremiumAccessOption && this.formGroup.controls.premiumAccessAddon.value; request.planType = this.selectedPlan.type; - request.billingAddressPostalCode = this.taxInformation?.postalCode; - request.billingAddressCountry = this.taxInformation?.country; - request.taxIdNumber = this.taxInformation?.taxId; - request.billingAddressLine1 = this.taxInformation?.line1; - request.billingAddressLine2 = this.taxInformation?.line2; - request.billingAddressCity = this.taxInformation?.city; - request.billingAddressState = this.taxInformation?.state; + request.billingAddressPostalCode = billingAddress.postalCode; + request.billingAddressCountry = billingAddress.country; + request.taxIdNumber = billingAddress.taxId?.value; + request.billingAddressLine1 = billingAddress.line1; + request.billingAddressLine2 = billingAddress.line2; + request.billingAddressCity = billingAddress.city; + request.billingAddressState = billingAddress.state; } // Secrets Manager @@ -818,7 +907,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.formGroup.controls.clientOwnerEmail.value, request, ); - const providerKey = await this.keyService.getProviderKey(this.providerId); + + const providerKey = await firstValueFrom( + this.keyService + .providerKeys$(activeUserId) + .pipe(map((providerKeys) => providerKeys?.[this.providerId as ProviderId] ?? null)), + ); + assertNonNullish(providerKey, "Provider key not found"); + providerRequest.organizationCreateRequest.key = ( await this.encryptService.wrapSymmetricKey(orgKey, providerKey) ).encryptedString; @@ -900,7 +996,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { if (this.currentPlan && this.currentPlan.productTier !== ProductTierType.Enterprise) { const upgradedPlan = this.passwordManagerPlans.find((plan) => { if (this.currentPlan.productTier === ProductTierType.Free) { - return plan.type === PlanType.FamiliesAnnually; + return plan.type === this._familyPlan; } if ( diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index 5fa10c4c87c..0666cca2c4b 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -223,7 +223,7 @@

      {{ "manageSubscription" | i18n }}

      {{ "manageSubscriptionFromThe" | i18n }} - {{ + {{ "providerPortal" | i18n }}. @@ -241,7 +241,7 @@

      -

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

      +

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

      {{ "billingContactProviderForAssistance" | i18n }}

      diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index 6bb262152ed..e0c1a12a80f 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -42,6 +42,8 @@ import { ChangePlanDialogResultType, openChangePlanDialog } from "./change-plan- import { DownloadLicenceDialogComponent } from "./download-license.component"; import { SecretsManagerSubscriptionOptions } from "./sm-adjust-subscription.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({ templateUrl: "organization-subscription-cloud.component.html", standalone: false, @@ -148,19 +150,17 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy const isResoldOrganizationOwner = this.userOrg.hasReseller && this.userOrg.isOwner; const isMSPUser = this.userOrg.hasProvider && this.userOrg.isProviderUser; - const metadata = await this.billingApiService.getOrganizationBillingMetadata( - this.organizationId, - ); - this.organizationIsManagedByConsolidatedBillingMSP = - this.userOrg.hasProvider && metadata.isManaged; + this.userOrg.hasProvider && this.userOrg.hasBillableProvider; this.showSubscription = isIndependentOrganizationOwner || isResoldOrganizationOwner || (isMSPUser && !this.organizationIsManagedByConsolidatedBillingMSP); - this.showSelfHost = metadata.isEligibleForSelfHost; + this.showSelfHost = + this.userOrg.productTierType === ProductTierType.Families || + this.userOrg.productTierType === ProductTierType.Enterprise; if (this.showSubscription) { this.sub = await this.organizationApiService.getSubscription(this.organizationId); @@ -300,6 +300,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy return this.i18nService.t("subscriptionFreePlan", this.sub.seats.toString()); } else if ( this.sub.planType === PlanType.FamiliesAnnually || + this.sub.planType === PlanType.FamiliesAnnually2025 || this.sub.planType === PlanType.FamiliesAnnually2019 || this.sub.planType === PlanType.TeamsStarter2023 || this.sub.planType === PlanType.TeamsStarter @@ -343,6 +344,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy data: { type: "Organization", id: this.organizationId, + plan: this.sub.plan.type, }, }); diff --git a/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.html b/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.html index 1c823ed76cc..d4828e359b9 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.html @@ -130,7 +130,7 @@ {{ "licenseAndBillingManagementDesc" | i18n }} -

      +

      {{ "uploadLicense" | i18n }}

      this.organizationService .organizations$(userId) - .pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)), + .pipe(getById(this.activatedRoute.snapshot.params.organizationId)), ), filter((organization): organization is Organization => !!organization), ); private load$: Observable = this.organization$.pipe( - switchMap((organization) => - this.configService - .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) - .pipe( - map((managePaymentDetailsOutsideCheckout) => { - if (!managePaymentDetailsOutsideCheckout) { - throw new RedirectError(["../payment-method"], this.activatedRoute); - } - return organization; - }), - ), - ), mapOrganizationToSubscriber, switchMap(async (organization) => { const getTaxIdWarning = firstValueFrom( @@ -132,14 +109,6 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy { taxIdWarning, }; }), - catchError((error: unknown) => { - if (error instanceof RedirectError) { - return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( - switchMap(() => EMPTY), - ); - } - throw error; - }), ); view$: Observable = merge( @@ -159,7 +128,6 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy { private messageListener: MessageListener, private organizationService: OrganizationService, private organizationWarningsService: OrganizationWarningsService, - private router: Router, private subscriberBillingClient: SubscriberBillingClient, ) {} diff --git a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.html b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.html deleted file mode 100644 index ab31147e916..00000000000 --- a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - {{ "loading" | i18n }} - - - - -

      - {{ accountCreditHeaderText }} -

      -

      {{ Math.abs(accountCredit) | currency: "$" }}

      -

      {{ "creditAppliedDesc" | i18n }}

      - -
      - - -

      {{ "paymentMethod" | i18n }}

      -

      {{ "noPaymentMethod" | i18n }}

      - - - -

      - - {{ paymentSource.description }} - - {{ "unverified" | i18n }} -

      -
      - -

      - {{ "paymentChargedWithUnpaidSubscription" | i18n }} -

      -
      -
      -
      diff --git a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts deleted file mode 100644 index 4106ee4f9cd..00000000000 --- a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { Location } from "@angular/common"; -import { Component, OnDestroy } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { ActivatedRoute, Router } from "@angular/router"; -import { combineLatest, firstValueFrom, from, lastValueFrom, map, switchMap } from "rxjs"; - -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { - OrganizationService, - getOrganizationById, -} 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 { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; -import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { TaxInformation } from "@bitwarden/common/billing/models/domain"; -import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request"; -import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; -import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SyncService } from "@bitwarden/common/platform/sync"; -import { DialogService, ToastService } from "@bitwarden/components"; - -import { BillingNotificationService } from "../../services/billing-notification.service"; -import { - AddCreditDialogResult, - openAddCreditDialog, -} from "../../shared/add-credit-dialog.component"; -import { - AdjustPaymentDialogComponent, - AdjustPaymentDialogResultType, -} from "../../shared/adjust-payment-dialog/adjust-payment-dialog.component"; -import { - TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE, - TrialPaymentDialogComponent, -} from "../../shared/trial-payment-dialog/trial-payment-dialog.component"; - -@Component({ - templateUrl: "./organization-payment-method.component.html", - standalone: false, -}) -export class OrganizationPaymentMethodComponent implements OnDestroy { - organizationId!: string; - isUnpaid = false; - accountCredit?: number; - paymentSource?: PaymentSourceResponse; - subscriptionStatus?: string; - organization?: Organization; - organizationSubscriptionResponse?: OrganizationSubscriptionResponse; - - loading = true; - - protected readonly Math = Math; - launchPaymentModalAutomatically = false; - - protected taxInformation?: TaxInformation; - - constructor( - private activatedRoute: ActivatedRoute, - private billingApiService: BillingApiServiceAbstraction, - protected organizationApiService: OrganizationApiServiceAbstraction, - private dialogService: DialogService, - private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService, - private router: Router, - private toastService: ToastService, - private location: Location, - private organizationService: OrganizationService, - private accountService: AccountService, - protected syncService: SyncService, - private billingNotificationService: BillingNotificationService, - private configService: ConfigService, - ) { - combineLatest([ - this.activatedRoute.params, - this.configService.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout), - ]) - .pipe( - switchMap(([{ organizationId }, managePaymentDetailsOutsideCheckout]) => { - if (this.platformUtilsService.isSelfHost()) { - return from(this.router.navigate(["/settings/subscription"])); - } - - if (managePaymentDetailsOutsideCheckout) { - return from( - this.router.navigate(["../payment-details"], { relativeTo: this.activatedRoute }), - ); - } - - this.organizationId = organizationId; - return from(this.load()); - }), - takeUntilDestroyed(), - ) - .subscribe(); - - const state = this.router.getCurrentNavigation()?.extras?.state; - // In case the above state is undefined or null, we use redundantState - const redundantState: any = location.getState(); - const queryParam = this.activatedRoute.snapshot.queryParamMap.get( - "launchPaymentModalAutomatically", - ); - if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) { - this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically; - } else if ( - redundantState && - Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically") - ) { - this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically; - } else { - this.launchPaymentModalAutomatically = queryParam === "true"; - } - } - ngOnDestroy(): void { - this.launchPaymentModalAutomatically = false; - } - - protected addAccountCredit = async (): Promise => { - if (this.subscriptionStatus === "trialing") { - const hasValidBillingAddress = await this.checkBillingAddressForTrialingOrg(); - if (!hasValidBillingAddress) { - return; - } - } - const dialogRef = openAddCreditDialog(this.dialogService, { - data: { - organizationId: this.organizationId, - }, - }); - - const result = await lastValueFrom(dialogRef.closed); - - if (result === AddCreditDialogResult.Added) { - await this.load(); - } - }; - - protected load = async (): Promise => { - this.loading = true; - try { - const { accountCredit, paymentSource, subscriptionStatus, taxInformation } = - await this.billingApiService.getOrganizationPaymentMethod(this.organizationId); - this.accountCredit = accountCredit; - this.paymentSource = paymentSource; - this.subscriptionStatus = subscriptionStatus; - this.taxInformation = taxInformation; - this.isUnpaid = this.subscriptionStatus === "unpaid"; - - if (this.organizationId) { - const organizationSubscriptionPromise = this.organizationApiService.getSubscription( - this.organizationId, - ); - - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); - - if (!userId) { - throw new Error("User ID is not found"); - } - - const organizationPromise = await firstValueFrom( - this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(this.organizationId)), - ); - - [this.organizationSubscriptionResponse, this.organization] = await Promise.all([ - organizationSubscriptionPromise, - organizationPromise, - ]); - - if (!this.organization) { - throw new Error("Organization is not found"); - } - if (!this.paymentSource) { - throw new Error("Payment source is not found"); - } - } - // If the flag `launchPaymentModalAutomatically` is set to true, - // we schedule a timeout (delay of 800ms) to automatically launch the payment modal. - // This delay ensures that any prior UI/rendering operations complete before triggering the modal. - if (this.launchPaymentModalAutomatically) { - window.setTimeout(async () => { - await this.changePayment(); - this.launchPaymentModalAutomatically = false; - this.location.replaceState(this.location.path(), "", {}); - }, 800); - } - } catch (error) { - this.billingNotificationService.handleError(error); - } finally { - this.loading = false; - } - }; - - protected updatePaymentMethod = async (): Promise => { - const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, { - data: { - initialPaymentMethod: this.paymentSource?.type, - organizationId: this.organizationId, - productTier: this.organization?.productTierType, - }, - }); - - const result = await lastValueFrom(dialogRef.closed); - - if (result === AdjustPaymentDialogResultType.Submitted) { - await this.load(); - } - }; - - changePayment = async () => { - const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, { - data: { - organizationId: this.organizationId, - subscription: this.organizationSubscriptionResponse!, - productTierType: this.organization!.productTierType, - }, - }); - const result = await lastValueFrom(dialogRef.closed); - if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) { - this.location.replaceState(this.location.path(), "", {}); - if (this.launchPaymentModalAutomatically && !this.organization?.enabled) { - await this.syncService.fullSync(true); - } - this.launchPaymentModalAutomatically = false; - await this.load(); - } - }; - - protected verifyBankAccount = async (request: VerifyBankAccountRequest): Promise => { - await this.billingApiService.verifyOrganizationBankAccount(this.organizationId, request); - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("verifiedBankAccount"), - }); - }; - - protected get accountCreditHeaderText(): string { - const hasAccountCredit = this.accountCredit && this.accountCredit > 0; - const key = hasAccountCredit ? "accountCredit" : "accountBalance"; - return this.i18nService.t(key); - } - - protected get paymentSourceClasses() { - if (this.paymentSource == null) { - return []; - } - switch (this.paymentSource.type) { - case PaymentMethodType.Card: - return ["bwi-credit-card"]; - case PaymentMethodType.BankAccount: - case PaymentMethodType.Check: - return ["bwi-billing"]; - case PaymentMethodType.PayPal: - return ["bwi-paypal text-primary"]; - default: - return []; - } - } - - protected get subscriptionIsUnpaid(): boolean { - return this.subscriptionStatus === "unpaid"; - } - - protected get updatePaymentSourceButtonText(): string { - const key = this.paymentSource == null ? "addPaymentMethod" : "changePaymentMethod"; - return this.i18nService.t(key); - } - - private async checkBillingAddressForTrialingOrg(): Promise { - const hasBillingAddress = this.taxInformation != null; - if (!hasBillingAddress) { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("billingAddressRequiredToAddCredit"), - }); - return false; - } - return true; - } -} diff --git a/apps/web/src/app/billing/organizations/sm-adjust-subscription.component.ts b/apps/web/src/app/billing/organizations/sm-adjust-subscription.component.ts index 33413832865..5fa6971bac6 100644 --- a/apps/web/src/app/billing/organizations/sm-adjust-subscription.component.ts +++ b/apps/web/src/app/billing/organizations/sm-adjust-subscription.component.ts @@ -56,14 +56,22 @@ export interface SecretsManagerSubscriptionOptions { additionalServiceAccountPrice: number; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-sm-adjust-subscription", templateUrl: "sm-adjust-subscription.component.html", standalone: false, }) export class SecretsManagerAdjustSubscriptionComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() organizationId: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() options: SecretsManagerSubscriptionOptions; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onAdjusted = new EventEmitter(); private destroy$ = new Subject(); diff --git a/apps/web/src/app/billing/organizations/sm-subscribe-standalone.component.ts b/apps/web/src/app/billing/organizations/sm-subscribe-standalone.component.ts index 6f9525e4fce..1ef705fd4bd 100644 --- a/apps/web/src/app/billing/organizations/sm-subscribe-standalone.component.ts +++ b/apps/web/src/app/billing/organizations/sm-subscribe-standalone.component.ts @@ -20,15 +20,25 @@ import { ToastService } from "@bitwarden/components"; import { secretsManagerSubscribeFormFactory } from "../shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-subscribe-standalone", templateUrl: "sm-subscribe-standalone.component.html", standalone: false, }) export class SecretsManagerSubscribeStandaloneComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() plan: PlanResponse; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() organization: Organization; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() customerDiscount: BillingCustomerDiscount; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSubscribe = new EventEmitter(); formGroup = secretsManagerSubscribeFormFactory(this.formBuilder); diff --git a/apps/web/src/app/billing/organizations/subscription-hidden.component.ts b/apps/web/src/app/billing/organizations/subscription-hidden.component.ts index cca12e938d2..ef6e2dd0495 100644 --- a/apps/web/src/app/billing/organizations/subscription-hidden.component.ts +++ b/apps/web/src/app/billing/organizations/subscription-hidden.component.ts @@ -4,18 +4,22 @@ import { Component, Input } from "@angular/core"; import { GearIcon } from "@bitwarden/assets/svg"; +// 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-org-subscription-hidden", template: `
      -

      {{ "billingManagedByProvider" | i18n: providerName }}

      +

      {{ "billingManagedByProvider" | i18n: providerName }}

      {{ "billingContactProviderForAssistance" | i18n }}

      `, standalone: false, }) export class SubscriptionHiddenComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() providerName: string; gearIcon = GearIcon; } diff --git a/apps/web/src/app/billing/organizations/subscription-status.component.ts b/apps/web/src/app/billing/organizations/subscription-status.component.ts index 0b59df3f707..54a309a441b 100644 --- a/apps/web/src/app/billing/organizations/subscription-status.component.ts +++ b/apps/web/src/app/billing/organizations/subscription-status.component.ts @@ -23,13 +23,19 @@ type ComponentData = { }; }; +// 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-subscription-status", templateUrl: "subscription-status.component.html", standalone: false, }) export class SubscriptionStatusComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) organizationSubscriptionResponse: OrganizationSubscriptionResponse; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() reinstatementRequested = new EventEmitter(); constructor( diff --git a/apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts b/apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts index 8390e432236..debac3cb2f7 100644 --- a/apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts +++ b/apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts @@ -8,6 +8,8 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { OrganizationWarningsService } from "../services"; import { OrganizationFreeTrialWarning } from "../types"; +// 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-organization-free-trial-warning", template: ` @@ -36,8 +38,14 @@ import { OrganizationFreeTrialWarning } from "../types"; imports: [BannerModule, SharedModule], }) export class OrganizationFreeTrialWarningComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) organization!: Organization; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() includeOrganizationNameInMessaging = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() clicked = new EventEmitter(); warning$!: Observable; diff --git a/apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts b/apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts index c49f59f6b05..e9850b55c9e 100644 --- a/apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts +++ b/apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts @@ -8,6 +8,8 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { OrganizationWarningsService } from "../services"; import { OrganizationResellerRenewalWarning } from "../types"; +// 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-organization-reseller-renewal-warning", template: ` @@ -27,6 +29,8 @@ import { OrganizationResellerRenewalWarning } from "../types"; imports: [BannerModule, SharedModule], }) export class OrganizationResellerRenewalWarningComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) organization!: Organization; warning$!: Observable; diff --git a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts index c7a297cc28b..9466e813e4d 100644 --- a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts +++ b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts @@ -15,9 +15,8 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogRef, DialogService } from "@bitwarden/components"; import { OrganizationBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { @@ -35,11 +34,11 @@ import { TaxIdWarningTypes } from "@bitwarden/web-vault/app/billing/warnings/typ describe("OrganizationWarningsService", () => { let service: OrganizationWarningsService; - let configService: MockProxy; let dialogService: MockProxy; let i18nService: MockProxy; let organizationApiService: MockProxy; let organizationBillingClient: MockProxy; + let platformUtilsService: MockProxy; let router: MockProxy; const organization = { @@ -57,15 +56,17 @@ describe("OrganizationWarningsService", () => { }); beforeEach(() => { - configService = mock(); dialogService = mock(); i18nService = mock(); organizationApiService = mock(); organizationBillingClient = mock(); + platformUtilsService = mock(); router = mock(); (openChangePlanDialog as jest.Mock).mockReset(); + platformUtilsService.isSelfHost.mockReturnValue(false); + i18nService.t.mockImplementation((key: string, ...args: any[]) => { switch (key) { case "freeTrialEndPromptCount": @@ -94,11 +95,11 @@ describe("OrganizationWarningsService", () => { TestBed.configureTestingModule({ providers: [ OrganizationWarningsService, - { provide: ConfigService, useValue: configService }, { provide: DialogService, useValue: dialogService }, { provide: I18nService, useValue: i18nService }, { provide: OrganizationApiServiceAbstraction, useValue: organizationApiService }, { provide: OrganizationBillingClient, useValue: organizationBillingClient }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, { provide: Router, useValue: router }, ], }); @@ -116,6 +117,16 @@ describe("OrganizationWarningsService", () => { }); }); + it("should return null when platform is self-hosted", (done) => { + platformUtilsService.isSelfHost.mockReturnValue(true); + + service.getFreeTrialWarning$(organization).subscribe((result) => { + expect(result).toBeNull(); + expect(organizationBillingClient.getWarnings).not.toHaveBeenCalled(); + done(); + }); + }); + it("should return warning with count message when remaining trial days >= 2", (done) => { const warning = { remainingTrialDays: 5 }; organizationBillingClient.getWarnings.mockResolvedValue({ @@ -211,6 +222,16 @@ describe("OrganizationWarningsService", () => { }); }); + it("should return null when platform is self-hosted", (done) => { + platformUtilsService.isSelfHost.mockReturnValue(true); + + service.getResellerRenewalWarning$(organization).subscribe((result) => { + expect(result).toBeNull(); + expect(organizationBillingClient.getWarnings).not.toHaveBeenCalled(); + done(); + }); + }); + it("should return upcoming warning with correct type and message", (done) => { const renewalDate = new Date(2024, 11, 31); const warning = { @@ -303,6 +324,16 @@ describe("OrganizationWarningsService", () => { }); }); + it("should return null when platform is self-hosted", (done) => { + platformUtilsService.isSelfHost.mockReturnValue(true); + + service.getTaxIdWarning$(organization).subscribe((result) => { + expect(result).toBeNull(); + expect(organizationBillingClient.getWarnings).not.toHaveBeenCalled(); + done(); + }); + }); + it("should return tax_id_missing type when tax ID is missing", (done) => { const warning = { type: TaxIdWarningTypes.Missing }; organizationBillingClient.getWarnings.mockResolvedValue({ @@ -426,11 +457,19 @@ describe("OrganizationWarningsService", () => { it("should not show dialog when no inactive subscription warning exists", (done) => { organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); - service.showInactiveSubscriptionDialog$(organization).subscribe({ - complete: () => { - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); - done(); - }, + service.showInactiveSubscriptionDialog$(organization).subscribe(() => { + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + done(); + }); + }); + + it("should not show dialog when platform is self-hosted", (done) => { + platformUtilsService.isSelfHost.mockReturnValue(true); + + service.showInactiveSubscriptionDialog$(organization).subscribe(() => { + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(organizationBillingClient.getWarnings).not.toHaveBeenCalled(); + done(); }); }); @@ -442,20 +481,18 @@ describe("OrganizationWarningsService", () => { dialogService.openSimpleDialog.mockResolvedValue(true); - service.showInactiveSubscriptionDialog$(organization).subscribe({ - complete: () => { - expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ - title: "Test Organization subscription suspended", - content: { - key: "suspendedManagedOrgMessage", - placeholders: ["Test Reseller Inc"], - }, - type: "danger", - acceptButtonText: "Close", - cancelButtonText: null, - }); - done(); - }, + service.showInactiveSubscriptionDialog$(organization).subscribe(() => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: "Test Organization subscription suspended", + content: { + key: "suspendedManagedOrgMessage", + placeholders: ["Test Reseller Inc"], + }, + type: "danger", + acceptButtonText: "Close", + cancelButtonText: null, + }); + done(); }); }); @@ -466,27 +503,21 @@ describe("OrganizationWarningsService", () => { } as OrganizationWarningsResponse); dialogService.openSimpleDialog.mockResolvedValue(true); - configService.getFeatureFlag.mockResolvedValue(false); router.navigate.mockResolvedValue(true); - service.showInactiveSubscriptionDialog$(organization).subscribe({ - complete: () => { - expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ - title: "Test Organization subscription suspended", - content: { key: "suspendedOwnerOrgMessage" }, - type: "danger", - acceptButtonText: "Continue", - cancelButtonText: "Close", - }); - expect(configService.getFeatureFlag).toHaveBeenCalledWith( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, - ); - expect(router.navigate).toHaveBeenCalledWith( - ["organizations", "org-id-123", "billing", "payment-method"], - { state: { launchPaymentModalAutomatically: true } }, - ); - done(); - }, + service.showInactiveSubscriptionDialog$(organization).subscribe(() => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: "Test Organization subscription suspended", + content: { key: "suspendedOwnerOrgMessage" }, + type: "danger", + acceptButtonText: "Continue", + cancelButtonText: "Close", + }); + expect(router.navigate).toHaveBeenCalledWith( + ["organizations", "org-id-123", "billing", "payment-details"], + { state: { launchPaymentModalAutomatically: true } }, + ); + done(); }); }); @@ -497,17 +528,14 @@ describe("OrganizationWarningsService", () => { } as OrganizationWarningsResponse); dialogService.openSimpleDialog.mockResolvedValue(true); - configService.getFeatureFlag.mockResolvedValue(true); router.navigate.mockResolvedValue(true); - service.showInactiveSubscriptionDialog$(organization).subscribe({ - complete: () => { - expect(router.navigate).toHaveBeenCalledWith( - ["organizations", "org-id-123", "billing", "payment-details"], - { state: { launchPaymentModalAutomatically: true } }, - ); - done(); - }, + service.showInactiveSubscriptionDialog$(organization).subscribe(() => { + expect(router.navigate).toHaveBeenCalledWith( + ["organizations", "org-id-123", "billing", "payment-details"], + { state: { launchPaymentModalAutomatically: true } }, + ); + done(); }); }); @@ -519,13 +547,10 @@ describe("OrganizationWarningsService", () => { dialogService.openSimpleDialog.mockResolvedValue(false); - service.showInactiveSubscriptionDialog$(organization).subscribe({ - complete: () => { - expect(dialogService.openSimpleDialog).toHaveBeenCalled(); - expect(configService.getFeatureFlag).not.toHaveBeenCalled(); - expect(router.navigate).not.toHaveBeenCalled(); - done(); - }, + service.showInactiveSubscriptionDialog$(organization).subscribe(() => { + expect(dialogService.openSimpleDialog).toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + done(); }); }); @@ -545,18 +570,16 @@ describe("OrganizationWarningsService", () => { (openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef); - service.showInactiveSubscriptionDialog$(organization).subscribe({ - complete: () => { - expect(organizationApiService.getSubscription).toHaveBeenCalledWith(organization.id); - expect(openChangePlanDialog).toHaveBeenCalledWith(dialogService, { - data: { - organizationId: organization.id, - subscription: subscription, - productTierType: organization.productTierType, - }, - }); - done(); - }, + service.showInactiveSubscriptionDialog$(organization).subscribe(() => { + expect(organizationApiService.getSubscription).toHaveBeenCalledWith(organization.id); + expect(openChangePlanDialog).toHaveBeenCalledWith(dialogService, { + data: { + organizationId: organization.id, + subscription: subscription, + productTierType: organization.productTierType, + }, + }); + done(); }); }); @@ -568,17 +591,15 @@ describe("OrganizationWarningsService", () => { dialogService.openSimpleDialog.mockResolvedValue(true); - service.showInactiveSubscriptionDialog$(organization).subscribe({ - complete: () => { - expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ - title: "Test Organization subscription suspended", - content: { key: "suspendedUserOrgMessage" }, - type: "danger", - acceptButtonText: "Close", - cancelButtonText: null, - }); - done(); - }, + service.showInactiveSubscriptionDialog$(organization).subscribe(() => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: "Test Organization subscription suspended", + content: { key: "suspendedUserOrgMessage" }, + type: "danger", + acceptButtonText: "Close", + cancelButtonText: null, + }); + done(); }); }); }); @@ -595,6 +616,18 @@ describe("OrganizationWarningsService", () => { }); }); + it("should not show dialog when platform is self-hosted", (done) => { + platformUtilsService.isSelfHost.mockReturnValue(true); + + service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({ + complete: () => { + expect(organizationApiService.getSubscription).not.toHaveBeenCalled(); + expect(organizationBillingClient.getWarnings).not.toHaveBeenCalled(); + done(); + }, + }); + }); + it("should open trial payment dialog when free trial warning exists", (done) => { const warning = { remainingTrialDays: 2 }; const subscription = { id: "sub-123" } as OrganizationSubscriptionResponse; diff --git a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts index c6bb1bc231b..a34533bcada 100644 --- a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts +++ b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts @@ -8,6 +8,7 @@ import { map, merge, Observable, + of, Subject, switchMap, tap, @@ -16,9 +17,8 @@ import { take } from "rxjs/operators"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; import { OrganizationBillingClient } from "@bitwarden/web-vault/app/billing/clients"; @@ -48,16 +48,17 @@ export class OrganizationWarningsService { private refreshFreeTrialWarningTrigger = new Subject(); private refreshTaxIdWarningTrigger = new Subject(); + private refreshInactiveSubscriptionWarningTrigger = new Subject(); private taxIdWarningRefreshedSubject = new BehaviorSubject(null); taxIdWarningRefreshed$ = this.taxIdWarningRefreshedSubject.asObservable(); constructor( - private configService: ConfigService, private dialogService: DialogService, private i18nService: I18nService, private organizationApiService: OrganizationApiServiceAbstraction, private organizationBillingClient: OrganizationBillingClient, + private platformUtilsService: PlatformUtilsService, private router: Router, ) {} @@ -167,12 +168,24 @@ export class OrganizationWarningsService { refreshFreeTrialWarning = () => this.refreshFreeTrialWarningTrigger.next(); + refreshInactiveSubscriptionWarning = () => this.refreshInactiveSubscriptionWarningTrigger.next(); + refreshTaxIdWarning = () => this.refreshTaxIdWarningTrigger.next(); showInactiveSubscriptionDialog$ = (organization: Organization): Observable => - this.getWarning$(organization, (response) => response.inactiveSubscription).pipe( - filter((warning) => warning !== null), + merge( + this.getWarning$(organization, (response) => response.inactiveSubscription), + this.refreshInactiveSubscriptionWarningTrigger.pipe( + switchMap(() => + this.getWarning$(organization, (response) => response.inactiveSubscription, true), + ), + ), + ).pipe( switchMap(async (warning) => { + if (!warning) { + return; + } + switch (warning.resolution) { case "contact_provider": { await this.dialogService.openSimpleDialog({ @@ -196,14 +209,8 @@ export class OrganizationWarningsService { cancelButtonText: this.i18nService.t("close"), }); if (confirmed) { - const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, - ); - const route = managePaymentDetailsOutsideCheckout - ? "payment-details" - : "payment-method"; await this.router.navigate( - ["organizations", `${organization.id}`, "billing", route], + ["organizations", `${organization.id}`, "billing", "payment-details"], { state: { launchPaymentModalAutomatically: true }, }, @@ -277,12 +284,17 @@ export class OrganizationWarningsService { organization: Organization, extract: (response: OrganizationWarningsResponse) => T | null | undefined, bypassCache: boolean = false, - ): Observable => - this.readThroughWarnings$(organization, bypassCache).pipe( + ): Observable => { + if (this.platformUtilsService.isSelfHost()) { + return of(null); + } + + return this.readThroughWarnings$(organization, bypassCache).pipe( map((response) => { const value = extract(response); return value ? value : null; }), take(1), ); + }; } diff --git a/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts b/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts index a83a00e8158..1ba1536ff36 100644 --- a/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts @@ -52,11 +52,13 @@ const positiveNumberValidator = return null; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: ` - + {{ "addCredit" | i18n }}
      @@ -128,6 +130,8 @@ const positiveNumberValidator = providers: [SubscriberBillingClient], }) export class AddAccountCreditDialogComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm!: ElementRef; protected payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig; diff --git a/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts index 4d2fadaa894..756f7281049 100644 --- a/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts @@ -18,11 +18,13 @@ type DialogParams = { subscriber: BitwardenSubscriber; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: ` - + {{ "changePaymentMethod" | i18n }}
      diff --git a/apps/web/src/app/billing/payment/components/display-account-credit.component.ts b/apps/web/src/app/billing/payment/components/display-account-credit.component.ts index f6aa0ef58bb..b4684f0d739 100644 --- a/apps/web/src/app/billing/payment/components/display-account-credit.component.ts +++ b/apps/web/src/app/billing/payment/components/display-account-credit.component.ts @@ -10,6 +10,8 @@ import { BitwardenSubscriber } from "../../types"; import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.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-display-account-credit", template: ` @@ -26,7 +28,11 @@ import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.com providers: [SubscriberBillingClient, CurrencyPipe], }) export class DisplayAccountCreditComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) subscriber!: BitwardenSubscriber; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) credit!: number | null; constructor( diff --git a/apps/web/src/app/billing/payment/components/display-billing-address.component.ts b/apps/web/src/app/billing/payment/components/display-billing-address.component.ts index 03d21a79003..2c5b7986c7b 100644 --- a/apps/web/src/app/billing/payment/components/display-billing-address.component.ts +++ b/apps/web/src/app/billing/payment/components/display-billing-address.component.ts @@ -12,6 +12,8 @@ import { } from "@bitwarden/web-vault/app/billing/warnings/types"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-display-billing-address", template: ` @@ -48,9 +50,17 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; imports: [AddressPipe, SharedModule], }) export class DisplayBillingAddressComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) subscriber!: BitwardenSubscriber; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) billingAddress!: BillingAddress | null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() taxIdWarning?: TaxIdWarningType; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() updated = new EventEmitter(); constructor(private dialogService: DialogService) {} diff --git a/apps/web/src/app/billing/payment/components/display-payment-method.component.ts b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts index c33d805aed7..c5ffa4268ed 100644 --- a/apps/web/src/app/billing/payment/components/display-payment-method.component.ts +++ b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts @@ -5,10 +5,12 @@ import { DialogService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; import { BitwardenSubscriber } from "../../types"; -import { MaskedPaymentMethod } from "../types"; +import { getCardBrandIcon, MaskedPaymentMethod } from "../types"; import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.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-display-payment-method", template: ` @@ -40,9 +42,9 @@ import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dial } @case ("card") {

      - @let brandIcon = getBrandIconForCard(); - @if (brandIcon !== null) { - + @let cardBrandIcon = getCardBrandIcon(); + @if (cardBrandIcon !== null) { + } @else { } @@ -70,20 +72,16 @@ import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dial imports: [SharedModule], }) export class DisplayPaymentMethodComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) subscriber!: BitwardenSubscriber; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) paymentMethod!: MaskedPaymentMethod | null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() updated = new EventEmitter(); - protected availableCardIcons: Record = { - amex: "card-amex", - diners: "card-diners-club", - discover: "card-discover", - jcb: "card-jcb", - mastercard: "card-mastercard", - unionpay: "card-unionpay", - visa: "card-visa", - }; - constructor(private dialogService: DialogService) {} changePaymentMethod = async (): Promise => { @@ -100,13 +98,5 @@ export class DisplayPaymentMethodComponent { } }; - protected getBrandIconForCard = (): string | null => { - if (this.paymentMethod?.type !== "card") { - return null; - } - - return this.paymentMethod.brand in this.availableCardIcons - ? this.availableCardIcons[this.paymentMethod.brand] - : null; - }; + protected getCardBrandIcon = () => getCardBrandIcon(this.paymentMethod); } diff --git a/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts b/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts index de2f2f94497..3ac7cbd8702 100644 --- a/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts @@ -11,10 +11,7 @@ import { ToastService, } from "@bitwarden/components"; import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; -import { - BillingAddress, - getTaxIdTypeForCountry, -} from "@bitwarden/web-vault/app/billing/payment/types"; +import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types"; import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types"; import { TaxIdWarningType, @@ -22,7 +19,10 @@ import { } from "@bitwarden/web-vault/app/billing/warnings/types"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; -import { EnterBillingAddressComponent } from "./enter-billing-address.component"; +import { + EnterBillingAddressComponent, + getBillingAddressFromForm, +} from "./enter-billing-address.component"; type DialogParams = { subscriber: BitwardenSubscriber; @@ -35,11 +35,13 @@ type DialogResult = | { type: "error" } | { type: "success"; billingAddress: BillingAddress }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: ` - + {{ "editBillingAddress" | i18n }}

      @@ -104,13 +106,7 @@ export class EditBillingAddressDialogComponent { return; } - const { taxId, ...addressFields } = this.formGroup.getRawValue(); - - const taxIdType = taxId ? getTaxIdTypeForCountry(addressFields.country) : null; - - const billingAddress = taxIdType - ? { ...addressFields, taxId: { code: taxIdType.code, value: taxId! } } - : { ...addressFields, taxId: null }; + const billingAddress = getBillingAddressFromForm(this.formGroup); const result = await this.billingClient.updateBillingAddress( this.dialogParams.subscriber, diff --git a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts index 7659b7ed5ca..db95beea7f8 100644 --- a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts +++ b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts @@ -24,6 +24,17 @@ export interface BillingAddressControls { export type BillingAddressFormGroup = FormGroup>; +export const getBillingAddressFromForm = (formGroup: BillingAddressFormGroup): BillingAddress => + getBillingAddressFromControls(formGroup.getRawValue()); + +export const getBillingAddressFromControls = (controls: BillingAddressControls) => { + const { taxId, ...addressFields } = controls; + const taxIdType = taxId ? getTaxIdTypeForCountry(addressFields.country) : null; + return taxIdType + ? { ...addressFields, taxId: { code: taxIdType.code, value: taxId! } } + : { ...addressFields, taxId: null }; +}; + type Scenario = | { type: "checkout"; @@ -36,6 +47,8 @@ type Scenario = taxIdWarning?: TaxIdWarningType; }; +// 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-enter-billing-address", template: ` @@ -57,7 +70,7 @@ type Scenario =
      - {{ "zipPostalCode" | i18n }} + {{ "zipPostalCodeLabel" | i18n }}
      -
      - - {{ "address1" | i18n }} - - -
      -
      - - {{ "address2" | i18n }} - - -
      -
      - - {{ "cityTown" | i18n }} - - -
      -
      - - {{ "stateProvince" | i18n }} - - -
      + @if (scenario.type === "update") { +
      + + {{ "address1" | i18n }} + + +
      +
      + + {{ "address2" | i18n }} + + +
      +
      + + {{ "cityTown" | i18n }} + + +
      +
      + + {{ "stateProvince" | i18n }} + + +
      + } @if (supportsTaxId$ | async) {
      @@ -146,7 +161,11 @@ type Scenario = imports: [SharedModule], }) export class EnterBillingAddressComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) scenario!: Scenario; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) group!: BillingAddressFormGroup; protected selectableCountries = selectableCountries; @@ -175,7 +194,7 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy { this.supportsTaxId$ = this.group.controls.country.valueChanges.pipe( startWith(this.group.value.country ?? this.selectableCountries[0].value), map((country) => { - if (!this.scenario.supportsTaxId) { + if (!this.scenario.supportsTaxId || country === "US") { return false; } diff --git a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts index 93c45b873fe..9e7b870579d 100644 --- a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts +++ b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts @@ -1,22 +1,25 @@ -import { Component, Input, OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { map, Observable, of, startWith, Subject, takeUntil } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PopoverModule, ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; import { BillingServicesModule, BraintreeService, StripeService } from "../../services"; -import { PaymentLabelComponent } from "../../shared/payment/payment-label.component"; import { + AccountCreditPaymentMethod, isTokenizablePaymentMethod, selectableCountries, TokenizablePaymentMethod, TokenizedPaymentMethod, } from "../types"; -type PaymentMethodOption = TokenizablePaymentMethod | "accountCredit"; +import { PaymentLabelComponent } from "./payment-label.component"; + +type PaymentMethodOption = TokenizablePaymentMethod | AccountCreditPaymentMethod; type PaymentMethodFormGroup = FormGroup<{ type: FormControl; @@ -34,14 +37,15 @@ type PaymentMethodFormGroup = FormGroup<{ @Component({ selector: "app-enter-payment-method", + changeDetection: ChangeDetectionStrategy.OnPush, template: ` - @let showBillingDetails = includeBillingAddress && selected !== "payPal"; - + @let showBillingDetails = includeBillingAddress() && selected !== "payPal"; + @if (showBillingDetails) {
      {{ "paymentMethod" | i18n }}
      }
      - + @@ -56,7 +60,7 @@ type PaymentMethodFormGroup = FormGroup<{ } - @if (showPayPal) { + @if (showPayPal()) { @@ -64,7 +68,7 @@ type PaymentMethodFormGroup = FormGroup<{ } - @if (showAccountCredit) { + @if (showAccountCredit()) { @@ -78,10 +82,10 @@ type PaymentMethodFormGroup = FormGroup<{ @case ("card") {
      - - {{ "number" | i18n }} + + {{ "cardNumberLabel" | i18n }} -
      +
      - + {{ "expiration" | i18n }} -
      +
      - + {{ "securityCodeSlashCVV" | i18n }}

      {{ "cardSecurityCodeDescription" | i18n }}

      -
      +
      } @@ -127,7 +131,7 @@ type PaymentMethodFormGroup = FormGroup<{ bitInput id="routingNumber" type="text" - [formControl]="group.controls.bankAccount.controls.routingNumber" + [formControl]="group().controls.bankAccount.controls.routingNumber" required /> @@ -137,7 +141,7 @@ type PaymentMethodFormGroup = FormGroup<{ bitInput id="accountNumber" type="text" - [formControl]="group.controls.bankAccount.controls.accountNumber" + [formControl]="group().controls.bankAccount.controls.accountNumber" required /> @@ -147,7 +151,7 @@ type PaymentMethodFormGroup = FormGroup<{ id="accountHolderName" bitInput type="text" - [formControl]="group.controls.bankAccount.controls.accountHolderName" + [formControl]="group().controls.bankAccount.controls.accountHolderName" required /> @@ -155,7 +159,7 @@ type PaymentMethodFormGroup = FormGroup<{ {{ "bankAccountType" | i18n }} @@ -182,19 +186,25 @@ type PaymentMethodFormGroup = FormGroup<{ } @case ("accountCredit") { - - {{ "makeSureEnoughCredit" | i18n }} - + @if (hasEnoughAccountCredit()) { + + {{ "makeSureEnoughCredit" | i18n }} + + } @else { + + {{ "notEnoughAccountCredit" | i18n }} + + } } } @if (showBillingDetails) { -
      {{ "billingAddress" | i18n }}
      +
      {{ "billingAddress" | i18n }}
      {{ "country" | i18n }} - + @for (selectableCountry of selectableCountries; track selectableCountry.value) {
      - {{ "zipPostalCode" | i18n }} + {{ "zipPostalCodeLabel" | i18n }} @@ -223,13 +233,15 @@ type PaymentMethodFormGroup = FormGroup<{ standalone: true, imports: [BillingServicesModule, PaymentLabelComponent, PopoverModule, SharedModule], }) -export class EnterPaymentMethodComponent implements OnInit { - @Input({ required: true }) group!: PaymentMethodFormGroup; +export class EnterPaymentMethodComponent implements OnInit, OnDestroy { + protected readonly instanceId = Utils.newGuid(); - @Input() private showBankAccount = true; - @Input() showPayPal = true; - @Input() showAccountCredit = false; - @Input() includeBillingAddress = false; + readonly group = input.required(); + protected readonly showBankAccount = input(true); + readonly showPayPal = input(true); + readonly showAccountCredit = input(false); + readonly hasEnoughAccountCredit = input(true); + readonly includeBillingAddress = input(false); protected showBankAccount$!: Observable; protected selectableCountries = selectableCountries; @@ -246,57 +258,62 @@ export class EnterPaymentMethodComponent implements OnInit { ngOnInit() { this.stripeService.loadStripe( + this.instanceId, { - cardNumber: "#stripe-card-number", - cardExpiry: "#stripe-card-expiry", - cardCvc: "#stripe-card-cvc", + cardNumber: `#stripe-card-number-${this.instanceId}`, + cardExpiry: `#stripe-card-expiry-${this.instanceId}`, + cardCvc: `#stripe-card-cvc-${this.instanceId}`, }, true, ); - if (this.showPayPal) { + if (this.showPayPal()) { this.braintreeService.loadBraintree("#braintree-container", false); } - if (!this.includeBillingAddress) { - this.showBankAccount$ = of(this.showBankAccount); - this.group.controls.billingAddress.disable(); + if (!this.includeBillingAddress()) { + this.showBankAccount$ = of(this.showBankAccount()); + this.group().controls.billingAddress.disable(); } else { - this.group.controls.billingAddress.patchValue({ + this.group().controls.billingAddress.patchValue({ country: "US", }); - this.showBankAccount$ = this.group.controls.billingAddress.controls.country.valueChanges.pipe( - startWith(this.group.controls.billingAddress.controls.country.value), - map((country) => this.showBankAccount && country === "US"), - ); + this.showBankAccount$ = + this.group().controls.billingAddress.controls.country.valueChanges.pipe( + startWith(this.group().controls.billingAddress.controls.country.value), + map((country) => this.showBankAccount() && country === "US"), + ); } - this.group.controls.type.valueChanges - .pipe(startWith(this.group.controls.type.value), takeUntil(this.destroy$)) + this.group() + .controls.type.valueChanges.pipe( + startWith(this.group().controls.type.value), + takeUntil(this.destroy$), + ) .subscribe((selected) => { if (selected === "bankAccount") { - this.group.controls.bankAccount.enable(); - if (this.includeBillingAddress) { - this.group.controls.billingAddress.enable(); + this.group().controls.bankAccount.enable(); + if (this.includeBillingAddress()) { + this.group().controls.billingAddress.enable(); } } else { switch (selected) { case "card": { - this.stripeService.mountElements(); - if (this.includeBillingAddress) { - this.group.controls.billingAddress.enable(); + this.stripeService.mountElements(this.instanceId); + if (this.includeBillingAddress()) { + this.group().controls.billingAddress.enable(); } break; } case "payPal": { this.braintreeService.createDropin(); - if (this.includeBillingAddress) { - this.group.controls.billingAddress.disable(); + if (this.includeBillingAddress()) { + this.group().controls.billingAddress.disable(); } break; } } - this.group.controls.bankAccount.disable(); + this.group().controls.bankAccount.disable(); } }); @@ -307,22 +324,28 @@ export class EnterPaymentMethodComponent implements OnInit { }); } - select = (paymentMethod: PaymentMethodOption) => - this.group.controls.type.patchValue(paymentMethod); + ngOnDestroy() { + this.stripeService.unloadStripe(this.instanceId); + this.destroy$.next(); + this.destroy$.complete(); + } - tokenize = async (): Promise => { + select = (paymentMethod: PaymentMethodOption) => + this.group().controls.type.patchValue(paymentMethod); + + tokenize = async (): Promise => { const exchange = async (paymentMethod: TokenizablePaymentMethod) => { switch (paymentMethod) { case "bankAccount": { - this.group.controls.bankAccount.markAllAsTouched(); - if (!this.group.controls.bankAccount.valid) { + this.group().controls.bankAccount.markAllAsTouched(); + if (!this.group().controls.bankAccount.valid) { throw new Error("Attempted to tokenize invalid bank account information."); } - const bankAccount = this.group.controls.bankAccount.getRawValue(); + const bankAccount = this.group().controls.bankAccount.getRawValue(); const clientSecret = await this.stripeService.createSetupIntent("bankAccount"); - const billingDetails = this.group.controls.billingAddress.enabled - ? this.group.controls.billingAddress.getRawValue() + const billingDetails = this.group().controls.billingAddress.enabled + ? this.group().controls.billingAddress.getRawValue() : undefined; return await this.stripeService.setupBankAccountPaymentMethod( clientSecret, @@ -332,10 +355,14 @@ export class EnterPaymentMethodComponent implements OnInit { } case "card": { const clientSecret = await this.stripeService.createSetupIntent("card"); - const billingDetails = this.group.controls.billingAddress.enabled - ? this.group.controls.billingAddress.getRawValue() + const billingDetails = this.group().controls.billingAddress.enabled + ? this.group().controls.billingAddress.getRawValue() : undefined; - return this.stripeService.setupCardPaymentMethod(clientSecret, billingDetails); + return this.stripeService.setupCardPaymentMethod( + this.instanceId, + clientSecret, + billingDetails, + ); } case "payPal": { return this.braintreeService.requestPaymentMethod(); @@ -351,27 +378,51 @@ export class EnterPaymentMethodComponent implements OnInit { const token = await exchange(this.selected); return { type: this.selected, token }; } catch (error: unknown) { - this.logService.error(error); - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("problemSubmittingPaymentMethod"), - }); - throw error; + if (error) { + this.logService.error(error); + switch (this.selected) { + case "card": { + if ( + typeof error === "object" && + "message" in error && + typeof error.message === "string" + ) { + this.toastService.showToast({ + variant: "error", + title: "", + message: error.message, + }); + } + return null; + } + case "payPal": { + if (typeof error === "string" && error === "No payment method is available.") { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("clickPayWithPayPal"), + }); + return null; + } + } + } + throw error; + } + return null; } }; validate = (): boolean => { if (this.selected === "bankAccount") { - this.group.controls.bankAccount.markAllAsTouched(); - return this.group.controls.bankAccount.valid; + this.group().controls.bankAccount.markAllAsTouched(); + return this.group().controls.bankAccount.valid; } return true; }; get selected(): PaymentMethodOption { - return this.group.value.type!; + return this.group().value.type!; } static getFormGroup = (): PaymentMethodFormGroup => diff --git a/apps/web/src/app/billing/payment/components/index.ts b/apps/web/src/app/billing/payment/components/index.ts index 7e500d2119e..5e10fa4763b 100644 --- a/apps/web/src/app/billing/payment/components/index.ts +++ b/apps/web/src/app/billing/payment/components/index.ts @@ -6,6 +6,7 @@ export * from "./display-payment-method.component"; export * from "./edit-billing-address-dialog.component"; export * from "./enter-billing-address.component"; export * from "./enter-payment-method.component"; +export * from "./payment-label.component"; export * from "./require-payment-method-dialog.component"; export * from "./submit-payment-method-dialog.component"; export * from "./verify-bank-account.component"; diff --git a/apps/web/src/app/billing/payment/components/payment-label.component.ts b/apps/web/src/app/billing/payment/components/payment-label.component.ts new file mode 100644 index 00000000000..cfd19ef791d --- /dev/null +++ b/apps/web/src/app/billing/payment/components/payment-label.component.ts @@ -0,0 +1,46 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { booleanAttribute, Component, Input } from "@angular/core"; + +import { FormFieldModule } from "@bitwarden/components"; + +import { SharedModule } from "../../../shared"; + +/** + * Label that should be used for elements loaded via Stripe API. + * + * Applies the same label styles from CL form-field 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-payment-label", + template: ` + + + + +
      + + + ({{ "required" | i18n }}) + +
      + `, + imports: [FormFieldModule, SharedModule], +}) +export class PaymentLabelComponent { + /** `id` of the associated input */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input({ required: true }) for: string; + /** Displays required text on the label */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input({ transform: booleanAttribute }) required = false; + + constructor() {} +} diff --git a/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts index b1ca1922775..81775c83b58 100644 --- a/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts @@ -29,11 +29,13 @@ type DialogParams = { }; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: ` - + {{ "addPaymentMethod" | i18n }}
      diff --git a/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts index 62d2b775eb5..98e8ba99e5e 100644 --- a/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts @@ -14,8 +14,12 @@ export type SubmitPaymentMethodDialogResult = | { type: "error" } | { type: "success"; paymentMethod: MaskedPaymentMethod }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "" }) export abstract class SubmitPaymentMethodDialogComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(EnterPaymentMethodComponent) private enterPaymentMethodComponent!: EnterPaymentMethodComponent; protected formGroup = EnterPaymentMethodComponent.getFormGroup(); @@ -37,6 +41,10 @@ export abstract class SubmitPaymentMethodDialogComponent { } const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + if (!paymentMethod) { + return; + } + const billingAddress = this.formGroup.value.type !== "payPal" ? this.formGroup.controls.billingAddress.getRawValue() diff --git a/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts b/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts index b1a2814daf2..5e61cf5b129 100644 --- a/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts +++ b/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts @@ -9,6 +9,8 @@ import { SharedModule } from "../../../shared"; import { BitwardenSubscriber } from "../../types"; import { MaskedPaymentMethod } from "../types"; +// 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-verify-bank-account", template: ` @@ -35,7 +37,11 @@ import { MaskedPaymentMethod } from "../types"; providers: [SubscriberBillingClient], }) export class VerifyBankAccountComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) subscriber!: BitwardenSubscriber; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() verified = new EventEmitter(); protected formGroup = new FormGroup({ diff --git a/apps/web/src/app/billing/payment/types/masked-payment-method.ts b/apps/web/src/app/billing/payment/types/masked-payment-method.ts index f57170518a1..9f194667cb8 100644 --- a/apps/web/src/app/billing/payment/types/masked-payment-method.ts +++ b/apps/web/src/app/billing/payment/types/masked-payment-method.ts @@ -21,6 +21,24 @@ export const StripeCardBrands = { export type StripeCardBrand = (typeof StripeCardBrands)[keyof typeof StripeCardBrands]; +export const cardBrandIcons: Record = { + amex: "card-amex", + diners: "card-diners-club", + discover: "card-discover", + jcb: "card-jcb", + mastercard: "card-mastercard", + unionpay: "card-unionpay", + visa: "card-visa", +}; + +export const getCardBrandIcon = (paymentMethod: MaskedPaymentMethod | null): string | null => { + if (paymentMethod?.type !== "card") { + return null; + } + + return paymentMethod.brand in cardBrandIcons ? cardBrandIcons[paymentMethod.brand] : null; +}; + type MaskedBankAccount = { type: BankAccountPaymentMethod; bankName: string; diff --git a/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts b/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts index def240f534b..d2cbfcf5101 100644 --- a/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts +++ b/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts @@ -1,22 +1,48 @@ +import { PaymentMethodType } from "@bitwarden/common/billing/enums"; + export const TokenizablePaymentMethods = { bankAccount: "bankAccount", card: "card", payPal: "payPal", } as const; +export const NonTokenizablePaymentMethods = { + accountCredit: "accountCredit", +} as const; + export type BankAccountPaymentMethod = typeof TokenizablePaymentMethods.bankAccount; export type CardPaymentMethod = typeof TokenizablePaymentMethods.card; export type PayPalPaymentMethod = typeof TokenizablePaymentMethods.payPal; +export type AccountCreditPaymentMethod = typeof NonTokenizablePaymentMethods.accountCredit; export type TokenizablePaymentMethod = (typeof TokenizablePaymentMethods)[keyof typeof TokenizablePaymentMethods]; +export type NonTokenizablePaymentMethod = + (typeof NonTokenizablePaymentMethods)[keyof typeof NonTokenizablePaymentMethods]; export const isTokenizablePaymentMethod = (value: string): value is TokenizablePaymentMethod => { const valid = Object.values(TokenizablePaymentMethods) as readonly string[]; return valid.includes(value); }; +export const tokenizablePaymentMethodToLegacyEnum = ( + paymentMethod: TokenizablePaymentMethod, +): PaymentMethodType => { + switch (paymentMethod) { + case "bankAccount": + return PaymentMethodType.BankAccount; + case "card": + return PaymentMethodType.Card; + case "payPal": + return PaymentMethodType.PayPal; + } +}; + export type TokenizedPaymentMethod = { type: TokenizablePaymentMethod; token: string; }; + +export type NonTokenizedPaymentMethod = { + type: NonTokenizablePaymentMethod; +}; diff --git a/apps/web/src/app/billing/services/free-families-policy.service.spec.ts b/apps/web/src/app/billing/services/free-families-policy.service.spec.ts index 10ccc448986..5b39a5a848a 100644 --- a/apps/web/src/app/billing/services/free-families-policy.service.spec.ts +++ b/apps/web/src/app/billing/services/free-families-policy.service.spec.ts @@ -38,7 +38,6 @@ describe("FreeFamiliesPolicyService", () => { describe("showSponsoredFamiliesDropdown$", () => { it("should return true when all conditions are met", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization that meets all criteria @@ -58,7 +57,6 @@ describe("FreeFamiliesPolicyService", () => { it("should return false when organization is not Enterprise", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization that is not Enterprise tier @@ -74,27 +72,8 @@ describe("FreeFamiliesPolicyService", () => { expect(result).toBe(false); }); - it("should return false when feature flag is disabled", async () => { - // Configure mocks to disable feature flag - configService.getFeatureFlag$.mockReturnValue(of(false)); - policyService.policiesByType$.mockReturnValue(of([])); - - // Create a test organization that meets other criteria - const organization = { - id: "org-id", - productTierType: ProductTierType.Enterprise, - useAdminSponsoredFamilies: true, - isAdmin: true, - } as Organization; - - // Test the method - const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization))); - expect(result).toBe(false); - }); - it("should return false when families feature is disabled by policy", async () => { // Configure mocks with a policy that disables the feature - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue( of([{ organizationId: "org-id", enabled: true } as Policy]), ); @@ -114,7 +93,6 @@ describe("FreeFamiliesPolicyService", () => { it("should return false when useAdminSponsoredFamilies is false", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization with useAdminSponsoredFamilies set to false @@ -132,7 +110,6 @@ describe("FreeFamiliesPolicyService", () => { it("should return true when user is an owner but not admin", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization where user is owner but not admin @@ -152,7 +129,6 @@ describe("FreeFamiliesPolicyService", () => { it("should return true when user can manage users but is not admin or owner", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization where user can manage users but is not admin or owner @@ -172,7 +148,6 @@ describe("FreeFamiliesPolicyService", () => { it("should return false when user has no admin permissions", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization where user has no admin permissions diff --git a/apps/web/src/app/billing/services/free-families-policy.service.ts b/apps/web/src/app/billing/services/free-families-policy.service.ts index 7a8e3804b2c..68e333d53ba 100644 --- a/apps/web/src/app/billing/services/free-families-policy.service.ts +++ b/apps/web/src/app/billing/services/free-families-policy.service.ts @@ -8,8 +8,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; interface EnterpriseOrgStatus { isFreeFamilyPolicyEnabled: boolean; @@ -19,17 +17,10 @@ interface EnterpriseOrgStatus { @Injectable({ providedIn: "root" }) export class FreeFamiliesPolicyService { - protected enterpriseOrgStatus: EnterpriseOrgStatus = { - isFreeFamilyPolicyEnabled: false, - belongToOneEnterpriseOrgs: false, - belongToMultipleEnterpriseOrgs: false, - }; - constructor( private policyService: PolicyService, private organizationService: OrganizationService, private accountService: AccountService, - private configService: ConfigService, ) {} organizations$ = this.accountService.activeAccount$.pipe( @@ -64,20 +55,14 @@ export class FreeFamiliesPolicyService { userId, ); - return combineLatest([ - enterpriseOrganization$, - this.configService.getFeatureFlag$(FeatureFlag.PM17772_AdminInitiatedSponsorships), - organization, - policies$, - ]).pipe( - map(([isEnterprise, featureFlagEnabled, org, policies]) => { + return combineLatest([enterpriseOrganization$, organization, policies$]).pipe( + map(([isEnterprise, org, policies]) => { const familiesFeatureDisabled = policies.some( (policy) => policy.organizationId === org.id && policy.enabled, ); return ( isEnterprise && - featureFlagEnabled && !familiesFeatureDisabled && org.useAdminSponsoredFamilies && (org.isAdmin || org.isOwner || org.canManageUsers) @@ -104,9 +89,11 @@ export class FreeFamiliesPolicyService { if (!orgStatus) { return false; } - const { belongToOneEnterpriseOrgs, isFreeFamilyPolicyEnabled } = orgStatus; + const { isFreeFamilyPolicyEnabled } = orgStatus; const hasSponsorshipOrgs = organizations.some((org) => org.canManageSponsorships); - return hasSponsorshipOrgs && !(belongToOneEnterpriseOrgs && isFreeFamilyPolicyEnabled); + + // Hide if ANY organization has the policy enabled + return hasSponsorshipOrgs && !isFreeFamilyPolicyEnabled; } checkEnterpriseOrganizationsAndFetchPolicy(): Observable { @@ -122,16 +109,12 @@ export class FreeFamiliesPolicyService { const { belongToOneEnterpriseOrgs, belongToMultipleEnterpriseOrgs } = this.evaluateEnterpriseOrganizations(organizations); - if (!belongToOneEnterpriseOrgs) { - return of({ - isFreeFamilyPolicyEnabled: false, - belongToOneEnterpriseOrgs, - belongToMultipleEnterpriseOrgs, - }); - } + // Get all enterprise organization IDs + const enterpriseOrgIds = organizations + .filter((org) => org.canManageSponsorships) + .map((org) => org.id); - const organizationId = this.getOrganizationIdForOneEnterprise(organizations); - if (!organizationId) { + if (enterpriseOrgIds.length === 0) { return of({ isFreeFamilyPolicyEnabled: false, belongToOneEnterpriseOrgs, @@ -145,8 +128,8 @@ export class FreeFamiliesPolicyService { this.policyService.policiesByType$(PolicyType.FreeFamiliesSponsorshipPolicy, userId), ), map((policies) => ({ - isFreeFamilyPolicyEnabled: policies.some( - (policy) => policy.organizationId === organizationId && policy.enabled, + isFreeFamilyPolicyEnabled: enterpriseOrgIds.every((orgId) => + policies.some((policy) => policy.organizationId === orgId && policy.enabled), ), belongToOneEnterpriseOrgs, belongToMultipleEnterpriseOrgs, @@ -166,9 +149,4 @@ export class FreeFamiliesPolicyService { belongToMultipleEnterpriseOrgs: count > 1, }; } - - private getOrganizationIdForOneEnterprise(organizations: any[]): string | null { - const enterpriseOrganizations = organizations.filter((org) => org.canManageSponsorships); - return enterpriseOrganizations.length === 1 ? enterpriseOrganizations[0].id : null; - } } diff --git a/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.spec.ts b/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.spec.ts new file mode 100644 index 00000000000..086c7504040 --- /dev/null +++ b/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.spec.ts @@ -0,0 +1,147 @@ +import { firstValueFrom } from "rxjs"; + +import { + FakeAccountService, + FakeStateProvider, + mockAccountServiceWith, +} from "@bitwarden/common/spec"; +import { newGuid } from "@bitwarden/guid"; +import { UserId } from "@bitwarden/user-core"; + +import { + PREMIUM_INTEREST_KEY, + WebPremiumInterestStateService, +} from "./web-premium-interest-state.service"; + +describe("WebPremiumInterestStateService", () => { + let service: WebPremiumInterestStateService; + let stateProvider: FakeStateProvider; + let accountService: FakeAccountService; + + const mockUserId = newGuid() as UserId; + const mockUserEmail = "user@example.com"; + + beforeEach(() => { + accountService = mockAccountServiceWith(mockUserId, { email: mockUserEmail }); + stateProvider = new FakeStateProvider(accountService); + service = new WebPremiumInterestStateService(stateProvider); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("getPremiumInterest", () => { + it("should throw an error when userId is not provided", async () => { + const promise = service.getPremiumInterest(null); + + await expect(promise).rejects.toThrow("UserId is required. Cannot get 'premiumInterest'."); + }); + + it("should return null when no value is set", async () => { + const result = await service.getPremiumInterest(mockUserId); + + expect(result).toBeNull(); + }); + + it("should return true when value is set to true", async () => { + await stateProvider.setUserState(PREMIUM_INTEREST_KEY, true, mockUserId); + + const result = await service.getPremiumInterest(mockUserId); + + expect(result).toBe(true); + }); + + it("should return false when value is set to false", async () => { + await stateProvider.setUserState(PREMIUM_INTEREST_KEY, false, mockUserId); + + const result = await service.getPremiumInterest(mockUserId); + + expect(result).toBe(false); + }); + + it("should use getUserState$ to retrieve the value", async () => { + const getUserStateSpy = jest.spyOn(stateProvider, "getUserState$"); + await stateProvider.setUserState(PREMIUM_INTEREST_KEY, true, mockUserId); + + await service.getPremiumInterest(mockUserId); + + expect(getUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, mockUserId); + }); + }); + + describe("setPremiumInterest", () => { + it("should throw an error when userId is not provided", async () => { + const promise = service.setPremiumInterest(null, true); + + await expect(promise).rejects.toThrow("UserId is required. Cannot set 'premiumInterest'."); + }); + + it("should set the value to true", async () => { + await service.setPremiumInterest(mockUserId, true); + + const result = await firstValueFrom( + stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId), + ); + + expect(result).toBe(true); + }); + + it("should set the value to false", async () => { + await service.setPremiumInterest(mockUserId, false); + + const result = await firstValueFrom( + stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId), + ); + + expect(result).toBe(false); + }); + + it("should update an existing value", async () => { + await service.setPremiumInterest(mockUserId, true); + await service.setPremiumInterest(mockUserId, false); + + const result = await firstValueFrom( + stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId), + ); + + expect(result).toBe(false); + }); + + it("should use setUserState to store the value", async () => { + const setUserStateSpy = jest.spyOn(stateProvider, "setUserState"); + + await service.setPremiumInterest(mockUserId, true); + + expect(setUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, true, mockUserId); + }); + }); + + describe("clearPremiumInterest", () => { + it("should throw an error when userId is not provided", async () => { + const promise = service.clearPremiumInterest(null); + + await expect(promise).rejects.toThrow("UserId is required. Cannot clear 'premiumInterest'."); + }); + + it("should clear the value by setting it to null", async () => { + await service.setPremiumInterest(mockUserId, true); + await service.clearPremiumInterest(mockUserId); + + const result = await firstValueFrom( + stateProvider.getUserState$(PREMIUM_INTEREST_KEY, mockUserId), + ); + + expect(result).toBeNull(); + }); + + it("should use setUserState with null to clear the value", async () => { + const setUserStateSpy = jest.spyOn(stateProvider, "setUserState"); + await service.setPremiumInterest(mockUserId, true); + + await service.clearPremiumInterest(mockUserId); + + expect(setUserStateSpy).toHaveBeenCalledWith(PREMIUM_INTEREST_KEY, null, mockUserId); + }); + }); +}); diff --git a/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.ts b/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.ts new file mode 100644 index 00000000000..f66fba559f4 --- /dev/null +++ b/apps/web/src/app/billing/services/premium-interest/web-premium-interest-state.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction"; +import { BILLING_MEMORY, StateProvider, UserKeyDefinition } from "@bitwarden/state"; +import { UserId } from "@bitwarden/user-core"; + +export const PREMIUM_INTEREST_KEY = new UserKeyDefinition( + BILLING_MEMORY, + "premiumInterest", + { + deserializer: (value: boolean) => value, + clearOn: ["lock", "logout"], + }, +); + +@Injectable() +export class WebPremiumInterestStateService implements PremiumInterestStateService { + constructor(private stateProvider: StateProvider) {} + + async getPremiumInterest(userId: UserId): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot get 'premiumInterest'."); + } + + return await firstValueFrom(this.stateProvider.getUserState$(PREMIUM_INTEREST_KEY, userId)); + } + + async setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot set 'premiumInterest'."); + } + + await this.stateProvider.setUserState(PREMIUM_INTEREST_KEY, premiumInterest, userId); + } + + async clearPremiumInterest(userId: UserId): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot clear 'premiumInterest'."); + } + + await this.stateProvider.setUserState(PREMIUM_INTEREST_KEY, null, userId); + } +} diff --git a/apps/web/src/app/billing/services/pricing-summary.service.ts b/apps/web/src/app/billing/services/pricing-summary.service.ts index 0b048b379d8..b3c071a8b88 100644 --- a/apps/web/src/app/billing/services/pricing-summary.service.ts +++ b/apps/web/src/app/billing/services/pricing-summary.service.ts @@ -1,10 +1,7 @@ import { Injectable } from "@angular/core"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; import { PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums"; -import { TaxInformation } from "@bitwarden/common/billing/models/domain/tax-information"; -import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; @@ -14,17 +11,13 @@ import { PricingSummaryData } from "../shared/pricing-summary/pricing-summary.co providedIn: "root", }) export class PricingSummaryService { - private estimatedTax: number = 0; - - constructor(private taxService: TaxServiceAbstraction) {} - async getPricingSummaryData( plan: PlanResponse, sub: OrganizationSubscriptionResponse, organization: Organization, selectedInterval: PlanInterval, - taxInformation: TaxInformation, isSecretsManagerTrial: boolean, + estimatedTax: number, ): Promise { // Calculation helpers const passwordManagerSeatTotal = @@ -57,6 +50,9 @@ export class PricingSummaryService { if (plan.PasswordManager?.hasPremiumAccessOption) { passwordManagerSubtotal += plan.PasswordManager.premiumAccessOptionPrice; } + if (plan.PasswordManager?.hasAdditionalStorageOption) { + passwordManagerSubtotal += additionalStorageTotal; + } const secretsManagerSubtotal = plan.SecretsManager ? (plan.SecretsManager.basePrice || 0) + @@ -72,14 +68,9 @@ export class PricingSummaryService { const acceptingSponsorship = false; const storageGb = sub?.maxStorageGb ? sub?.maxStorageGb - 1 : 0; - this.estimatedTax = await this.getEstimatedTax(organization, plan, sub, taxInformation); - const total = organization?.useSecretsManager - ? passwordManagerSubtotal + - additionalStorageTotal + - secretsManagerSubtotal + - this.estimatedTax - : passwordManagerSubtotal + additionalStorageTotal + this.estimatedTax; + ? passwordManagerSubtotal + secretsManagerSubtotal + estimatedTax + : passwordManagerSubtotal + estimatedTax; return { selectedPlanInterval: selectedInterval === PlanInterval.Annually ? "year" : "month", @@ -104,45 +95,10 @@ export class PricingSummaryService { additionalServiceAccount, storageGb, isSecretsManagerTrial, - estimatedTax: this.estimatedTax, + estimatedTax, }; } - async getEstimatedTax( - organization: Organization, - currentPlan: PlanResponse, - sub: OrganizationSubscriptionResponse, - taxInformation: TaxInformation, - ) { - if (!taxInformation || !taxInformation.country || !taxInformation.postalCode) { - return 0; - } - - const request: PreviewOrganizationInvoiceRequest = { - organizationId: organization.id, - passwordManager: { - additionalStorage: 0, - plan: currentPlan?.type, - seats: sub.seats, - }, - taxInformation: { - postalCode: taxInformation.postalCode, - country: taxInformation.country, - taxId: taxInformation.taxId, - }, - }; - - if (organization.useSecretsManager) { - request.secretsManager = { - seats: sub.smSeats ?? 0, - additionalMachineAccounts: - (sub.smServiceAccounts ?? 0) - (sub.plan.SecretsManager?.baseServiceAccount ?? 0), - }; - } - const invoiceResponse = await this.taxService.previewOrganizationInvoice(request); - return invoiceResponse.taxAmount; - } - getAdditionalServiceAccount(plan: PlanResponse, sub: OrganizationSubscriptionResponse): number { if (!plan || !plan.SecretsManager) { return 0; diff --git a/apps/web/src/app/billing/services/stripe.service.spec.ts b/apps/web/src/app/billing/services/stripe.service.spec.ts new file mode 100644 index 00000000000..983aeb266ae --- /dev/null +++ b/apps/web/src/app/billing/services/stripe.service.spec.ts @@ -0,0 +1,797 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BankAccount } from "@bitwarden/common/billing/models/domain"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +import { StripeService } from "./stripe.service"; + +// Extend Window interface to include Stripe +declare global { + interface Window { + Stripe: any; + } +} + +describe("StripeService", () => { + let service: StripeService; + let apiService: MockProxy; + let logService: MockProxy; + + // Stripe SDK mocks + let mockStripeInstance: any; + let mockElements: any; + let mockCardNumber: any; + let mockCardExpiry: any; + let mockCardCvc: any; + + // DOM mocks + let mockScript: HTMLScriptElement; + let mockIframe: HTMLIFrameElement; + + beforeEach(() => { + jest.useFakeTimers(); + + // Setup service dependency mocks + apiService = mock(); + logService = mock(); + + // Setup Stripe element mocks + mockCardNumber = { + mount: jest.fn(), + unmount: jest.fn(), + }; + mockCardExpiry = { + mount: jest.fn(), + unmount: jest.fn(), + }; + mockCardCvc = { + mount: jest.fn(), + unmount: jest.fn(), + }; + + // Setup Stripe Elements mock + mockElements = { + create: jest.fn((type: string) => { + switch (type) { + case "cardNumber": + return mockCardNumber; + case "cardExpiry": + return mockCardExpiry; + case "cardCvc": + return mockCardCvc; + default: + return null; + } + }), + getElement: jest.fn((type: string) => { + switch (type) { + case "cardNumber": + return mockCardNumber; + case "cardExpiry": + return mockCardExpiry; + case "cardCvc": + return mockCardCvc; + default: + return null; + } + }), + }; + + // Setup Stripe instance mock + mockStripeInstance = { + elements: jest.fn(() => mockElements), + confirmCardSetup: jest.fn(), + confirmUsBankAccountSetup: jest.fn(), + }; + + // Setup window.Stripe mock + window.Stripe = jest.fn(() => mockStripeInstance); + + // Setup DOM mocks + mockScript = { + id: "", + src: "", + onload: null, + onerror: null, + } as any; + + mockIframe = { + src: "https://js.stripe.com/v3/", + remove: jest.fn(), + } as any; + + jest.spyOn(window.document, "createElement").mockReturnValue(mockScript); + jest.spyOn(window.document, "getElementById").mockReturnValue(null); + jest.spyOn(window.document.head, "appendChild").mockReturnValue(mockScript); + jest.spyOn(window.document.head, "removeChild").mockImplementation(() => mockScript); + jest.spyOn(window.document, "querySelectorAll").mockReturnValue([mockIframe] as any); + + // Mock getComputedStyle + jest.spyOn(window, "getComputedStyle").mockReturnValue({ + getPropertyValue: (prop: string) => { + const props: Record = { + "--color-text-main": "0, 0, 0", + "--color-text-muted": "128, 128, 128", + "--color-danger-600": "220, 38, 38", + }; + return props[prop] || ""; + }, + } as any); + + // Create service instance + service = new StripeService(apiService, logService); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + // Helper function to trigger script load + const triggerScriptLoad = () => { + if (mockScript.onload) { + mockScript.onload(new Event("load")); + } + }; + + // Helper function to advance timers and flush promises + const advanceTimersAndFlush = async (ms: number) => { + jest.advanceTimersByTime(ms); + await Promise.resolve(); + }; + + describe("createSetupIntent", () => { + it("should call API with correct path for card payment", async () => { + apiService.send.mockResolvedValue("client_secret_card_123"); + + const result = await service.createSetupIntent("card"); + + expect(apiService.send).toHaveBeenCalledWith("POST", "/setup-intent/card", null, true, true); + expect(result).toBe("client_secret_card_123"); + }); + + it("should call API with correct path for bank account payment", async () => { + apiService.send.mockResolvedValue("client_secret_bank_456"); + + const result = await service.createSetupIntent("bankAccount"); + + expect(apiService.send).toHaveBeenCalledWith( + "POST", + "/setup-intent/bank-account", + null, + true, + true, + ); + expect(result).toBe("client_secret_bank_456"); + }); + + it("should return client secret from API response", async () => { + const expectedSecret = "seti_1234567890_secret_abcdefg"; + apiService.send.mockResolvedValue(expectedSecret); + + const result = await service.createSetupIntent("card"); + + expect(result).toBe(expectedSecret); + }); + + it("should propagate API errors", async () => { + const error = new Error("API error"); + apiService.send.mockRejectedValue(error); + + await expect(service.createSetupIntent("card")).rejects.toThrow("API error"); + }); + }); + + describe("loadStripe - initial load", () => { + const instanceId = "test-instance-1"; + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + it("should create script element with correct attributes", () => { + service.loadStripe(instanceId, elementIds, false); + + expect(window.document.createElement).toHaveBeenCalledWith("script"); + expect(mockScript.id).toBe("stripe-script"); + expect(mockScript.src).toBe("https://js.stripe.com/v3?advancedFraudSignals=false"); + }); + + it("should append script to document head", () => { + service.loadStripe(instanceId, elementIds, false); + + expect(window.document.head.appendChild).toHaveBeenCalledWith(mockScript); + }); + + it("should initialize Stripe client on script load", async () => { + service.loadStripe(instanceId, elementIds, false); + + triggerScriptLoad(); + await advanceTimersAndFlush(0); + + expect(window.Stripe).toHaveBeenCalledWith(process.env.STRIPE_KEY); + }); + + it("should create Elements instance and store in Map", async () => { + service.loadStripe(instanceId, elementIds, false); + + triggerScriptLoad(); + await advanceTimersAndFlush(50); + + expect(mockStripeInstance.elements).toHaveBeenCalled(); + expect(service["instances"].size).toBe(1); + expect(service["instances"].get(instanceId)).toBeDefined(); + }); + + it("should increment instanceCount", async () => { + service.loadStripe(instanceId, elementIds, false); + + triggerScriptLoad(); + await advanceTimersAndFlush(50); + + expect(service["instanceCount"]).toBe(1); + }); + }); + + describe("loadStripe - already loaded", () => { + const instanceId1 = "instance-1"; + const instanceId2 = "instance-2"; + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + beforeEach(async () => { + // Load first instance to initialize Stripe + service.loadStripe(instanceId1, elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + }); + + it("should not create new script if already loaded", () => { + jest.clearAllMocks(); + + service.loadStripe(instanceId2, elementIds, false); + + expect(window.document.createElement).not.toHaveBeenCalled(); + expect(window.document.head.appendChild).not.toHaveBeenCalled(); + }); + + it("should immediately initialize instance when script loaded", async () => { + service.loadStripe(instanceId2, elementIds, false); + await advanceTimersAndFlush(50); + + expect(service["instances"].size).toBe(2); + expect(service["instances"].get(instanceId2)).toBeDefined(); + }); + + it("should increment instanceCount correctly", async () => { + expect(service["instanceCount"]).toBe(1); + + service.loadStripe(instanceId2, elementIds, false); + await advanceTimersAndFlush(50); + + expect(service["instanceCount"]).toBe(2); + }); + }); + + describe("loadStripe - concurrent calls", () => { + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + it("should handle multiple loadStripe calls sequentially", async () => { + // Test practical scenario: load instances one after another + service.loadStripe("instance-1", elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + + service.loadStripe("instance-2", elementIds, false); + await advanceTimersAndFlush(100); + + service.loadStripe("instance-3", elementIds, false); + await advanceTimersAndFlush(100); + + // All instances should be initialized + expect(service["instances"].size).toBe(3); + expect(service["instanceCount"]).toBe(3); + expect(service["instances"].get("instance-1")).toBeDefined(); + expect(service["instances"].get("instance-2")).toBeDefined(); + expect(service["instances"].get("instance-3")).toBeDefined(); + }); + + it("should share Stripe client across instances", async () => { + // Load first instance + service.loadStripe("instance-1", elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + + const stripeClientAfterFirst = service["stripe"]; + expect(stripeClientAfterFirst).toBeDefined(); + + // Load second instance + service.loadStripe("instance-2", elementIds, false); + await advanceTimersAndFlush(100); + + // Should reuse the same Stripe client + expect(service["stripe"]).toBe(stripeClientAfterFirst); + expect(service["instances"].size).toBe(2); + }); + }); + + describe("mountElements - success path", () => { + const instanceId = "mount-test-instance"; + const elementIds = { + cardNumber: "#card-number-mount", + cardExpiry: "#card-expiry-mount", + cardCvc: "#card-cvc-mount", + }; + + beforeEach(async () => { + service.loadStripe(instanceId, elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + }); + + it("should mount all three card elements to DOM", async () => { + service.mountElements(instanceId); + await advanceTimersAndFlush(100); + + expect(mockCardNumber.mount).toHaveBeenCalledWith("#card-number-mount"); + expect(mockCardExpiry.mount).toHaveBeenCalledWith("#card-expiry-mount"); + expect(mockCardCvc.mount).toHaveBeenCalledWith("#card-cvc-mount"); + }); + + it("should use correct element IDs from instance", async () => { + const customIds = { + cardNumber: "#custom-card", + cardExpiry: "#custom-expiry", + cardCvc: "#custom-cvc", + }; + + service.loadStripe("custom-instance", customIds, false); + await advanceTimersAndFlush(100); + + service.mountElements("custom-instance"); + await advanceTimersAndFlush(100); + + expect(mockCardNumber.mount).toHaveBeenCalledWith("#custom-card"); + expect(mockCardExpiry.mount).toHaveBeenCalledWith("#custom-expiry"); + expect(mockCardCvc.mount).toHaveBeenCalledWith("#custom-cvc"); + }); + + it("should handle autoMount flag correctly", async () => { + const autoMountId = "auto-mount-instance"; + jest.clearAllMocks(); + + service.loadStripe(autoMountId, elementIds, true); + triggerScriptLoad(); + await advanceTimersAndFlush(150); + + // Should auto-mount without explicit call + expect(mockCardNumber.mount).toHaveBeenCalled(); + expect(mockCardExpiry.mount).toHaveBeenCalled(); + expect(mockCardCvc.mount).toHaveBeenCalled(); + }); + }); + + describe("mountElements - retry logic", () => { + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + it("should retry if instance not found", async () => { + service.mountElements("non-existent-instance"); + await advanceTimersAndFlush(100); + + expect(logService.warning).toHaveBeenCalledWith( + expect.stringContaining("Stripe instance non-existent-instance not found"), + ); + }); + + it("should log error after 10 failed attempts", async () => { + service.mountElements("non-existent-instance"); + + for (let i = 0; i < 10; i++) { + await advanceTimersAndFlush(100); + } + + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("not found after 10 attempts"), + ); + }); + + it("should retry if elements not ready", async () => { + const instanceId = "retry-elements-instance"; + service.loadStripe(instanceId, elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + + // Make elements temporarily unavailable + mockElements.getElement.mockReturnValueOnce(null); + mockElements.getElement.mockReturnValueOnce(null); + mockElements.getElement.mockReturnValueOnce(null); + + service.mountElements(instanceId); + await advanceTimersAndFlush(100); + + expect(logService.warning).toHaveBeenCalledWith( + expect.stringContaining("Some Stripe card elements"), + ); + }); + }); + + describe("setupCardPaymentMethod", () => { + const instanceId = "card-setup-instance"; + const clientSecret = "seti_card_secret_123"; + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + beforeEach(async () => { + service.loadStripe(instanceId, elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + }); + + it("should call Stripe confirmCardSetup with correct parameters", async () => { + mockStripeInstance.confirmCardSetup.mockResolvedValue({ + setupIntent: { status: "succeeded", payment_method: "pm_card_123" }, + }); + + await service.setupCardPaymentMethod(instanceId, clientSecret); + + expect(mockStripeInstance.confirmCardSetup).toHaveBeenCalledWith(clientSecret, { + payment_method: { + card: mockCardNumber, + }, + }); + }); + + it("should include billing details when provided", async () => { + mockStripeInstance.confirmCardSetup.mockResolvedValue({ + setupIntent: { status: "succeeded", payment_method: "pm_card_123" }, + }); + + const billingDetails = { country: "US", postalCode: "12345" }; + await service.setupCardPaymentMethod(instanceId, clientSecret, billingDetails); + + expect(mockStripeInstance.confirmCardSetup).toHaveBeenCalledWith(clientSecret, { + payment_method: { + card: mockCardNumber, + billing_details: { + address: { + country: "US", + postal_code: "12345", + }, + }, + }, + }); + }); + + it("should throw error if instance not found", async () => { + await expect(service.setupCardPaymentMethod("non-existent", clientSecret)).rejects.toThrow( + "Payment method initialization failed. Please try again.", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Stripe instance non-existent not found"), + ); + }); + + it("should throw error if setup fails", async () => { + const error = { message: "Card declined" }; + mockStripeInstance.confirmCardSetup.mockResolvedValue({ error }); + + await expect(service.setupCardPaymentMethod(instanceId, clientSecret)).rejects.toEqual(error); + expect(logService.error).toHaveBeenCalledWith(error); + }); + + it("should throw error if status is not succeeded", async () => { + const error = { message: "Invalid status" }; + mockStripeInstance.confirmCardSetup.mockResolvedValue({ + setupIntent: { status: "requires_action" }, + error, + }); + + await expect(service.setupCardPaymentMethod(instanceId, clientSecret)).rejects.toEqual(error); + }); + + it("should return payment method ID on success", async () => { + mockStripeInstance.confirmCardSetup.mockResolvedValue({ + setupIntent: { status: "succeeded", payment_method: "pm_card_success_123" }, + }); + + const result = await service.setupCardPaymentMethod(instanceId, clientSecret); + + expect(result).toBe("pm_card_success_123"); + }); + }); + + describe("setupBankAccountPaymentMethod", () => { + const clientSecret = "seti_bank_secret_456"; + const bankAccount: BankAccount = { + accountHolderName: "John Doe", + routingNumber: "110000000", + accountNumber: "000123456789", + accountHolderType: "individual", + }; + + beforeEach(async () => { + // Initialize Stripe instance for bank account tests + service.loadStripe( + "bank-test-instance", + { + cardNumber: "#card", + cardExpiry: "#expiry", + cardCvc: "#cvc", + }, + false, + ); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + }); + + it("should call Stripe confirmUsBankAccountSetup with bank details", async () => { + mockStripeInstance.confirmUsBankAccountSetup.mockResolvedValue({ + setupIntent: { status: "requires_action", payment_method: "pm_bank_123" }, + }); + + await service.setupBankAccountPaymentMethod(clientSecret, bankAccount); + + expect(mockStripeInstance.confirmUsBankAccountSetup).toHaveBeenCalledWith(clientSecret, { + payment_method: { + us_bank_account: { + routing_number: "110000000", + account_number: "000123456789", + account_holder_type: "individual", + }, + billing_details: { + name: "John Doe", + }, + }, + }); + }); + + it("should include billing address when provided", async () => { + mockStripeInstance.confirmUsBankAccountSetup.mockResolvedValue({ + setupIntent: { status: "requires_action", payment_method: "pm_bank_123" }, + }); + + const billingDetails = { country: "US", postalCode: "90210" }; + await service.setupBankAccountPaymentMethod(clientSecret, bankAccount, billingDetails); + + expect(mockStripeInstance.confirmUsBankAccountSetup).toHaveBeenCalledWith(clientSecret, { + payment_method: { + us_bank_account: { + routing_number: "110000000", + account_number: "000123456789", + account_holder_type: "individual", + }, + billing_details: { + name: "John Doe", + address: { + country: "US", + postal_code: "90210", + }, + }, + }, + }); + }); + + it("should omit billing address when not provided", async () => { + mockStripeInstance.confirmUsBankAccountSetup.mockResolvedValue({ + setupIntent: { status: "requires_action", payment_method: "pm_bank_123" }, + }); + + await service.setupBankAccountPaymentMethod(clientSecret, bankAccount); + + const call = mockStripeInstance.confirmUsBankAccountSetup.mock.calls[0][1]; + expect(call.payment_method.billing_details.address).toBeUndefined(); + }); + + it("should validate status is requires_action", async () => { + const error = { message: "Invalid status" }; + mockStripeInstance.confirmUsBankAccountSetup.mockResolvedValue({ + setupIntent: { status: "succeeded" }, + error, + }); + + await expect( + service.setupBankAccountPaymentMethod(clientSecret, bankAccount), + ).rejects.toEqual(error); + }); + + it("should return payment method ID on success", async () => { + mockStripeInstance.confirmUsBankAccountSetup.mockResolvedValue({ + setupIntent: { status: "requires_action", payment_method: "pm_bank_success_456" }, + }); + + const result = await service.setupBankAccountPaymentMethod(clientSecret, bankAccount); + + expect(result).toBe("pm_bank_success_456"); + }); + }); + + describe("unloadStripe - single instance", () => { + const instanceId = "unload-test-instance"; + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + beforeEach(async () => { + service.loadStripe(instanceId, elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + }); + + it("should unmount all card elements", () => { + service.unloadStripe(instanceId); + + expect(mockCardNumber.unmount).toHaveBeenCalled(); + expect(mockCardExpiry.unmount).toHaveBeenCalled(); + expect(mockCardCvc.unmount).toHaveBeenCalled(); + }); + + it("should remove instance from Map", () => { + expect(service["instances"].has(instanceId)).toBe(true); + + service.unloadStripe(instanceId); + + expect(service["instances"].has(instanceId)).toBe(false); + }); + + it("should decrement instanceCount", () => { + expect(service["instanceCount"]).toBe(1); + + service.unloadStripe(instanceId); + + expect(service["instanceCount"]).toBe(0); + }); + + it("should remove script when last instance unloaded", () => { + jest.spyOn(window.document, "getElementById").mockReturnValue(mockScript); + + service.unloadStripe(instanceId); + + expect(window.document.head.removeChild).toHaveBeenCalledWith(mockScript); + }); + + it("should remove Stripe iframes after cleanup delay", async () => { + service.unloadStripe(instanceId); + + await advanceTimersAndFlush(500); + + expect(window.document.querySelectorAll).toHaveBeenCalledWith("iframe"); + expect(mockIframe.remove).toHaveBeenCalled(); + }); + }); + + describe("unloadStripe - multiple instances", () => { + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + beforeEach(async () => { + // Load first instance + service.loadStripe("instance-1", elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + + // Load second instance (script already loaded) + service.loadStripe("instance-2", elementIds, false); + await advanceTimersAndFlush(100); + }); + + it("should not remove script when other instances exist", () => { + expect(service["instanceCount"]).toBe(2); + + service.unloadStripe("instance-1"); + + expect(service["instanceCount"]).toBe(1); + expect(window.document.head.removeChild).not.toHaveBeenCalled(); + }); + + it("should only cleanup specific instance", () => { + service.unloadStripe("instance-1"); + + expect(service["instances"].has("instance-1")).toBe(false); + expect(service["instances"].has("instance-2")).toBe(true); + }); + + it("should handle reference counting correctly", () => { + expect(service["instanceCount"]).toBe(2); + + service.unloadStripe("instance-1"); + expect(service["instanceCount"]).toBe(1); + + service.unloadStripe("instance-2"); + expect(service["instanceCount"]).toBe(0); + }); + }); + + describe("unloadStripe - edge cases", () => { + it("should handle unload of non-existent instance gracefully", () => { + expect(() => service.unloadStripe("non-existent")).not.toThrow(); + expect(service["instanceCount"]).toBe(0); + }); + + it("should handle duplicate unload calls", async () => { + const instanceId = "duplicate-unload"; + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + service.loadStripe(instanceId, elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + + service.unloadStripe(instanceId); + expect(service["instanceCount"]).toBe(0); + + service.unloadStripe(instanceId); + expect(service["instanceCount"]).toBe(0); // Should not go negative + }); + + it("should catch and log element unmount errors", async () => { + const instanceId = "error-unmount"; + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + service.loadStripe(instanceId, elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + + const unmountError = new Error("Unmount failed"); + mockCardNumber.unmount.mockImplementation(() => { + throw unmountError; + }); + + service.unloadStripe(instanceId); + + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Error unmounting Stripe elements"), + unmountError, + ); + }); + }); + + describe("element styling", () => { + it("should apply correct CSS custom properties", () => { + const options = service["getElementOptions"]("cardNumber"); + + expect(options.style.base.color).toBe("rgb(0, 0, 0)"); + expect(options.style.base["::placeholder"].color).toBe("rgb(128, 128, 128)"); + expect(options.style.invalid.color).toBe("rgb(0, 0, 0)"); + expect(options.style.invalid.borderColor).toBe("rgb(220, 38, 38)"); + }); + + it("should remove placeholder for cardNumber and cardCvc", () => { + const cardNumberOptions = service["getElementOptions"]("cardNumber"); + const cardCvcOptions = service["getElementOptions"]("cardCvc"); + const cardExpiryOptions = service["getElementOptions"]("cardExpiry"); + + expect(cardNumberOptions.placeholder).toBe(""); + expect(cardCvcOptions.placeholder).toBe(""); + expect(cardExpiryOptions.placeholder).toBeUndefined(); + }); + }); +}); diff --git a/apps/web/src/app/billing/services/stripe.service.ts b/apps/web/src/app/billing/services/stripe.service.ts index 7ea0d7d52c8..9aabab9beb0 100644 --- a/apps/web/src/app/billing/services/stripe.service.ts +++ b/apps/web/src/app/billing/services/stripe.service.ts @@ -8,8 +8,6 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { BankAccountPaymentMethod, CardPaymentMethod } from "../payment/types"; -import { BillingServicesModule } from "./billing-services.module"; - type SetupBankAccountRequest = { payment_method: { us_bank_account: { @@ -39,15 +37,21 @@ type SetupCardRequest = { }; }; -@Injectable({ providedIn: BillingServicesModule }) +@Injectable({ providedIn: "root" }) export class StripeService { - private stripe: any; - private elements: any; - private elementIds: { - cardNumber: string; - cardExpiry: string; - cardCvc: string; - }; + // Shared/Global - One Stripe client for entire application + private stripe: any = null; + private stripeScriptLoaded = false; + private instanceCount = 0; + + // Per-Instance - Isolated Elements for each component + private instances = new Map< + string, + { + elements: any; + elementIds: { cardNumber: string; cardExpiry: string; cardCvc: string }; + } + >(); constructor( private apiService: ApiService, @@ -76,53 +80,121 @@ export class StripeService { * Loads [Stripe JS]{@link https://docs.stripe.com/js} in the element of the current page and mounts * Stripe credit card [elements]{@link https://docs.stripe.com/js/elements_object/create} into the HTML elements with the provided element IDS. * We do this to avoid having to load the Stripe JS SDK on every page of the Web Vault given many pages contain sensitive information. + * @param instanceId - Unique identifier for this component instance. * @param elementIds - The ID attributes of the HTML elements used to load the Stripe JS credit card elements. * @param autoMount - A flag indicating whether you want to immediately mount the Stripe credit card elements. */ loadStripe( + instanceId: string, elementIds: { cardNumber: string; cardExpiry: string; cardCvc: string }, autoMount: boolean, ) { - this.elementIds = elementIds; - const script = window.document.createElement("script"); - script.id = "stripe-script"; - script.src = "https://js.stripe.com/v3?advancedFraudSignals=false"; - script.onload = async () => { - const window$ = window as any; - this.stripe = window$.Stripe(process.env.STRIPE_KEY); - this.elements = this.stripe.elements(); - setTimeout(() => { - this.elements.create("cardNumber", this.getElementOptions("cardNumber")); - this.elements.create("cardExpiry", this.getElementOptions("cardExpiry")); - this.elements.create("cardCvc", this.getElementOptions("cardCvc")); - if (autoMount) { - this.mountElements(); - } - }, 50); - }; + // Check if script is already loaded + if (this.stripeScriptLoaded) { + // Script already loaded, initialize this instance immediately + this.initializeInstance(instanceId, elementIds, autoMount); + } else if (!window.document.getElementById("stripe-script")) { + // Script not loaded and not loading, start loading it + const script = window.document.createElement("script"); + script.id = "stripe-script"; + script.src = "https://js.stripe.com/v3?advancedFraudSignals=false"; + script.onload = async () => { + const window$ = window as any; + this.stripe = window$.Stripe(process.env.STRIPE_KEY); + this.stripeScriptLoaded = true; // Mark as loaded after script loads - window.document.head.appendChild(script); + // Initialize this instance after script loads + this.initializeInstance(instanceId, elementIds, autoMount); + }; + window.document.head.appendChild(script); + } else { + // Script is currently loading, wait for it + this.initializeInstance(instanceId, elementIds, autoMount); + } } - mountElements(attempt: number = 1) { - setTimeout(() => { - if (!this.elements) { - this.logService.warning(`Stripe elements are missing, retrying for attempt ${attempt}...`); - this.mountElements(attempt + 1); + private initializeInstance( + instanceId: string, + elementIds: { cardNumber: string; cardExpiry: string; cardCvc: string }, + autoMount: boolean, + attempt: number = 1, + ) { + // Wait for stripe to be available if script just loaded + if (!this.stripe) { + if (attempt < 10) { + this.logService.warning( + `Stripe not yet loaded for instance ${instanceId}, retrying attempt ${attempt}...`, + ); + setTimeout( + () => this.initializeInstance(instanceId, elementIds, autoMount, attempt + 1), + 50, + ); } else { - const cardNumber = this.elements.getElement("cardNumber"); - const cardExpiry = this.elements.getElement("cardExpiry"); - const cardCVC = this.elements.getElement("cardCvc"); + this.logService.error( + `Stripe failed to load for instance ${instanceId} after ${attempt} attempts`, + ); + } + return; + } + + // Create a new Elements instance for this component + const elements = this.stripe.elements(); + + // Store instance data + this.instances.set(instanceId, { elements, elementIds }); + + // Increment instance count now that instance is successfully initialized + this.instanceCount++; + + // Create the card elements + setTimeout(() => { + elements.create("cardNumber", this.getElementOptions("cardNumber")); + elements.create("cardExpiry", this.getElementOptions("cardExpiry")); + elements.create("cardCvc", this.getElementOptions("cardCvc")); + + if (autoMount) { + this.mountElements(instanceId); + } + }, 50); + } + + mountElements(instanceId: string, attempt: number = 1) { + setTimeout(() => { + const instance = this.instances.get(instanceId); + + if (!instance) { + if (attempt < 10) { + this.logService.warning( + `Stripe instance ${instanceId} not found, retrying for attempt ${attempt}...`, + ); + this.mountElements(instanceId, attempt + 1); + } else { + this.logService.error( + `Stripe instance ${instanceId} not found after ${attempt} attempts`, + ); + } + return; + } + + if (!instance.elements) { + this.logService.warning( + `Stripe elements for instance ${instanceId} are missing, retrying for attempt ${attempt}...`, + ); + this.mountElements(instanceId, attempt + 1); + } else { + const cardNumber = instance.elements.getElement("cardNumber"); + const cardExpiry = instance.elements.getElement("cardExpiry"); + const cardCVC = instance.elements.getElement("cardCvc"); if ([cardNumber, cardExpiry, cardCVC].some((element) => !element)) { this.logService.warning( - `Some Stripe card elements are missing, retrying for attempt ${attempt}...`, + `Some Stripe card elements for instance ${instanceId} are missing, retrying for attempt ${attempt}...`, ); - this.mountElements(attempt + 1); + this.mountElements(instanceId, attempt + 1); } else { - cardNumber.mount(this.elementIds.cardNumber); - cardExpiry.mount(this.elementIds.cardExpiry); - cardCVC.mount(this.elementIds.cardCvc); + cardNumber.mount(instance.elementIds.cardNumber); + cardExpiry.mount(instance.elementIds.cardExpiry); + cardCVC.mount(instance.elementIds.cardCvc); } } }, 100); @@ -132,6 +204,9 @@ export class StripeService { * Creates a Stripe [SetupIntent]{@link https://docs.stripe.com/api/setup_intents} and uses the resulting client secret * to invoke the Stripe JS [confirmUsBankAccountSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_us_bank_account_setup} method, * thereby creating and storing a Stripe [PaymentMethod]{@link https://docs.stripe.com/api/payment_methods}. + * @param clientSecret - The client secret from the SetupIntent. + * @param bankAccount - The bank account details. + * @param billingDetails - Optional billing details. * @returns The ID of the newly created PaymentMethod. */ async setupBankAccountPaymentMethod( @@ -171,13 +246,28 @@ export class StripeService { * Creates a Stripe [SetupIntent]{@link https://docs.stripe.com/api/setup_intents} and uses the resulting client secret * to invoke the Stripe JS [confirmCardSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_card_setup} method, * thereby creating and storing a Stripe [PaymentMethod]{@link https://docs.stripe.com/api/payment_methods}. + * @param instanceId - Unique identifier for the component instance. + * @param clientSecret - The client secret from the SetupIntent. + * @param billingDetails - Optional billing details. * @returns The ID of the newly created PaymentMethod. */ async setupCardPaymentMethod( + instanceId: string, clientSecret: string, billingDetails?: { country: string; postalCode: string }, ): Promise { - const cardNumber = this.elements.getElement("cardNumber"); + const instance = this.instances.get(instanceId); + if (!instance) { + const availableInstances = Array.from(this.instances.keys()); + this.logService.error( + `Stripe instance ${instanceId} not found. ` + + `Available instances: [${availableInstances.join(", ")}]. ` + + `This may occur if the component was destroyed during the payment flow.`, + ); + throw new Error("Payment method initialization failed. Please try again."); + } + + const cardNumber = instance.elements.getElement("cardNumber"); const request: SetupCardRequest = { payment_method: { card: cardNumber, @@ -200,24 +290,77 @@ export class StripeService { } /** - * Removes {@link https://docs.stripe.com/js} from the element of the current page as well as all - * Stripe-managed

      {{ credential.name }}{{ credential.name }} diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.ts index 94e926ac138..b2bc8e6c322 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.ts +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, HostBinding, OnDestroy, OnInit } from "@angular/core"; import { Subject, switchMap, takeUntil } from "rxjs"; @@ -17,6 +15,8 @@ import { openCreateCredentialDialog } from "./create-credential-dialog/create-cr import { openDeleteCredentialDialogComponent } from "./delete-credential-dialog/delete-credential-dialog.component"; import { openEnableCredentialDialogComponent } from "./enable-encryption-dialog/enable-encryption-dialog.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-webauthn-login-settings", templateUrl: "webauthn-login-settings.component.html", @@ -34,6 +34,8 @@ export class WebauthnLoginSettingsComponent implements OnInit, OnDestroy { protected credentials?: WebauthnLoginCredentialView[]; protected loading = true; + protected requireSsoPolicyEnabled = false; + constructor( private webauthnService: WebauthnLoginAdminService, private dialogService: DialogService, @@ -41,25 +43,6 @@ export class WebauthnLoginSettingsComponent implements OnInit, OnDestroy { private accountService: AccountService, ) {} - @HostBinding("attr.aria-busy") - get ariaBusy() { - return this.loading ? "true" : "false"; - } - - get hasCredentials() { - return this.credentials && this.credentials.length > 0; - } - - get hasData() { - return this.credentials !== undefined; - } - - get limitReached() { - return this.credentials?.length >= this.MaxCredentialCount; - } - - requireSsoPolicyEnabled = false; - ngOnInit(): void { this.accountService.activeAccount$ .pipe( @@ -88,6 +71,23 @@ export class WebauthnLoginSettingsComponent implements OnInit, OnDestroy { this.destroy$.complete(); } + @HostBinding("attr.aria-busy") + get ariaBusy() { + return this.loading ? "true" : "false"; + } + + get hasCredentials() { + return (this.credentials?.length ?? 0) > 0; + } + + get hasData() { + return this.credentials !== undefined; + } + + get limitReached() { + return (this.credentials?.length ?? 0) >= this.MaxCredentialCount; + } + protected createCredential() { openCreateCredentialDialog(this.dialogService, {}); } diff --git a/apps/web/src/app/auth/shared/components/user-verification/user-verification-prompt.component.ts b/apps/web/src/app/auth/shared/components/user-verification/user-verification-prompt.component.ts index 77df374f3ed..beafa48bb8e 100644 --- a/apps/web/src/app/auth/shared/components/user-verification/user-verification-prompt.component.ts +++ b/apps/web/src/app/auth/shared/components/user-verification/user-verification-prompt.component.ts @@ -21,6 +21,8 @@ import { /** * @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent instead. */ +// 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: "user-verification-prompt.component.html", standalone: false, diff --git a/apps/web/src/app/auth/shared/components/user-verification/user-verification.component.ts b/apps/web/src/app/auth/shared/components/user-verification/user-verification.component.ts index 42f4b26fb36..7ea5014254b 100644 --- a/apps/web/src/app/auth/shared/components/user-verification/user-verification.component.ts +++ b/apps/web/src/app/auth/shared/components/user-verification/user-verification.component.ts @@ -8,6 +8,8 @@ import { UserVerificationComponent as BaseComponent } from "@bitwarden/angular/a * @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent or UserVerificationFormInputComponent instead. * Each client specific component should eventually be converted over to use one of these new components. */ +// 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-user-verification", templateUrl: "user-verification.component.html", diff --git a/apps/web/src/app/auth/verify-email-token.component.ts b/apps/web/src/app/auth/verify-email-token.component.ts index 2c4fa7f447c..30bfcf95bbf 100644 --- a/apps/web/src/app/auth/verify-email-token.component.ts +++ b/apps/web/src/app/auth/verify-email-token.component.ts @@ -13,6 +13,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ToastService } from "@bitwarden/components"; +// 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-verify-email-token", templateUrl: "verify-email-token.component.html", diff --git a/apps/web/src/app/auth/verify-recover-delete.component.ts b/apps/web/src/app/auth/verify-recover-delete.component.ts index a475fdfd3e5..06d6096c3de 100644 --- a/apps/web/src/app/auth/verify-recover-delete.component.ts +++ b/apps/web/src/app/auth/verify-recover-delete.component.ts @@ -11,6 +11,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ToastService } from "@bitwarden/components"; +// 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-verify-recover-delete", templateUrl: "verify-recover-delete.component.html", diff --git a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.html b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.html deleted file mode 100644 index 0c1a4270662..00000000000 --- a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.html +++ /dev/null @@ -1,104 +0,0 @@ - - - {{ "loading" | i18n }} - - -
      -
      -

      {{ "billingPlanLabel" | i18n }}

      -
      - -
      -
      - -
      -
      -
      -

      {{ "paymentType" | i18n }}

      - - - - @if (trialLength === 0) { - @let priceLabel = - subscriptionProduct === SubscriptionProduct.PasswordManager - ? "passwordManagerPlanPrice" - : "secretsManagerPlanPrice"; - -
      -
      - {{ priceLabel | i18n }}: {{ getPriceFor(formGroup.value.cadence) | currency: "USD $" }} -
      - {{ "estimatedTax" | i18n }}: - @if (fetchingTaxAmount) { - - } @else { - {{ taxAmount | currency: "USD $" }} - } -
      -
      -
      -

      - {{ "total" | i18n }}: - @if (fetchingTaxAmount) { - - } @else { - {{ total | currency: "USD $" }}/{{ interval | i18n }} - } -

      -
      - } -
      -
      - - -
      -
      - - - - - {{ "loading" | i18n }} - diff --git a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts deleted file mode 100644 index 431f8882505..00000000000 --- a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts +++ /dev/null @@ -1,360 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { - Component, - EventEmitter, - Input, - OnDestroy, - OnInit, - Output, - ViewChild, -} from "@angular/core"; -import { FormBuilder, Validators } from "@angular/forms"; -import { firstValueFrom, from, Subject, switchMap, takeUntil } from "rxjs"; - -import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { - BillingInformation, - OrganizationBillingServiceAbstraction as OrganizationBillingService, - OrganizationInformation, - PaymentInformation, - PlanInformation, -} from "@bitwarden/common/billing/abstractions/organization-billing.service"; -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { - PaymentMethodType, - PlanType, - ProductTierType, - ProductType, -} from "@bitwarden/common/billing/enums"; -import { PreviewTaxAmountForOrganizationTrialRequest } from "@bitwarden/common/billing/models/request/tax"; -import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { ToastService } from "@bitwarden/components"; - -import { BillingSharedModule } from "../../shared"; -import { PaymentComponent } from "../../shared/payment/payment.component"; - -export type TrialOrganizationType = Exclude; - -export interface OrganizationInfo { - name: string; - email: string; - type: TrialOrganizationType | null; -} - -export interface OrganizationCreatedEvent { - organizationId: string; - planDescription: string; -} - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -enum SubscriptionCadence { - Annual, - Monthly, -} - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum SubscriptionProduct { - PasswordManager, - SecretsManager, -} - -@Component({ - selector: "app-trial-billing-step", - templateUrl: "trial-billing-step.component.html", - imports: [BillingSharedModule], -}) -export class TrialBillingStepComponent implements OnInit, OnDestroy { - @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; - @ViewChild(ManageTaxInformationComponent) taxInfoComponent: ManageTaxInformationComponent; - @Input() organizationInfo: OrganizationInfo; - @Input() subscriptionProduct: SubscriptionProduct = SubscriptionProduct.PasswordManager; - @Input() trialLength: number; - @Output() steppedBack = new EventEmitter(); - @Output() organizationCreated = new EventEmitter(); - - loading = true; - fetchingTaxAmount = false; - - annualCadence = SubscriptionCadence.Annual; - monthlyCadence = SubscriptionCadence.Monthly; - - formGroup = this.formBuilder.group({ - cadence: [SubscriptionCadence.Annual, Validators.required], - }); - formPromise: Promise; - - applicablePlans: PlanResponse[]; - annualPlan?: PlanResponse; - monthlyPlan?: PlanResponse; - - taxAmount = 0; - - private destroy$ = new Subject(); - - protected readonly SubscriptionProduct = SubscriptionProduct; - - constructor( - private apiService: ApiService, - private i18nService: I18nService, - private formBuilder: FormBuilder, - private messagingService: MessagingService, - private organizationBillingService: OrganizationBillingService, - private toastService: ToastService, - private taxService: TaxServiceAbstraction, - private accountService: AccountService, - ) {} - - async ngOnInit(): Promise { - const plans = await this.apiService.getPlans(); - this.applicablePlans = plans.data.filter(this.isApplicable); - this.annualPlan = this.findPlanFor(SubscriptionCadence.Annual); - this.monthlyPlan = this.findPlanFor(SubscriptionCadence.Monthly); - - if (this.trialLength === 0) { - this.formGroup.controls.cadence.valueChanges - .pipe( - switchMap((cadence) => from(this.previewTaxAmount(cadence))), - takeUntil(this.destroy$), - ) - .subscribe((taxAmount) => { - this.taxAmount = taxAmount; - }); - } - - this.loading = false; - } - - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } - - async submit(): Promise { - if (!this.taxInfoComponent.validate()) { - return; - } - - this.formPromise = this.createOrganization(); - - const organizationId = await this.formPromise; - const planDescription = this.getPlanDescription(); - - this.toastService.showToast({ - variant: "success", - title: this.i18nService.t("organizationCreated"), - message: this.i18nService.t("organizationReadyToGo"), - }); - - this.organizationCreated.emit({ - organizationId, - planDescription, - }); - - // TODO: No one actually listening to this? - this.messagingService.send("organizationCreated", { organizationId }); - } - - async onTaxInformationChanged() { - if (this.trialLength === 0) { - this.taxAmount = await this.previewTaxAmount(this.formGroup.value.cadence); - } - - this.paymentComponent.showBankAccount = - this.taxInfoComponent.getTaxInformation().country === "US"; - if ( - !this.paymentComponent.showBankAccount && - this.paymentComponent.selected === PaymentMethodType.BankAccount - ) { - this.paymentComponent.select(PaymentMethodType.Card); - } - } - - protected getPriceFor(cadence: SubscriptionCadence): number { - const plan = this.findPlanFor(cadence); - return this.subscriptionProduct === SubscriptionProduct.PasswordManager - ? plan.PasswordManager.basePrice === 0 - ? plan.PasswordManager.seatPrice - : plan.PasswordManager.basePrice - : plan.SecretsManager.basePrice === 0 - ? plan.SecretsManager.seatPrice - : plan.SecretsManager.basePrice; - } - - protected stepBack() { - this.steppedBack.emit(); - } - - private async createOrganization(): Promise { - const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - const planResponse = this.findPlanFor(this.formGroup.value.cadence); - - const { type, token } = await this.paymentComponent.tokenize(); - const paymentMethod: [string, PaymentMethodType] = [token, type]; - - const organization: OrganizationInformation = { - name: this.organizationInfo.name, - billingEmail: this.organizationInfo.email, - initiationPath: - this.subscriptionProduct === SubscriptionProduct.PasswordManager - ? "Password Manager trial from marketing website" - : "Secrets Manager trial from marketing website", - }; - - const plan: PlanInformation = { - type: planResponse.type, - passwordManagerSeats: 1, - }; - - if (this.subscriptionProduct === SubscriptionProduct.SecretsManager) { - plan.subscribeToSecretsManager = true; - plan.isFromSecretsManagerTrial = true; - plan.secretsManagerSeats = 1; - } - - const payment: PaymentInformation = { - paymentMethod, - billing: this.getBillingInformationFromTaxInfoComponent(), - skipTrial: this.trialLength === 0, - }; - - const response = await this.organizationBillingService.purchaseSubscription( - { - organization, - plan, - payment, - }, - activeUserId, - ); - - return response.id; - } - - private productTypeToPlanTypeMap: { - [productType in TrialOrganizationType]: { - [cadence in SubscriptionCadence]?: PlanType; - }; - } = { - [ProductTierType.Enterprise]: { - [SubscriptionCadence.Annual]: PlanType.EnterpriseAnnually, - [SubscriptionCadence.Monthly]: PlanType.EnterpriseMonthly, - }, - [ProductTierType.Families]: { - [SubscriptionCadence.Annual]: PlanType.FamiliesAnnually, - // No monthly option for Families plan - }, - [ProductTierType.Teams]: { - [SubscriptionCadence.Annual]: PlanType.TeamsAnnually, - [SubscriptionCadence.Monthly]: PlanType.TeamsMonthly, - }, - [ProductTierType.TeamsStarter]: { - // No annual option for Teams Starter plan - [SubscriptionCadence.Monthly]: PlanType.TeamsStarter, - }, - }; - - private findPlanFor(cadence: SubscriptionCadence): PlanResponse | null { - const productType = this.organizationInfo.type; - const planType = this.productTypeToPlanTypeMap[productType]?.[cadence]; - return planType ? this.applicablePlans.find((plan) => plan.type === planType) : null; - } - - protected get showTaxIdField(): boolean { - switch (this.organizationInfo.type) { - case ProductTierType.Families: - return false; - default: - return true; - } - } - - private getBillingInformationFromTaxInfoComponent(): BillingInformation { - return { - postalCode: this.taxInfoComponent.getTaxInformation()?.postalCode, - country: this.taxInfoComponent.getTaxInformation()?.country, - taxId: this.taxInfoComponent.getTaxInformation()?.taxId, - addressLine1: this.taxInfoComponent.getTaxInformation()?.line1, - addressLine2: this.taxInfoComponent.getTaxInformation()?.line2, - city: this.taxInfoComponent.getTaxInformation()?.city, - state: this.taxInfoComponent.getTaxInformation()?.state, - }; - } - - private getPlanDescription(): string { - const plan = this.findPlanFor(this.formGroup.value.cadence); - const price = - this.subscriptionProduct === SubscriptionProduct.PasswordManager - ? plan.PasswordManager.basePrice === 0 - ? plan.PasswordManager.seatPrice - : plan.PasswordManager.basePrice - : plan.SecretsManager.basePrice === 0 - ? plan.SecretsManager.seatPrice - : plan.SecretsManager.basePrice; - - switch (this.formGroup.value.cadence) { - case SubscriptionCadence.Annual: - return `${this.i18nService.t("annual")} ($${price}/${this.i18nService.t("yr")})`; - case SubscriptionCadence.Monthly: - return `${this.i18nService.t("monthly")} ($${price}/${this.i18nService.t("monthAbbr")})`; - } - } - - private isApplicable(plan: PlanResponse): boolean { - const hasCorrectProductType = - plan.productTier === ProductTierType.Enterprise || - plan.productTier === ProductTierType.Families || - plan.productTier === ProductTierType.Teams || - plan.productTier === ProductTierType.TeamsStarter; - const notDisabledOrLegacy = !plan.disabled && !plan.legacyYear; - return hasCorrectProductType && notDisabledOrLegacy; - } - - private previewTaxAmount = async (cadence: SubscriptionCadence): Promise => { - this.fetchingTaxAmount = true; - - if (!this.taxInfoComponent.validate()) { - this.fetchingTaxAmount = false; - return 0; - } - - const plan = this.findPlanFor(cadence); - - const productType = - this.subscriptionProduct === SubscriptionProduct.PasswordManager - ? ProductType.PasswordManager - : ProductType.SecretsManager; - - const taxInformation = this.taxInfoComponent.getTaxInformation(); - - const request: PreviewTaxAmountForOrganizationTrialRequest = { - planType: plan.type, - productType, - taxInformation: { - ...taxInformation, - }, - }; - - const response = await this.taxService.previewTaxAmountForOrganizationTrial(request); - this.fetchingTaxAmount = false; - return response; - }; - - get price() { - return this.getPriceFor(this.formGroup.value.cadence); - } - - get total() { - return this.price + this.taxAmount; - } - - get interval() { - return this.formGroup.value.cadence === SubscriptionCadence.Annual ? "year" : "month"; - } -} diff --git a/apps/web/src/app/billing/clients/account-billing.client.ts b/apps/web/src/app/billing/clients/account-billing.client.ts new file mode 100644 index 00000000000..256a06b3ead --- /dev/null +++ b/apps/web/src/app/billing/clients/account-billing.client.ts @@ -0,0 +1,34 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; + +import { + BillingAddress, + NonTokenizedPaymentMethod, + TokenizedPaymentMethod, +} from "../payment/types"; + +@Injectable() +export class AccountBillingClient { + private endpoint = "/account/billing/vnext"; + private apiService: ApiService; + + constructor(apiService: ApiService) { + this.apiService = apiService; + } + + purchasePremiumSubscription = async ( + paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod, + billingAddress: Pick, + ): Promise => { + const path = `${this.endpoint}/subscription`; + + // Determine the request payload based on the payment method type + const isTokenizedPayment = "token" in paymentMethod; + + const request = isTokenizedPayment + ? { tokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress } + : { nonTokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress }; + await this.apiService.send("POST", path, request, true, true); + }; +} diff --git a/apps/web/src/app/billing/clients/index.ts b/apps/web/src/app/billing/clients/index.ts index ff962abcbf3..0251693a3b2 100644 --- a/apps/web/src/app/billing/clients/index.ts +++ b/apps/web/src/app/billing/clients/index.ts @@ -1,2 +1,4 @@ export * from "./organization-billing.client"; export * from "./subscriber-billing.client"; +export * from "./tax.client"; +export * from "./account-billing.client"; diff --git a/apps/web/src/app/billing/clients/subscriber-billing.client.ts b/apps/web/src/app/billing/clients/subscriber-billing.client.ts index 18ca215ef0c..107a8ccc728 100644 --- a/apps/web/src/app/billing/clients/subscriber-billing.client.ts +++ b/apps/web/src/app/billing/clients/subscriber-billing.client.ts @@ -82,6 +82,24 @@ export class SubscriberBillingClient { return data ? new MaskedPaymentMethodResponse(data).value : null; }; + restartSubscription = async ( + subscriber: BitwardenSubscriber, + paymentMethod: TokenizedPaymentMethod, + billingAddress: BillingAddress, + ): Promise => { + const path = `${this.getEndpoint(subscriber)}/subscription/restart`; + await this.apiService.send( + "POST", + path, + { + paymentMethod, + billingAddress, + }, + true, + false, + ); + }; + updateBillingAddress = async ( subscriber: BitwardenSubscriber, billingAddress: BillingAddress, diff --git a/apps/web/src/app/billing/clients/tax.client.ts b/apps/web/src/app/billing/clients/tax.client.ts new file mode 100644 index 00000000000..09debd5a210 --- /dev/null +++ b/apps/web/src/app/billing/clients/tax.client.ts @@ -0,0 +1,131 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types"; + +class TaxAmountResponse extends BaseResponse implements TaxAmounts { + tax: number; + total: number; + + constructor(response: any) { + super(response); + + this.tax = this.getResponseProperty("Tax"); + this.total = this.getResponseProperty("Total"); + } +} + +export type OrganizationSubscriptionPlan = { + tier: "families" | "teams" | "enterprise"; + cadence: "annually" | "monthly"; +}; + +export type OrganizationSubscriptionPurchase = OrganizationSubscriptionPlan & { + passwordManager: { + seats: number; + additionalStorage: number; + sponsored: boolean; + }; + secretsManager?: { + seats: number; + additionalServiceAccounts: number; + standalone: boolean; + }; +}; + +export type OrganizationSubscriptionUpdate = { + passwordManager?: { + seats?: number; + additionalStorage?: number; + }; + secretsManager?: { + seats?: number; + additionalServiceAccounts?: number; + }; +}; + +export interface TaxAmounts { + tax: number; + total: number; +} + +@Injectable() +export class TaxClient { + constructor(private apiService: ApiService) {} + + previewTaxForOrganizationSubscriptionPurchase = async ( + purchase: OrganizationSubscriptionPurchase, + billingAddress: BillingAddress, + ): Promise => { + const json = await this.apiService.send( + "POST", + "/billing/tax/organizations/subscriptions/purchase", + { + purchase, + billingAddress, + }, + true, + true, + ); + + return new TaxAmountResponse(json); + }; + + previewTaxForOrganizationSubscriptionPlanChange = async ( + organizationId: string, + plan: { + tier: "families" | "teams" | "enterprise"; + cadence: "annually" | "monthly"; + }, + billingAddress: BillingAddress | null, + ): Promise => { + const json = await this.apiService.send( + "POST", + `/billing/tax/organizations/${organizationId}/subscription/plan-change`, + { + plan, + billingAddress, + }, + true, + true, + ); + + return new TaxAmountResponse(json); + }; + + previewTaxForOrganizationSubscriptionUpdate = async ( + organizationId: string, + update: OrganizationSubscriptionUpdate, + ): Promise => { + const json = await this.apiService.send( + "POST", + `/billing/tax/organizations/${organizationId}/subscription/update`, + { + update, + }, + true, + true, + ); + + return new TaxAmountResponse(json); + }; + + previewTaxForPremiumSubscriptionPurchase = async ( + additionalStorage: number, + billingAddress: BillingAddress, + ): Promise => { + const json = await this.apiService.send( + "POST", + `/billing/tax/premium/subscriptions/purchase`, + { + additionalStorage, + billingAddress, + }, + true, + true, + ); + + return new TaxAmountResponse(json); + }; +} diff --git a/apps/web/src/app/billing/guards/has-premium.guard.ts b/apps/web/src/app/billing/guards/has-premium.guard.ts index 61853b25cb8..f10e75d8268 100644 --- a/apps/web/src/app/billing/guards/has-premium.guard.ts +++ b/apps/web/src/app/billing/guards/has-premium.guard.ts @@ -1,21 +1,21 @@ import { inject } from "@angular/core"; import { ActivatedRouteSnapshot, - RouterStateSnapshot, - Router, CanActivateFn, + Router, + RouterStateSnapshot, UrlTree, } from "@angular/router"; -import { Observable, of } from "rxjs"; +import { from, Observable, of } from "rxjs"; import { switchMap, tap } from "rxjs/operators"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; /** - * CanActivate guard that checks if the user has premium and otherwise triggers the "premiumRequired" - * message and blocks navigation. + * CanActivate guard that checks if the user has premium and otherwise triggers the premium upgrade + * flow and blocks navigation. */ export function hasPremiumGuard(): CanActivateFn { return ( @@ -23,7 +23,7 @@ export function hasPremiumGuard(): CanActivateFn { _state: RouterStateSnapshot, ): Observable => { const router = inject(Router); - const messagingService = inject(MessagingService); + const premiumUpgradePromptService = inject(PremiumUpgradePromptService); const billingAccountProfileStateService = inject(BillingAccountProfileStateService); const accountService = inject(AccountService); @@ -33,10 +33,14 @@ export function hasPremiumGuard(): CanActivateFn { ? billingAccountProfileStateService.hasPremiumFromAnySource$(account.id) : of(false), ), - tap((userHasPremium: boolean) => { + switchMap((userHasPremium: boolean) => { + // Can't call async method inside observables so instead, wait for service then switch back to the boolean if (!userHasPremium) { - messagingService.send("premiumRequired"); + return from(premiumUpgradePromptService.promptForPremium()).pipe( + switchMap(() => of(userHasPremium)), + ); } + return of(userHasPremium); }), // Prevent trapping the user on the login page, since that's an awful UX flow tap((userHasPremium: boolean) => { diff --git a/apps/web/src/app/billing/index.ts b/apps/web/src/app/billing/index.ts index 217f1e05be9..a3047bbab6a 100644 --- a/apps/web/src/app/billing/index.ts +++ b/apps/web/src/app/billing/index.ts @@ -1,2 +1 @@ export { OrganizationPlansComponent } from "./organizations"; -export { TaxInfoComponent } from "./shared"; diff --git a/apps/web/src/app/billing/individual/billing-history-view.component.ts b/apps/web/src/app/billing/individual/billing-history-view.component.ts index d615e01d0db..607a35baa94 100644 --- a/apps/web/src/app/billing/individual/billing-history-view.component.ts +++ b/apps/web/src/app/billing/individual/billing-history-view.component.ts @@ -10,6 +10,8 @@ import { } from "@bitwarden/common/billing/models/response/billing.response"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-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({ templateUrl: "billing-history-view.component.html", standalone: false, diff --git a/apps/web/src/app/billing/individual/index.ts b/apps/web/src/app/billing/individual/index.ts deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts index 87b342ed997..cdccaaab8ab 100644 --- a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts @@ -1,12 +1,16 @@ -import { NgModule } from "@angular/core"; +import { inject, NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { map } from "rxjs"; +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 { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component"; - -import { PaymentMethodComponent } from "../shared"; +import { SelfHostedPremiumComponent } from "@bitwarden/web-vault/app/billing/individual/premium/self-hosted-premium.component"; import { BillingHistoryViewComponent } from "./billing-history-view.component"; -import { PremiumComponent } from "./premium/premium.component"; +import { CloudHostedPremiumVNextComponent } from "./premium/cloud-hosted-premium-vnext.component"; +import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component"; import { SubscriptionComponent } from "./subscription.component"; import { UserSubscriptionComponent } from "./user-subscription.component"; @@ -22,15 +26,54 @@ const routes: Routes = [ component: UserSubscriptionComponent, data: { titleId: "premiumMembership" }, }, + /** + * Three-Route Matching Strategy for /premium: + * + * Routes are evaluated in order using canMatch guards. The first route that matches will be selected. + * + * 1. Self-Hosted Environment → SelfHostedPremiumComponent + * - Matches when platformUtilsService.isSelfHost() === true + * + * 2. Cloud-Hosted + Feature Flag Enabled → CloudHostedPremiumVNextComponent + * - Only evaluated if Route 1 doesn't match (not self-hosted) + * - Matches when PM24033PremiumUpgradeNewDesign feature flag === true + * + * 3. Cloud-Hosted + Feature Flag Disabled → CloudHostedPremiumComponent (Fallback) + * - No canMatch guard, so this always matches as the fallback route + * - Used when neither Route 1 nor Route 2 match + */ + // Route 1: Self-Hosted -> SelfHostedPremiumComponent { path: "premium", - component: PremiumComponent, + component: SelfHostedPremiumComponent, data: { titleId: "goPremium" }, + canMatch: [ + () => { + const platformUtilsService = inject(PlatformUtilsService); + return platformUtilsService.isSelfHost(); + }, + ], }, + // Route 2: Cloud Hosted + FF -> CloudHostedPremiumVNextComponent { - path: "payment-method", - component: PaymentMethodComponent, - data: { titleId: "paymentMethod" }, + path: "premium", + component: CloudHostedPremiumVNextComponent, + data: { titleId: "goPremium" }, + canMatch: [ + () => { + const configService = inject(ConfigService); + + return configService + .getFeatureFlag$(FeatureFlag.PM24033PremiumUpgradeNewDesign) + .pipe(map((flagValue) => flagValue === true)); + }, + ], + }, + // Route 3: Cloud Hosted + FF Disabled -> CloudHostedPremiumComponent (Fallback) + { + path: "premium", + component: CloudHostedPremiumComponent, + data: { titleId: "goPremium" }, }, { path: "payment-details", diff --git a/apps/web/src/app/billing/individual/individual-billing.module.ts b/apps/web/src/app/billing/individual/individual-billing.module.ts index ad75da00c99..200df5d9f07 100644 --- a/apps/web/src/app/billing/individual/individual-billing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing.module.ts @@ -1,21 +1,34 @@ import { NgModule } from "@angular/core"; +import { PricingCardComponent } from "@bitwarden/pricing"; +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, +} from "@bitwarden/web-vault/app/billing/payment/components"; + import { HeaderModule } from "../../layouts/header/header.module"; import { BillingSharedModule } from "../shared"; import { BillingHistoryViewComponent } from "./billing-history-view.component"; import { IndividualBillingRoutingModule } from "./individual-billing-routing.module"; -import { PremiumComponent } from "./premium/premium.component"; +import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component"; import { SubscriptionComponent } from "./subscription.component"; import { UserSubscriptionComponent } from "./user-subscription.component"; @NgModule({ - imports: [IndividualBillingRoutingModule, BillingSharedModule, HeaderModule], + imports: [ + IndividualBillingRoutingModule, + BillingSharedModule, + HeaderModule, + EnterPaymentMethodComponent, + EnterBillingAddressComponent, + PricingCardComponent, + ], declarations: [ SubscriptionComponent, BillingHistoryViewComponent, UserSubscriptionComponent, - PremiumComponent, + CloudHostedPremiumComponent, ], }) export class IndividualBillingModule {} diff --git a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts index 9f46d9d3909..8c061894fac 100644 --- a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts +++ b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts @@ -1,22 +1,7 @@ import { Component } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { - BehaviorSubject, - EMPTY, - filter, - from, - map, - merge, - Observable, - shareReplay, - switchMap, - tap, -} from "rxjs"; -import { catchError } from "rxjs/operators"; +import { BehaviorSubject, filter, merge, Observable, shareReplay, switchMap, tap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; @@ -28,19 +13,14 @@ import { import { MaskedPaymentMethod } from "../../payment/types"; import { mapAccountToSubscriber, BitwardenSubscriber } from "../../types"; -class RedirectError { - constructor( - public path: string[], - public relativeTo: ActivatedRoute, - ) {} -} - type View = { account: BitwardenSubscriber; paymentMethod: MaskedPaymentMethod | null; credit: number | null; }; +// 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: "./account-payment-details.component.html", standalone: true, @@ -56,23 +36,11 @@ export class AccountPaymentDetailsComponent { private viewState$ = new BehaviorSubject(null); private load$: Observable = this.accountService.activeAccount$.pipe( - switchMap((account) => - this.configService - .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) - .pipe( - map((managePaymentDetailsOutsideCheckout) => { - if (!managePaymentDetailsOutsideCheckout) { - throw new RedirectError(["../payment-method"], this.activatedRoute); - } - return account; - }), - ), - ), mapAccountToSubscriber, switchMap(async (account) => { const [paymentMethod, credit] = await Promise.all([ - this.billingClient.getPaymentMethod(account), - this.billingClient.getCredit(account), + this.subscriberBillingClient.getPaymentMethod(account), + this.subscriberBillingClient.getCredit(account), ]); return { @@ -82,14 +50,6 @@ export class AccountPaymentDetailsComponent { }; }), shareReplay({ bufferSize: 1, refCount: false }), - catchError((error: unknown) => { - if (error instanceof RedirectError) { - return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( - switchMap(() => EMPTY), - ); - } - throw error; - }), ); view$: Observable = merge( @@ -99,10 +59,7 @@ export class AccountPaymentDetailsComponent { constructor( private accountService: AccountService, - private activatedRoute: ActivatedRoute, - private billingClient: SubscriberBillingClient, - private configService: ConfigService, - private router: Router, + private subscriberBillingClient: SubscriberBillingClient, ) {} setPaymentMethod = (paymentMethod: MaskedPaymentMethod) => { diff --git a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.html b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.html new file mode 100644 index 00000000000..6b168901b2e --- /dev/null +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.html @@ -0,0 +1,68 @@ +
      + +
      +
      + + {{ "bitwardenFreeplanMessage" | i18n }} + +
      + +

      + {{ "upgradeCompleteSecurity" | i18n }} +

      +

      + {{ "individualUpgradeDescriptionMessage" | i18n }} +

      +
      + + +
      + +
      + @if (premiumCardData$ | async; as premiumData) { + +

      {{ "premium" | i18n }}

      +
      + } +
      + + +
      + @if (familiesCardData$ | async; as familiesData) { + +

      {{ "families" | i18n }}

      +
      + } +
      +
      + + +
      +

      + {{ "individualUpgradeTaxInformationMessage" | i18n }} +

      + + {{ "viewbusinessplans" | i18n }} + + +
      +
      +
      diff --git a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.ts b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.ts new file mode 100644 index 00000000000..d78451e4f3a --- /dev/null +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.ts @@ -0,0 +1,242 @@ +import { CommonModule } from "@angular/common"; +import { Component, DestroyRef, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { + combineLatest, + firstValueFrom, + from, + map, + Observable, + of, + shareReplay, + switchMap, + take, +} from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { + BadgeModule, + DialogService, + LinkModule, + SectionComponent, + TypographyModule, +} from "@bitwarden/components"; +import { PricingCardComponent } from "@bitwarden/pricing"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { BitwardenSubscriber, mapAccountToSubscriber } from "../../types"; +import { + UnifiedUpgradeDialogComponent, + UnifiedUpgradeDialogParams, + UnifiedUpgradeDialogResult, + UnifiedUpgradeDialogStatus, + UnifiedUpgradeDialogStep, +} from "../upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component"; + +const RouteParams = { + callToAction: "callToAction", +} as const; +const RouteParamValues = { + upgradeToPremium: "upgradeToPremium", +} as const; + +// 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: "./cloud-hosted-premium-vnext.component.html", + standalone: true, + imports: [ + CommonModule, + SectionComponent, + BadgeModule, + TypographyModule, + LinkModule, + I18nPipe, + PricingCardComponent, + ], +}) +export class CloudHostedPremiumVNextComponent { + protected hasPremiumFromAnyOrganization$: Observable; + protected hasPremiumPersonally$: Observable; + protected shouldShowNewDesign$: Observable; + protected shouldShowUpgradeDialogOnInit$: Observable; + protected personalPricingTiers$: Observable; + protected premiumCardData$: Observable<{ + tier: PersonalSubscriptionPricingTier | undefined; + price: number; + features: string[]; + }>; + protected familiesCardData$: Observable<{ + tier: PersonalSubscriptionPricingTier | undefined; + price: number; + features: string[]; + }>; + protected subscriber!: BitwardenSubscriber; + private destroyRef = inject(DestroyRef); + + constructor( + private accountService: AccountService, + private apiService: ApiService, + private dialogService: DialogService, + private syncService: SyncService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, + private router: Router, + private activatedRoute: ActivatedRoute, + ) { + this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe( + switchMap((account) => + account + ? this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id) + : of(false), + ), + ); + + this.hasPremiumPersonally$ = this.accountService.activeAccount$.pipe( + switchMap((account) => + account + ? this.billingAccountProfileStateService.hasPremiumPersonally$(account.id) + : of(false), + ), + ); + + this.accountService.activeAccount$ + .pipe(mapAccountToSubscriber, takeUntilDestroyed(this.destroyRef)) + .subscribe((subscriber) => { + this.subscriber = subscriber; + }); + + this.shouldShowNewDesign$ = combineLatest([ + this.hasPremiumFromAnyOrganization$, + this.hasPremiumPersonally$, + ]).pipe(map(([hasOrgPremium, hasPersonalPremium]) => !hasOrgPremium && !hasPersonalPremium)); + + // redirect to user subscription page if they already have premium personally + // redirect to individual vault if they already have premium from an org + combineLatest([this.hasPremiumFromAnyOrganization$, this.hasPremiumPersonally$]) + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap(([hasPremiumFromOrg, hasPremiumPersonally]) => { + if (hasPremiumPersonally) { + return from(this.navigateToSubscriptionPage()); + } + if (hasPremiumFromOrg) { + return from(this.navigateToIndividualVault()); + } + return of(true); + }), + ) + .subscribe(); + + this.shouldShowUpgradeDialogOnInit$ = combineLatest([ + this.hasPremiumFromAnyOrganization$, + this.hasPremiumPersonally$, + this.activatedRoute.queryParams, + ]).pipe( + map(([hasOrgPremium, hasPersonalPremium, queryParams]) => { + const cta = queryParams[RouteParams.callToAction]; + return !hasOrgPremium && !hasPersonalPremium && cta === RouteParamValues.upgradeToPremium; + }), + ); + + this.personalPricingTiers$ = + this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$(); + + this.premiumCardData$ = this.personalPricingTiers$.pipe( + map((tiers) => { + const tier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Premium); + return { + tier, + price: + tier?.passwordManager.type === "standalone" + ? Number((tier.passwordManager.annualPrice / 12).toFixed(2)) + : 0, + features: tier?.passwordManager.features.map((f) => f.value) || [], + }; + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + this.familiesCardData$ = this.personalPricingTiers$.pipe( + map((tiers) => { + const tier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Families); + return { + tier, + price: + tier?.passwordManager.type === "packaged" + ? Number((tier.passwordManager.annualPrice / 12).toFixed(2)) + : 0, + features: tier?.passwordManager.features.map((f) => f.value) || [], + }; + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + this.shouldShowUpgradeDialogOnInit$ + .pipe( + take(1), + switchMap((shouldShowUpgradeDialogOnInit) => { + if (shouldShowUpgradeDialogOnInit) { + return from(this.openUpgradeDialog("Premium")); + } + // Return an Observable that completes immediately when dialog should not be shown + return of(void 0); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + } + + private navigateToSubscriptionPage = (): Promise => + this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute }); + + private navigateToIndividualVault = (): Promise => this.router.navigate(["/vault"]); + + finalizeUpgrade = async () => { + await this.apiService.refreshIdentityToken(); + await this.syncService.fullSync(true); + }; + + protected async openUpgradeDialog(planType: "Premium" | "Families"): Promise { + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + return; + } + + const selectedPlan = + planType === "Premium" + ? PersonalSubscriptionPricingTierIds.Premium + : PersonalSubscriptionPricingTierIds.Families; + + const dialogParams: UnifiedUpgradeDialogParams = { + account, + initialStep: UnifiedUpgradeDialogStep.Payment, + selectedPlan: selectedPlan, + redirectOnCompletion: true, + }; + + const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, { + data: dialogParams, + }); + + dialogRef.closed + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result: UnifiedUpgradeDialogResult | undefined) => { + if ( + result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium || + result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies + ) { + void this.finalizeUpgrade(); + } + }); + } +} diff --git a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html new file mode 100644 index 00000000000..63c26bd61f1 --- /dev/null +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html @@ -0,0 +1,138 @@ +@if (isLoadingPrices$ | async) { + + + {{ "loading" | i18n }} + +} @else { + + +

      {{ "goPremium" | i18n }}

      + + {{ "alreadyPremiumFromOrg" | i18n }} + + +

      {{ "premiumUpgradeUnlockFeatures" | i18n }}

      +
        +
      • + + {{ "premiumSignUpStorage" | i18n }} +
      • +
      • + + {{ "premiumSignUpTwoStepOptions" | i18n }} +
      • +
      • + + {{ "premiumSignUpEmergency" | i18n }} +
      • +
      • + + {{ "premiumSignUpReports" | i18n }} +
      • +
      • + + {{ "premiumSignUpTotp" | i18n }} +
      • +
      • + + {{ "premiumSignUpSupport" | i18n }} +
      • +
      • + + {{ "premiumSignUpFuture" | i18n }} +
      • +
      +

      + {{ + "premiumPriceWithFamilyPlan" + | i18n: (premiumPrice$ | async | currency: "$") : familyPlanMaxUserCount + }} + + {{ "bitwardenFamiliesPlan" | i18n }} + +

      +
      +
      +
      + +

      {{ "addons" | i18n }}

      +
      + + {{ "additionalStorageGb" | i18n }} + + {{ + "additionalStorageIntervalDesc" + | i18n: "1 GB" : (storagePrice$ | async | currency: "$") : ("year" | i18n) + }} + +
      +
      + +

      {{ "summary" | i18n }}

      + {{ "premiumMembership" | i18n }}: {{ premiumPrice$ | async | currency: "$" }}
      + {{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB × + {{ storagePrice$ | async | currency: "$" }} = + {{ storageCost$ | async | currency: "$" }} +
      +
      + +

      {{ "paymentInformation" | i18n }}

      +
      + + + + +
      +
      +
      + {{ "planPrice" | i18n }}: {{ subtotal$ | async | currency: "USD $" }} + {{ "estimatedTax" | i18n }}: {{ tax$ | async | currency: "USD $" }} +
      +
      +
      +

      + {{ "total" | i18n }}: {{ total$ | async | currency: "USD $" }}/{{ + "year" | i18n + }} +

      + +
      +
      +
      +} diff --git a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts new file mode 100644 index 00000000000..fceeeedf170 --- /dev/null +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts @@ -0,0 +1,240 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Component, ViewChild } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { ActivatedRoute, Router } from "@angular/router"; +import { + catchError, + combineLatest, + concatMap, + filter, + from, + map, + Observable, + of, + shareReplay, + startWith, + switchMap, +} from "rxjs"; +import { debounceTime } from "rxjs/operators"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { PaymentMethodType } from "@bitwarden/common/billing/enums"; +import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service"; +import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient, TaxClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, + getBillingAddressFromForm, +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { + NonTokenizablePaymentMethods, + tokenizablePaymentMethodToLegacyEnum, +} from "@bitwarden/web-vault/app/billing/payment/types"; +import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types"; + +// 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: "./cloud-hosted-premium.component.html", + standalone: false, + providers: [SubscriberBillingClient, TaxClient], +}) +export class CloudHostedPremiumComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; + + protected hasPremiumFromAnyOrganization$: Observable; + protected hasEnoughAccountCredit$: Observable; + + protected formGroup = new FormGroup({ + additionalStorage: new FormControl(0, [Validators.min(0), Validators.max(99)]), + paymentMethod: EnterPaymentMethodComponent.getFormGroup(), + billingAddress: EnterBillingAddressComponent.getFormGroup(), + }); + + premiumPrices$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$().pipe( + map((tiers) => { + const premiumPlan = tiers.find( + (tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium, + ); + + if (!premiumPlan) { + throw new Error("Could not find Premium plan"); + } + + return { + seat: premiumPlan.passwordManager.annualPrice, + storage: premiumPlan.passwordManager.annualPricePerAdditionalStorageGB, + }; + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + premiumPrice$ = this.premiumPrices$.pipe(map((prices) => prices.seat)); + + storagePrice$ = this.premiumPrices$.pipe(map((prices) => prices.storage)); + + protected isLoadingPrices$ = this.premiumPrices$.pipe( + map(() => false), + startWith(true), + catchError(() => of(false)), + ); + + storageCost$ = combineLatest([ + this.storagePrice$, + this.formGroup.controls.additionalStorage.valueChanges.pipe( + startWith(this.formGroup.value.additionalStorage), + ), + ]).pipe(map(([storagePrice, additionalStorage]) => storagePrice * additionalStorage)); + + subtotal$ = combineLatest([this.premiumPrice$, this.storageCost$]).pipe( + map(([premiumPrice, storageCost]) => premiumPrice + storageCost), + ); + + tax$ = this.formGroup.valueChanges.pipe( + filter(() => this.formGroup.valid), + debounceTime(1000), + switchMap(async () => { + const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); + const taxAmounts = await this.taxClient.previewTaxForPremiumSubscriptionPurchase( + this.formGroup.value.additionalStorage, + billingAddress, + ); + return taxAmounts.tax; + }), + startWith(0), + ); + + total$ = combineLatest([this.subtotal$, this.tax$]).pipe( + map(([subtotal, tax]) => subtotal + tax), + ); + + protected cloudWebVaultURL: string; + protected readonly familyPlanMaxUserCount = 6; + + constructor( + private activatedRoute: ActivatedRoute, + private apiService: ApiService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private environmentService: EnvironmentService, + private i18nService: I18nService, + private router: Router, + private syncService: SyncService, + private toastService: ToastService, + private accountService: AccountService, + private subscriberBillingClient: SubscriberBillingClient, + private taxClient: TaxClient, + private subscriptionPricingService: DefaultSubscriptionPricingService, + ) { + this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe( + switchMap((account) => + this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id), + ), + ); + + const accountCredit$ = this.accountService.activeAccount$.pipe( + mapAccountToSubscriber, + switchMap((account) => this.subscriberBillingClient.getCredit(account)), + ); + + this.hasEnoughAccountCredit$ = combineLatest([ + accountCredit$, + this.total$, + this.formGroup.controls.paymentMethod.controls.type.valueChanges.pipe( + startWith(this.formGroup.value.paymentMethod.type), + ), + ]).pipe( + map(([credit, total, paymentMethod]) => { + if (paymentMethod !== NonTokenizablePaymentMethods.accountCredit) { + return true; + } + return credit >= total; + }), + ); + + combineLatest([ + this.accountService.activeAccount$.pipe( + switchMap((account) => + this.billingAccountProfileStateService.hasPremiumPersonally$(account.id), + ), + ), + this.environmentService.cloudWebVaultUrl$, + ]) + .pipe( + takeUntilDestroyed(), + concatMap(([hasPremiumPersonally, cloudWebVaultURL]) => { + if (hasPremiumPersonally) { + return from(this.navigateToSubscriptionPage()); + } + + this.cloudWebVaultURL = cloudWebVaultURL; + return of(true); + }), + ) + .subscribe(); + } + + finalizeUpgrade = async () => { + await this.apiService.refreshIdentityToken(); + await this.syncService.fullSync(true); + }; + + postFinalizeUpgrade = async () => { + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("premiumUpdated"), + }); + await this.navigateToSubscriptionPage(); + }; + + navigateToSubscriptionPage = (): Promise => + this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute }); + + submitPayment = async (): Promise => { + if (this.formGroup.invalid) { + return; + } + + // Check if account credit is selected + const selectedPaymentType = this.formGroup.value.paymentMethod.type; + + let paymentMethodType: number; + let paymentToken: string; + + if (selectedPaymentType === NonTokenizablePaymentMethods.accountCredit) { + // Account credit doesn't need tokenization + paymentMethodType = PaymentMethodType.Credit; + paymentToken = ""; + } else { + // Tokenize for card, bank account, or PayPal + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + paymentMethodType = tokenizablePaymentMethodToLegacyEnum(paymentMethod.type); + paymentToken = paymentMethod.token; + } + + const formData = new FormData(); + formData.append("paymentMethodType", paymentMethodType.toString()); + formData.append("paymentToken", paymentToken); + formData.append( + "additionalStorageGb", + (this.formGroup.value.additionalStorage ?? 0).toString(), + ); + formData.append("country", this.formGroup.value.billingAddress.country); + formData.append("postalCode", this.formGroup.value.billingAddress.postalCode); + + await this.apiService.postPremium(formData); + await this.finalizeUpgrade(); + await this.postFinalizeUpgrade(); + }; +} diff --git a/apps/web/src/app/billing/individual/premium/premium.component.html b/apps/web/src/app/billing/individual/premium/premium.component.html deleted file mode 100644 index 3f0f97541df..00000000000 --- a/apps/web/src/app/billing/individual/premium/premium.component.html +++ /dev/null @@ -1,119 +0,0 @@ - -

      {{ "goPremium" | i18n }}

      - - {{ "alreadyPremiumFromOrg" | i18n }} - - -

      {{ "premiumUpgradeUnlockFeatures" | i18n }}

      -
        -
      • - - {{ "premiumSignUpStorage" | i18n }} -
      • -
      • - - {{ "premiumSignUpTwoStepOptions" | i18n }} -
      • -
      • - - {{ "premiumSignUpEmergency" | i18n }} -
      • -
      • - - {{ "premiumSignUpReports" | i18n }} -
      • -
      • - - {{ "premiumSignUpTotp" | i18n }} -
      • -
      • - - {{ "premiumSignUpSupport" | i18n }} -
      • -
      • - - {{ "premiumSignUpFuture" | i18n }} -
      • -
      -

      - {{ - "premiumPriceWithFamilyPlan" | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount - }} - - {{ "bitwardenFamiliesPlan" | i18n }} - -

      - - {{ "purchasePremium" | i18n }} - -
      -
      - - - -
      - -

      {{ "addons" | i18n }}

      -
      - - {{ "additionalStorageGb" | i18n }} - - {{ - "additionalStorageIntervalDesc" - | i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n) - }} - -
      -
      - -

      {{ "summary" | i18n }}

      - {{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }}
      - {{ "additionalStorageGb" | i18n }}: {{ addOnFormGroup.value.additionalStorage || 0 }} GB × - {{ storageGBPrice | currency: "$" }} = - {{ additionalStorageCost | currency: "$" }} -
      -
      - -

      {{ "paymentInformation" | i18n }}

      - - -
      -
      - {{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }} - {{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }} -
      -
      -
      -

      - {{ "total" | i18n }}: {{ total | currency: "USD $" }}/{{ "year" | i18n }} -

      - -
      -
      diff --git a/apps/web/src/app/billing/individual/premium/premium.component.ts b/apps/web/src/app/billing/individual/premium/premium.component.ts deleted file mode 100644 index 974c22455ff..00000000000 --- a/apps/web/src/app/billing/individual/premium/premium.component.ts +++ /dev/null @@ -1,224 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, ViewChild } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { ActivatedRoute, Router } from "@angular/router"; -import { combineLatest, concatMap, from, Observable, of, switchMap } from "rxjs"; -import { debounceTime } from "rxjs/operators"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; -import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { PreviewIndividualInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-individual-invoice.request"; -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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SyncService } from "@bitwarden/common/platform/sync"; -import { ToastService } from "@bitwarden/components"; - -import { PaymentComponent } from "../../shared/payment/payment.component"; -import { TaxInfoComponent } from "../../shared/tax-info.component"; - -@Component({ - templateUrl: "./premium.component.html", - standalone: false, -}) -export class PremiumComponent { - @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; - @ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent; - - protected hasPremiumFromAnyOrganization$: Observable; - - protected addOnFormGroup = new FormGroup({ - additionalStorage: new FormControl(0, [Validators.min(0), Validators.max(99)]), - }); - - protected licenseFormGroup = new FormGroup({ - file: new FormControl(null, [Validators.required]), - }); - - protected cloudWebVaultURL: string; - protected isSelfHost = false; - - protected estimatedTax: number = 0; - protected readonly familyPlanMaxUserCount = 6; - protected readonly premiumPrice = 10; - protected readonly storageGBPrice = 4; - - constructor( - private activatedRoute: ActivatedRoute, - private apiService: ApiService, - private billingAccountProfileStateService: BillingAccountProfileStateService, - private configService: ConfigService, - private environmentService: EnvironmentService, - private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService, - private router: Router, - private syncService: SyncService, - private toastService: ToastService, - private tokenService: TokenService, - private taxService: TaxServiceAbstraction, - private accountService: AccountService, - ) { - this.isSelfHost = this.platformUtilsService.isSelfHost(); - - this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe( - switchMap((account) => - this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id), - ), - ); - - combineLatest([ - this.accountService.activeAccount$.pipe( - switchMap((account) => - this.billingAccountProfileStateService.hasPremiumPersonally$(account.id), - ), - ), - this.environmentService.cloudWebVaultUrl$, - ]) - .pipe( - takeUntilDestroyed(), - concatMap(([hasPremiumPersonally, cloudWebVaultURL]) => { - if (hasPremiumPersonally) { - return from(this.navigateToSubscriptionPage()); - } - - this.cloudWebVaultURL = cloudWebVaultURL; - return of(true); - }), - ) - .subscribe(); - - this.addOnFormGroup.controls.additionalStorage.valueChanges - .pipe(debounceTime(1000), takeUntilDestroyed()) - .subscribe(() => { - this.refreshSalesTax(); - }); - } - - finalizeUpgrade = async () => { - await this.apiService.refreshIdentityToken(); - await this.syncService.fullSync(true); - }; - - postFinalizeUpgrade = async () => { - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("premiumUpdated"), - }); - await this.navigateToSubscriptionPage(); - }; - - navigateToSubscriptionPage = (): Promise => - this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute }); - - onLicenseFileSelected = (event: Event): void => { - const element = event.target as HTMLInputElement; - this.licenseFormGroup.value.file = element.files.length > 0 ? element.files[0] : null; - }; - - submitPremiumLicense = async (): Promise => { - this.licenseFormGroup.markAllAsTouched(); - - if (this.licenseFormGroup.invalid) { - return this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("selectFile"), - }); - } - - const emailVerified = await this.tokenService.getEmailVerified(); - if (!emailVerified) { - return this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("verifyEmailFirst"), - }); - } - - const formData = new FormData(); - formData.append("license", this.licenseFormGroup.value.file); - - await this.apiService.postAccountLicense(formData); - await this.finalizeUpgrade(); - await this.postFinalizeUpgrade(); - }; - - submitPayment = async (): Promise => { - this.taxInfoComponent.taxFormGroup.markAllAsTouched(); - if (this.taxInfoComponent.taxFormGroup.invalid) { - return; - } - - const { type, token } = await this.paymentComponent.tokenize(); - - const formData = new FormData(); - formData.append("paymentMethodType", type.toString()); - formData.append("paymentToken", token); - formData.append("additionalStorageGb", this.addOnFormGroup.value.additionalStorage.toString()); - formData.append("country", this.taxInfoComponent.country); - formData.append("postalCode", this.taxInfoComponent.postalCode); - - await this.apiService.postPremium(formData); - await this.finalizeUpgrade(); - await this.postFinalizeUpgrade(); - }; - - protected get additionalStorageCost(): number { - return this.storageGBPrice * this.addOnFormGroup.value.additionalStorage; - } - - protected get premiumURL(): string { - return `${this.cloudWebVaultURL}/#/settings/subscription/premium`; - } - - protected get subtotal(): number { - return this.premiumPrice + this.additionalStorageCost; - } - - protected get total(): number { - return this.subtotal + this.estimatedTax; - } - - protected async onLicenseFileSelectedChanged(): Promise { - await this.postFinalizeUpgrade(); - } - - private refreshSalesTax(): void { - if (!this.taxInfoComponent.country || !this.taxInfoComponent.postalCode) { - return; - } - const request: PreviewIndividualInvoiceRequest = { - passwordManager: { - additionalStorage: this.addOnFormGroup.value.additionalStorage, - }, - taxInformation: { - postalCode: this.taxInfoComponent.postalCode, - country: this.taxInfoComponent.country, - }, - }; - - this.taxService - .previewIndividualInvoice(request) - .then((invoice) => { - this.estimatedTax = invoice.taxAmount; - }) - .catch((error) => { - this.toastService.showToast({ - title: "", - variant: "error", - message: this.i18nService.t(error.message), - }); - }); - } - - protected onTaxInformationChanged(): void { - this.refreshSalesTax(); - } -} diff --git a/apps/web/src/app/billing/individual/premium/self-hosted-premium.component.html b/apps/web/src/app/billing/individual/premium/self-hosted-premium.component.html new file mode 100644 index 00000000000..1e32e73c8f5 --- /dev/null +++ b/apps/web/src/app/billing/individual/premium/self-hosted-premium.component.html @@ -0,0 +1,49 @@ + + + +

      {{ "premiumUpgradeUnlockFeatures" | i18n }}

      +
        +
      • + + {{ "premiumSignUpStorage" | i18n }} +
      • +
      • + + {{ "premiumSignUpTwoStepOptions" | i18n }} +
      • +
      • + + {{ "premiumSignUpEmergency" | i18n }} +
      • +
      • + + {{ "premiumSignUpReports" | i18n }} +
      • +
      • + + {{ "premiumSignUpTotp" | i18n }} +
      • +
      • + + {{ "premiumSignUpSupport" | i18n }} +
      • +
      • + + {{ "premiumSignUpFuture" | i18n }} +
      • +
      + + {{ "purchasePremium" | i18n }} + +
      +
      + + + +
      diff --git a/apps/web/src/app/billing/individual/premium/self-hosted-premium.component.ts b/apps/web/src/app/billing/individual/premium/self-hosted-premium.component.ts new file mode 100644 index 00000000000..c28f2d45b6f --- /dev/null +++ b/apps/web/src/app/billing/individual/premium/self-hosted-premium.component.ts @@ -0,0 +1,79 @@ +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { combineLatest, map, of, switchMap } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ToastService } from "@bitwarden/components"; +import { BillingSharedModule } from "@bitwarden/web-vault/app/billing/shared"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + templateUrl: "./self-hosted-premium.component.html", + imports: [SharedModule, BillingSharedModule], +}) +export class SelfHostedPremiumComponent { + cloudPremiumPageUrl$ = this.environmentService.cloudWebVaultUrl$.pipe( + map((url) => `${url}/#/settings/subscription/premium`), + ); + + hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe( + switchMap((account) => + account + ? this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id) + : of(false), + ), + ); + + hasPremiumPersonally$ = this.accountService.activeAccount$.pipe( + switchMap((account) => + account + ? this.billingAccountProfileStateService.hasPremiumPersonally$(account.id) + : of(false), + ), + ); + + onLicenseFileUploaded = async () => { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("premiumUpdated"), + }); + await this.navigateToSubscription(); + }; + + constructor( + private accountService: AccountService, + private activatedRoute: ActivatedRoute, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private environmentService: EnvironmentService, + private i18nService: I18nService, + private router: Router, + private toastService: ToastService, + ) { + combineLatest([this.hasPremiumFromAnyOrganization$, this.hasPremiumPersonally$]) + .pipe( + takeUntilDestroyed(), + switchMap(([hasPremiumFromAnyOrganization, hasPremiumPersonally]) => { + if (hasPremiumFromAnyOrganization) { + return this.navigateToVault(); + } + if (hasPremiumPersonally) { + return this.navigateToSubscription(); + } + + return of(true); + }), + ) + .subscribe(); + } + + navigateToSubscription = () => + this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute }); + navigateToVault = () => this.router.navigate(["/vault"]); +} diff --git a/apps/web/src/app/billing/individual/subscription.component.html b/apps/web/src/app/billing/individual/subscription.component.html index fa2eb0412a9..4cbec4b4338 100644 --- a/apps/web/src/app/billing/individual/subscription.component.html +++ b/apps/web/src/app/billing/individual/subscription.component.html @@ -3,14 +3,9 @@ {{ "subscription" | i18n }} - @let paymentMethodPageData = paymentDetailsPageData$ | async; - {{ - paymentMethodPageData.textKey | i18n - }} + {{ "paymentDetails" | i18n }} {{ "billingHistory" | i18n }} - - - + diff --git a/apps/web/src/app/billing/individual/subscription.component.ts b/apps/web/src/app/billing/individual/subscription.component.ts index c6a20a9f6a3..37fb2baf3a6 100644 --- a/apps/web/src/app/billing/individual/subscription.component.ts +++ b/apps/web/src/app/billing/individual/subscription.component.ts @@ -1,46 +1,30 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, OnInit } from "@angular/core"; -import { map, Observable, switchMap } from "rxjs"; +import { Observable, switchMap } 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 { 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"; +// 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: "subscription.component.html", standalone: false, }) export class SubscriptionComponent implements OnInit { hasPremium$: Observable; - paymentDetailsPageData$: Observable<{ - route: string; - textKey: string; - }>; - selfHosted: boolean; constructor( private platformUtilsService: PlatformUtilsService, billingAccountProfileStateService: BillingAccountProfileStateService, accountService: AccountService, - private configService: ConfigService, ) { this.hasPremium$ = accountService.activeAccount$.pipe( switchMap((account) => billingAccountProfileStateService.hasPremiumPersonally$(account.id)), ); - - this.paymentDetailsPageData$ = this.configService - .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) - .pipe( - map((managePaymentDetailsOutsideCheckout) => - managePaymentDetailsOutsideCheckout - ? { route: "payment-details", textKey: "paymentDetails" } - : { route: "payment-method", textKey: "paymentMethod" }, - ), - ); } ngOnInit() { diff --git a/apps/web/src/app/billing/individual/upgrade/services/index.ts b/apps/web/src/app/billing/individual/upgrade/services/index.ts new file mode 100644 index 00000000000..e81e2eaeb01 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/services/index.ts @@ -0,0 +1 @@ +export * from "./unified-upgrade-prompt.service"; diff --git a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts new file mode 100644 index 00000000000..b18e3a7f5c3 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts @@ -0,0 +1,342 @@ +import { mock, mockReset } from "jest-mock-extended"; +import { of, BehaviorSubject } from "rxjs"; + +import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SyncService } from "@bitwarden/common/platform/sync/sync.service"; +import { DialogRef, DialogService } from "@bitwarden/components"; +import { StateProvider } from "@bitwarden/state"; + +import { + UnifiedUpgradeDialogComponent, + UnifiedUpgradeDialogStatus, +} from "../unified-upgrade-dialog/unified-upgrade-dialog.component"; + +import { + UnifiedUpgradePromptService, + PREMIUM_MODAL_DISMISSED_KEY, +} from "./unified-upgrade-prompt.service"; + +describe("UnifiedUpgradePromptService", () => { + let sut: UnifiedUpgradePromptService; + const mockAccountService = mock(); + const mockConfigService = mock(); + const mockBillingService = mock(); + const mockVaultProfileService = mock(); + const mockSyncService = mock(); + const mockDialogService = mock(); + const mockOrganizationService = mock(); + const mockDialogOpen = jest.spyOn(UnifiedUpgradeDialogComponent, "open"); + const mockPlatformUtilsService = mock(); + const mockStateProvider = mock(); + const mockLogService = mock(); + + /** + * Creates a mock DialogRef that implements the required properties for testing + * @param result The result that will be emitted by the closed observable + * @returns A mock DialogRef object + */ + function createMockDialogRef(result: T): DialogRef { + // Create a mock that implements the DialogRef interface + return { + // The closed property is readonly in the actual DialogRef + closed: of(result), + } as DialogRef; + } + + // Mock the open method of a dialog component to return the provided DialogRefs + // Supports multiple calls by returning different refs in sequence + function mockDialogOpenMethod(...refs: DialogRef[]) { + refs.forEach((ref) => mockDialogOpen.mockReturnValueOnce(ref)); + } + + function setupTestService() { + sut = new UnifiedUpgradePromptService( + mockAccountService, + mockConfigService, + mockBillingService, + mockVaultProfileService, + mockSyncService, + mockDialogService, + mockOrganizationService, + mockPlatformUtilsService, + mockStateProvider, + mockLogService, + ); + } + + const mockAccount: Account = { + id: "test-user-id", + } as Account; + const accountSubject = new BehaviorSubject(mockAccount); + + describe("initialization", () => { + beforeEach(() => { + mockAccountService.activeAccount$ = accountSubject.asObservable(); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockStateProvider.getUserState$.mockReturnValue(of(false)); + + setupTestService(); + }); + it("should be created", () => { + expect(sut).toBeTruthy(); + }); + }); + + describe("displayUpgradePromptConditionally", () => { + beforeEach(() => { + accountSubject.next(mockAccount); // Reset account to mockAccount + mockAccountService.activeAccount$ = accountSubject.asObservable(); + mockDialogOpen.mockReset(); + mockReset(mockDialogService); + mockReset(mockConfigService); + mockReset(mockBillingService); + mockReset(mockVaultProfileService); + mockReset(mockSyncService); + mockReset(mockOrganizationService); + mockReset(mockStateProvider); + + // Mock sync service methods + mockSyncService.fullSync.mockResolvedValue(true); + mockSyncService.lastSync$.mockReturnValue(of(new Date())); + mockReset(mockPlatformUtilsService); + + // Default: modal has not been dismissed + mockStateProvider.getUserState$.mockReturnValue(of(false)); + mockStateProvider.setUserState.mockResolvedValue(undefined); + }); + it("should subscribe to account and feature flag observables when checking display conditions", async () => { + // Arrange + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); + mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + + setupTestService(); + + // Act + await sut.displayUpgradePromptConditionally(); + + // Assert + expect(mockConfigService.getFeatureFlag$).toHaveBeenCalledWith( + FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog, + ); + expect(mockAccountService.activeAccount$).toBeDefined(); + }); + it("should not show dialog when feature flag is disabled", async () => { + // Arrange + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); + mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + const recentDate = new Date(); + recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old + mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate); + + setupTestService(); + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); + }); + + it("should not show dialog when user has premium", async () => { + // Arrange + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(true)); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); + setupTestService(); + + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); + }); + + it("should not show dialog when user has any organization membership", async () => { + // Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([{ id: "org1" } as any])); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + setupTestService(); + + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); + }); + + it("should not show dialog when profile is older than 5 minutes", async () => { + // Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); + const oldDate = new Date(); + oldDate.setMinutes(oldDate.getMinutes() - 10); // 10 minutes old + mockVaultProfileService.getProfileCreationDate.mockResolvedValue(oldDate); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + setupTestService(); + + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); + }); + + it("should show dialog when all conditions are met", async () => { + //Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); + const recentDate = new Date(); + recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old + mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + + const expectedResult = { status: UnifiedUpgradeDialogStatus.Closed }; + mockDialogOpenMethod(createMockDialogRef(expectedResult)); + setupTestService(); + + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toEqual(expectedResult); + expect(mockDialogOpen).toHaveBeenCalled(); + }); + + it("should not show dialog when account is null/undefined", async () => { + // Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + accountSubject.next(null); // Set account to null + setupTestService(); + + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); + }); + + it("should not show dialog when profile creation date is unavailable", async () => { + // Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); + mockVaultProfileService.getProfileCreationDate.mockResolvedValue(null); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + + setupTestService(); + + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); + }); + + it("should not show dialog when running in self-hosted environment", async () => { + // Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + const recentDate = new Date(); + recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old + mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate); + mockPlatformUtilsService.isSelfHost.mockReturnValue(true); + setupTestService(); + + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); + }); + + it("should not show dialog when user has previously dismissed the modal", async () => { + // Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); + const recentDate = new Date(); + recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old + mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + mockStateProvider.getUserState$.mockReturnValue(of(true)); // User has dismissed + setupTestService(); + + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toBeNull(); + expect(mockDialogOpen).not.toHaveBeenCalled(); + }); + + it("should save dismissal state when user closes the dialog", async () => { + // Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); + const recentDate = new Date(); + recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old + mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + + const expectedResult = { status: UnifiedUpgradeDialogStatus.Closed }; + mockDialogOpenMethod(createMockDialogRef(expectedResult)); + setupTestService(); + + // Act + await sut.displayUpgradePromptConditionally(); + + // Assert + expect(mockStateProvider.setUserState).toHaveBeenCalledWith( + PREMIUM_MODAL_DISMISSED_KEY, + true, + mockAccount.id, + ); + }); + + it("should not save dismissal state when user upgrades to premium", async () => { + // Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); + const recentDate = new Date(); + recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old + mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + + const expectedResult = { status: UnifiedUpgradeDialogStatus.UpgradedToPremium }; + mockDialogOpenMethod(createMockDialogRef(expectedResult)); + setupTestService(); + + // Act + await sut.displayUpgradePromptConditionally(); + + // Assert + expect(mockStateProvider.setUserState).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts new file mode 100644 index 00000000000..3ea8f19341d --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts @@ -0,0 +1,199 @@ +import { Injectable } from "@angular/core"; +import { combineLatest, firstValueFrom, timeout, from, Observable, of } from "rxjs"; +import { filter, switchMap, take, map } from "rxjs/operators"; + +import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SyncService } from "@bitwarden/common/platform/sync/sync.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DialogRef, DialogService } from "@bitwarden/components"; +import { BILLING_DISK, StateProvider, UserKeyDefinition } from "@bitwarden/state"; + +import { + UnifiedUpgradeDialogComponent, + UnifiedUpgradeDialogResult, + UnifiedUpgradeDialogStatus, +} from "../unified-upgrade-dialog/unified-upgrade-dialog.component"; + +// State key for tracking premium modal dismissal +export const PREMIUM_MODAL_DISMISSED_KEY = new UserKeyDefinition( + BILLING_DISK, + "premiumModalDismissed", + { + deserializer: (value: boolean) => value, + clearOn: [], + }, +); + +@Injectable({ + providedIn: "root", +}) +export class UnifiedUpgradePromptService { + private unifiedUpgradeDialogRef: DialogRef | null = null; + constructor( + private accountService: AccountService, + private configService: ConfigService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private vaultProfileService: VaultProfileService, + private syncService: SyncService, + private dialogService: DialogService, + private organizationService: OrganizationService, + private platformUtilsService: PlatformUtilsService, + private stateProvider: StateProvider, + private logService: LogService, + ) {} + + private shouldShowPrompt$: Observable = this.accountService.activeAccount$.pipe( + switchMap((account) => { + // Check self-hosted first before any other operations + if (this.platformUtilsService.isSelfHost()) { + return of(false); + } + + if (!account) { + return of(false); + } + + const isProfileLessThanFiveMinutesOld$ = from( + this.isProfileLessThanFiveMinutesOld(account.id), + ); + const hasOrganizations$ = from(this.hasOrganizations(account.id)); + const hasDismissedModal$ = this.hasDismissedModal$(account.id); + + return combineLatest([ + isProfileLessThanFiveMinutesOld$, + hasOrganizations$, + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog), + hasDismissedModal$, + ]).pipe( + map( + ([ + isProfileLessThanFiveMinutesOld, + hasOrganizations, + hasPremium, + isFlagEnabled, + hasDismissed, + ]) => { + return ( + isProfileLessThanFiveMinutesOld && + !hasOrganizations && + !hasPremium && + isFlagEnabled && + !hasDismissed + ); + }, + ), + ); + }), + take(1), + ); + + /** + * Conditionally prompt the user based on predefined criteria. + * + * @returns A promise that resolves to the dialog result if shown, or null if not shown + */ + async displayUpgradePromptConditionally(): Promise { + const shouldShow = await firstValueFrom(this.shouldShowPrompt$); + + if (shouldShow) { + return this.launchUpgradeDialog(); + } + + return null; + } + + /** + * Checks if a user's profile was created less than five minutes ago + * @param userId User ID to check + * @returns Promise that resolves to true if profile was created less than five minutes ago + */ + private async isProfileLessThanFiveMinutesOld(userId: string): Promise { + const createdAtDate = await this.vaultProfileService.getProfileCreationDate(userId); + if (!createdAtDate) { + return false; + } + const createdAtInMs = createdAtDate.getTime(); + const nowInMs = new Date().getTime(); + + const differenceInMs = nowInMs - createdAtInMs; + const msInAMinute = 1000 * 60; // 60 seconds * 1000ms + const differenceInMinutes = Math.round(differenceInMs / msInAMinute); + + return differenceInMinutes <= 5; + } + + private async launchUpgradeDialog(): Promise { + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + return null; + } + + this.unifiedUpgradeDialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, { + data: { account }, + }); + + const result = await firstValueFrom(this.unifiedUpgradeDialogRef.closed); + this.unifiedUpgradeDialogRef = null; + + // Save dismissal state when the modal is closed without upgrading + if (result?.status === UnifiedUpgradeDialogStatus.Closed) { + try { + await this.stateProvider.setUserState(PREMIUM_MODAL_DISMISSED_KEY, true, account.id); + } catch (error) { + // Log the error but don't block the dialog from closing + // The modal will still close properly even if persistence fails + this.logService.error("Failed to save premium modal dismissal state:", error); + } + } + + // Return the result or null if the dialog was dismissed without a result + return result || null; + } + + /** + * Checks if the user has any organization associated with their account + * @param userId User ID to check + * @returns Promise that resolves to true if user has any organizations, false otherwise + */ + private async hasOrganizations(userId: UserId): Promise { + // Wait for sync to complete to ensure organizations are fully loaded + // Also force a sync to ensure we have the latest data + await this.syncService.fullSync(false); + + // Wait for the sync to complete with timeout to prevent hanging + await firstValueFrom( + this.syncService.lastSync$(userId).pipe( + filter((lastSync) => lastSync !== null), + take(1), + timeout(30000), // 30 second timeout + ), + ); + + // Check if user has any organization membership (any status including pending) + // Try using memberOrganizations$ which might have different filtering logic + const memberOrganizations = await firstValueFrom( + this.organizationService.memberOrganizations$(userId), + ); + + return memberOrganizations.length > 0; + } + + /** + * Checks if the user has previously dismissed the premium modal + * @param userId User ID to check + * @returns Observable that emits true if modal was dismissed, false otherwise + */ + private hasDismissedModal$(userId: UserId): Observable { + return this.stateProvider + .getUserState$(PREMIUM_MODAL_DISMISSED_KEY, userId) + .pipe(map((dismissed) => dismissed ?? false)); + } +} diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html new file mode 100644 index 00000000000..83c940da97f --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html @@ -0,0 +1,15 @@ +@if (step() == PlanSelectionStep) { + +} @else if (step() == PaymentStep && selectedPlan() !== null && account() !== null) { + +} diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts new file mode 100644 index 00000000000..7f698ae50d1 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts @@ -0,0 +1,424 @@ +import { Component, input, output } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; + +import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction"; +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { + PersonalSubscriptionPricingTierId, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DIALOG_DATA, DialogRef } from "@bitwarden/components"; + +import { + UpgradeAccountComponent, + UpgradeAccountStatus, +} from "../upgrade-account/upgrade-account.component"; +import { + UpgradePaymentComponent, + UpgradePaymentResult, +} from "../upgrade-payment/upgrade-payment.component"; + +import { + UnifiedUpgradeDialogComponent, + UnifiedUpgradeDialogParams, + UnifiedUpgradeDialogStep, +} from "./unified-upgrade-dialog.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-upgrade-account", + template: "", + standalone: true, +}) +class MockUpgradeAccountComponent { + readonly dialogTitleMessageOverride = input(null); + readonly hideContinueWithoutUpgradingButton = input(false); + planSelected = output(); + closeClicked = output(); +} + +// 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-upgrade-payment", + template: "", + standalone: true, +}) +class MockUpgradePaymentComponent { + readonly selectedPlanId = input(null); + readonly account = input(null); + goBack = output(); + complete = output(); +} + +describe("UnifiedUpgradeDialogComponent", () => { + let component: UnifiedUpgradeDialogComponent; + let fixture: ComponentFixture; + const mockDialogRef = mock(); + const mockRouter = mock(); + const mockPremiumInterestStateService = mock(); + + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + emailVerified: true, + name: "Test User", + }; + + const defaultDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + initialStep: null, + selectedPlan: null, + planSelectionStepTitleOverride: null, + }; + + beforeEach(async () => { + // Reset mocks + jest.clearAllMocks(); + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: defaultDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, + ], + }) + .overrideComponent(UnifiedUpgradeDialogComponent, { + remove: { + imports: [UpgradeAccountComponent, UpgradePaymentComponent], + }, + add: { + imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize with default values", () => { + expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection); + expect(component["selectedPlan"]()).toBeNull(); + expect(component["account"]()).toEqual(mockAccount); + expect(component["planSelectionStepTitleOverride"]()).toBeNull(); + }); + + it("should initialize with custom initial step", async () => { + TestBed.resetTestingModule(); + + const customDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + initialStep: UnifiedUpgradeDialogStep.Payment, + selectedPlan: PersonalSubscriptionPricingTierIds.Premium, + }; + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: customDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, + ], + }) + .overrideComponent(UnifiedUpgradeDialogComponent, { + remove: { + imports: [UpgradeAccountComponent, UpgradePaymentComponent], + }, + add: { + imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + }, + }) + .compileComponents(); + + const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); + const customComponent = customFixture.componentInstance; + customFixture.detectChanges(); + + expect(customComponent["step"]()).toBe(UnifiedUpgradeDialogStep.Payment); + expect(customComponent["selectedPlan"]()).toBe(PersonalSubscriptionPricingTierIds.Premium); + }); + + describe("custom dialog title", () => { + it("should use null as default when no override is provided", () => { + expect(component["planSelectionStepTitleOverride"]()).toBeNull(); + }); + + it("should use custom title when provided in dialog config", async () => { + TestBed.resetTestingModule(); + + const customDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + initialStep: UnifiedUpgradeDialogStep.PlanSelection, + selectedPlan: null, + planSelectionStepTitleOverride: "upgradeYourPlan", + }; + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: customDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, + ], + }) + .overrideComponent(UnifiedUpgradeDialogComponent, { + remove: { + imports: [UpgradeAccountComponent, UpgradePaymentComponent], + }, + add: { + imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + }, + }) + .compileComponents(); + + const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); + const customComponent = customFixture.componentInstance; + customFixture.detectChanges(); + + expect(customComponent["planSelectionStepTitleOverride"]()).toBe("upgradeYourPlan"); + }); + }); + + describe("onPlanSelected", () => { + it("should set selected plan and move to payment step", () => { + component["onPlanSelected"](PersonalSubscriptionPricingTierIds.Premium); + + expect(component["selectedPlan"]()).toBe(PersonalSubscriptionPricingTierIds.Premium); + expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.Payment); + }); + }); + + describe("previousStep", () => { + it("should go back to plan selection and clear selected plan", async () => { + component["step"].set(UnifiedUpgradeDialogStep.Payment); + component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium); + + await component["previousStep"](); + + expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection); + expect(component["selectedPlan"]()).toBeNull(); + }); + }); + + describe("hideContinueWithoutUpgradingButton", () => { + it("should default to false when not provided", () => { + expect(component["hideContinueWithoutUpgradingButton"]()).toBe(false); + }); + + it("should be set to true when provided in dialog config", async () => { + TestBed.resetTestingModule(); + + const customDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + initialStep: null, + selectedPlan: null, + hideContinueWithoutUpgradingButton: true, + }; + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: customDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, + ], + }) + .overrideComponent(UnifiedUpgradeDialogComponent, { + remove: { + imports: [UpgradeAccountComponent, UpgradePaymentComponent], + }, + add: { + imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + }, + }) + .compileComponents(); + + const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); + const customComponent = customFixture.componentInstance; + customFixture.detectChanges(); + + expect(customComponent["hideContinueWithoutUpgradingButton"]()).toBe(true); + }); + }); + + describe("onComplete with premium interest", () => { + it("should check premium interest, clear it, and route to /vault when premium interest exists", async () => { + mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(true); + mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(); + mockRouter.navigate.mockResolvedValue(true); + + const result: UpgradePaymentResult = { + status: "upgradedToPremium", + organizationId: null, + }; + + await component["onComplete"](result); + + expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockRouter.navigate).toHaveBeenCalledWith(["/vault"]); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToPremium", + organizationId: null, + }); + }); + + it("should not clear premium interest when upgrading to families", async () => { + const result: UpgradePaymentResult = { + status: "upgradedToFamilies", + organizationId: "org-123", + }; + + await component["onComplete"](result); + + expect(mockPremiumInterestStateService.getPremiumInterest).not.toHaveBeenCalled(); + expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled(); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToFamilies", + organizationId: "org-123", + }); + }); + + it("should use standard redirect when no premium interest exists", async () => { + TestBed.resetTestingModule(); + + const customDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + redirectOnCompletion: true, + }; + + mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false); + mockRouter.navigate.mockResolvedValue(true); + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: customDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, + ], + }) + .overrideComponent(UnifiedUpgradeDialogComponent, { + remove: { + imports: [UpgradeAccountComponent, UpgradePaymentComponent], + }, + add: { + imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + }, + }) + .compileComponents(); + + const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); + const customComponent = customFixture.componentInstance; + customFixture.detectChanges(); + + const result: UpgradePaymentResult = { + status: "upgradedToPremium", + organizationId: null, + }; + + await customComponent["onComplete"](result); + + expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled(); + expect(mockRouter.navigate).toHaveBeenCalledWith([ + "/settings/subscription/user-subscription", + ]); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToPremium", + organizationId: null, + }); + }); + }); + + describe("onCloseClicked with premium interest", () => { + it("should clear premium interest when modal is closed", async () => { + mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(); + + await component["onCloseClicked"](); + + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); + }); + }); + + describe("previousStep with premium interest", () => { + it("should NOT clear premium interest when navigating between steps", async () => { + component["step"].set(UnifiedUpgradeDialogStep.Payment); + component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium); + + await component["previousStep"](); + + expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled(); + expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection); + expect(component["selectedPlan"]()).toBeNull(); + }); + + it("should clear premium interest when backing out of dialog completely", async () => { + TestBed.resetTestingModule(); + + const customDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + initialStep: UnifiedUpgradeDialogStep.Payment, + selectedPlan: PersonalSubscriptionPricingTierIds.Premium, + }; + + mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(); + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: customDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, + ], + }) + .overrideComponent(UnifiedUpgradeDialogComponent, { + remove: { + imports: [UpgradeAccountComponent, UpgradePaymentComponent], + }, + add: { + imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + }, + }) + .compileComponents(); + + const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); + const customComponent = customFixture.componentInstance; + customFixture.detectChanges(); + + await customComponent["previousStep"](); + + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); + }); + }); +}); diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts new file mode 100644 index 00000000000..02d48e8d8f4 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts @@ -0,0 +1,203 @@ +import { DIALOG_DATA } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject, OnInit, signal } from "@angular/core"; +import { Router } from "@angular/router"; + +import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction"; +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { PersonalSubscriptionPricingTierId } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { + ButtonModule, + DialogConfig, + DialogModule, + DialogRef, + DialogService, +} from "@bitwarden/components"; + +import { AccountBillingClient, TaxClient } from "../../../clients"; +import { BillingServicesModule } from "../../../services"; +import { UpgradeAccountComponent } from "../upgrade-account/upgrade-account.component"; +import { UpgradePaymentService } from "../upgrade-payment/services/upgrade-payment.service"; +import { + UpgradePaymentComponent, + UpgradePaymentResult, +} from "../upgrade-payment/upgrade-payment.component"; + +export const UnifiedUpgradeDialogStatus = { + Closed: "closed", + UpgradedToPremium: "upgradedToPremium", + UpgradedToFamilies: "upgradedToFamilies", +} as const; + +export const UnifiedUpgradeDialogStep = { + PlanSelection: "planSelection", + Payment: "payment", +} as const; + +export type UnifiedUpgradeDialogStatus = UnionOfValues; +export type UnifiedUpgradeDialogStep = UnionOfValues; + +export type UnifiedUpgradeDialogResult = { + status: UnifiedUpgradeDialogStatus; + organizationId?: string | null; +}; + +/** + * Parameters for the UnifiedUpgradeDialog component. + * In order to open the dialog to a specific step, you must provide the `initialStep` parameter and a `selectedPlan` if the step is `Payment`. + * + * @property {Account} account - The user account information. + * @property {UnifiedUpgradeDialogStep | null} [initialStep] - The initial step to show in the dialog, if any. + * @property {PersonalSubscriptionPricingTierId | null} [selectedPlan] - Pre-selected subscription plan, if any. + * @property {string | null} [dialogTitleMessageOverride] - Optional custom i18n key to override the default dialog title. + * @property {boolean} [hideContinueWithoutUpgradingButton] - Whether to hide the "Continue without upgrading" button. + * @property {boolean} [redirectOnCompletion] - Whether to redirect after successful upgrade. Premium upgrades redirect to subscription settings, Families upgrades redirect to organization vault. + */ +export type UnifiedUpgradeDialogParams = { + account: Account; + initialStep?: UnifiedUpgradeDialogStep | null; + selectedPlan?: PersonalSubscriptionPricingTierId | null; + planSelectionStepTitleOverride?: string | null; + hideContinueWithoutUpgradingButton?: boolean; + redirectOnCompletion?: boolean; +}; + +// 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-unified-upgrade-dialog", + imports: [ + CommonModule, + DialogModule, + ButtonModule, + UpgradeAccountComponent, + UpgradePaymentComponent, + BillingServicesModule, + ], + providers: [UpgradePaymentService, AccountBillingClient, TaxClient], + templateUrl: "./unified-upgrade-dialog.component.html", +}) +export class UnifiedUpgradeDialogComponent implements OnInit { + // Use signals for dialog state because inputs depend on parent component + protected readonly step = signal( + UnifiedUpgradeDialogStep.PlanSelection, + ); + protected readonly selectedPlan = signal(null); + protected readonly account = signal(null); + protected readonly planSelectionStepTitleOverride = signal(null); + protected readonly hideContinueWithoutUpgradingButton = signal(false); + + protected readonly PaymentStep = UnifiedUpgradeDialogStep.Payment; + protected readonly PlanSelectionStep = UnifiedUpgradeDialogStep.PlanSelection; + + constructor( + private dialogRef: DialogRef, + @Inject(DIALOG_DATA) private params: UnifiedUpgradeDialogParams, + private router: Router, + private premiumInterestStateService: PremiumInterestStateService, + ) {} + + ngOnInit(): void { + this.account.set(this.params.account); + this.step.set(this.params.initialStep ?? UnifiedUpgradeDialogStep.PlanSelection); + this.selectedPlan.set(this.params.selectedPlan ?? null); + this.planSelectionStepTitleOverride.set(this.params.planSelectionStepTitleOverride ?? null); + this.hideContinueWithoutUpgradingButton.set( + this.params.hideContinueWithoutUpgradingButton ?? false, + ); + } + + protected onPlanSelected(planId: PersonalSubscriptionPricingTierId): void { + this.selectedPlan.set(planId); + this.nextStep(); + } + protected async onCloseClicked(): Promise { + // Clear premium interest when user closes/abandons modal + await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id); + this.close({ status: UnifiedUpgradeDialogStatus.Closed }); + } + + private close(result: UnifiedUpgradeDialogResult): void { + this.dialogRef.close(result); + } + + protected nextStep() { + if (this.step() === UnifiedUpgradeDialogStep.PlanSelection) { + this.step.set(UnifiedUpgradeDialogStep.Payment); + } + } + + protected async previousStep(): Promise { + // If we are on the payment step and there was no initial step, go back to plan selection this is to prevent + // going back to payment step if the dialog was opened directly to payment step + if (this.step() === UnifiedUpgradeDialogStep.Payment && this.params?.initialStep == null) { + this.step.set(UnifiedUpgradeDialogStep.PlanSelection); + this.selectedPlan.set(null); + } else { + // Clear premium interest when backing out of dialog completely + await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id); + this.close({ status: UnifiedUpgradeDialogStatus.Closed }); + } + } + + protected async onComplete(result: UpgradePaymentResult): Promise { + let status: UnifiedUpgradeDialogStatus; + switch (result.status) { + case "upgradedToPremium": + status = UnifiedUpgradeDialogStatus.UpgradedToPremium; + break; + case "upgradedToFamilies": + status = UnifiedUpgradeDialogStatus.UpgradedToFamilies; + break; + case "closed": + status = UnifiedUpgradeDialogStatus.Closed; + break; + default: + status = UnifiedUpgradeDialogStatus.Closed; + } + + this.close({ status, organizationId: result.organizationId }); + + // Check premium interest and route to vault for marketing-initiated premium upgrades + if (status === UnifiedUpgradeDialogStatus.UpgradedToPremium) { + const hasPremiumInterest = await this.premiumInterestStateService.getPremiumInterest( + this.params.account.id, + ); + if (hasPremiumInterest) { + await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id); + await this.router.navigate(["/vault"]); + return; // Exit early, don't use redirectOnCompletion + } + } + + // Use redirectOnCompletion for standard upgrade flows + if ( + this.params.redirectOnCompletion && + (status === UnifiedUpgradeDialogStatus.UpgradedToPremium || + status === UnifiedUpgradeDialogStatus.UpgradedToFamilies) + ) { + const redirectUrl = + status === UnifiedUpgradeDialogStatus.UpgradedToFamilies + ? `/organizations/${result.organizationId}/vault` + : "/settings/subscription/user-subscription"; + await this.router.navigate([redirectUrl]); + } + } + + /** + * Opens the unified upgrade dialog. + * + * @param dialogService - The dialog service used to open the component + * @param dialogConfig - The configuration for the dialog including UnifiedUpgradeDialogParams data + * @returns A dialog reference object of type DialogRef + */ + static open( + dialogService: DialogService, + dialogConfig: DialogConfig, + ): DialogRef { + return dialogService.open(UnifiedUpgradeDialogComponent, { + data: dialogConfig.data, + }); + } +} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.html new file mode 100644 index 00000000000..f1aebac7695 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.html @@ -0,0 +1,70 @@ +@if (!loading()) { +
      +
      + +
      +
      +
      +

      + {{ dialogTitle() | i18n }} +

      +

      + {{ "individualUpgradeDescriptionMessage" | i18n }} +

      +
      + +
      + @if (premiumCardDetails) { + +

      + {{ premiumCardDetails.title }} +

      +
      + } + + @if (familiesCardDetails) { + +

      + {{ familiesCardDetails.title }} +

      +
      + } +
      +
      +

      + {{ "individualUpgradeTaxInformationMessage" | i18n }} +

      + @if (!hideContinueWithoutUpgradingButton()) { + + } +
      +
      +
      +} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts new file mode 100644 index 00000000000..add0eb0a011 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts @@ -0,0 +1,197 @@ +import { CdkTrapFocus } from "@angular/cdk/a11y"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PricingCardComponent } from "@bitwarden/pricing"; + +import { BillingServicesModule } from "../../../services"; + +import { UpgradeAccountComponent, UpgradeAccountStatus } from "./upgrade-account.component"; + +describe("UpgradeAccountComponent", () => { + let sut: UpgradeAccountComponent; + let fixture: ComponentFixture; + const mockI18nService = mock(); + const mockSubscriptionPricingService = mock(); + + // Mock pricing tiers data + const mockPricingTiers: PersonalSubscriptionPricingTier[] = [ + { + id: PersonalSubscriptionPricingTierIds.Premium, + name: "premium", // Name changed to match i18n key expectation + description: "Premium plan for individuals", + passwordManager: { + annualPrice: 10, + features: [{ value: "Feature 1" }, { value: "Feature 2" }, { value: "Feature 3" }], + }, + } as PersonalSubscriptionPricingTier, + { + id: PersonalSubscriptionPricingTierIds.Families, + name: "planNameFamilies", // Name changed to match i18n key expectation + description: "Family plan for up to 6 users", + passwordManager: { + annualPrice: 40, + features: [{ value: "Feature A" }, { value: "Feature B" }, { value: "Feature C" }], + users: 6, + }, + } as PersonalSubscriptionPricingTier, + ]; + + beforeEach(async () => { + jest.resetAllMocks(); + + mockI18nService.t.mockImplementation((key) => key); + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( + of(mockPricingTiers), + ); + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UpgradeAccountComponent, PricingCardComponent, CdkTrapFocus], + providers: [ + { provide: I18nService, useValue: mockI18nService }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: mockSubscriptionPricingService, + }, + ], + }) + .overrideComponent(UpgradeAccountComponent, { + // Remove BillingServicesModule to avoid conflicts with mocking SubscriptionPricingService dependencies + remove: { imports: [BillingServicesModule] }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(UpgradeAccountComponent); + sut = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(sut).toBeTruthy(); + }); + + it("should set up pricing tier details properly", () => { + expect(sut["premiumCardDetails"]).toBeDefined(); + expect(sut["familiesCardDetails"]).toBeDefined(); + }); + + it("should create premium card details correctly", () => { + // Because the i18n service is mocked to return the key itself + expect(sut["premiumCardDetails"].title).toBe("premium"); + expect(sut["premiumCardDetails"].tagline).toBe("Premium plan for individuals"); + expect(sut["premiumCardDetails"].price.amount).toBe(10 / 12); + expect(sut["premiumCardDetails"].price.cadence).toBe("monthly"); + expect(sut["premiumCardDetails"].button.type).toBe("primary"); + expect(sut["premiumCardDetails"].button.text).toBe("upgradeToPremium"); + expect(sut["premiumCardDetails"].features).toEqual(["Feature 1", "Feature 2", "Feature 3"]); + }); + + it("should create families card details correctly", () => { + // Because the i18n service is mocked to return the key itself + expect(sut["familiesCardDetails"].title).toBe("planNameFamilies"); + expect(sut["familiesCardDetails"].tagline).toBe("Family plan for up to 6 users"); + expect(sut["familiesCardDetails"].price.amount).toBe(40 / 12); + expect(sut["familiesCardDetails"].price.cadence).toBe("monthly"); + expect(sut["familiesCardDetails"].button.type).toBe("secondary"); + expect(sut["familiesCardDetails"].button.text).toBe("startFreeFamiliesTrial"); + expect(sut["familiesCardDetails"].features).toEqual(["Feature A", "Feature B", "Feature C"]); + }); + + it("should emit planSelected with premium pricing tier when premium plan is selected", () => { + // Arrange + const emitSpy = jest.spyOn(sut.planSelected, "emit"); + + // Act + sut.planSelected.emit(PersonalSubscriptionPricingTierIds.Premium); + + // Assert + expect(emitSpy).toHaveBeenCalledWith(PersonalSubscriptionPricingTierIds.Premium); + }); + + it("should emit planSelected with families pricing tier when families plan is selected", () => { + // Arrange + const emitSpy = jest.spyOn(sut.planSelected, "emit"); + + // Act + sut.planSelected.emit(PersonalSubscriptionPricingTierIds.Families); + + // Assert + expect(emitSpy).toHaveBeenCalledWith(PersonalSubscriptionPricingTierIds.Families); + }); + + it("should emit closeClicked with closed status when close button is clicked", () => { + // Arrange + const emitSpy = jest.spyOn(sut.closeClicked, "emit"); + + // Act + sut.closeClicked.emit(UpgradeAccountStatus.Closed); + + // Assert + expect(emitSpy).toHaveBeenCalledWith(UpgradeAccountStatus.Closed); + }); + + describe("isFamiliesPlan", () => { + it("should return true for families plan", () => { + const result = sut["isFamiliesPlan"](PersonalSubscriptionPricingTierIds.Families); + expect(result).toBe(true); + }); + + it("should return false for premium plan", () => { + const result = sut["isFamiliesPlan"](PersonalSubscriptionPricingTierIds.Premium); + expect(result).toBe(false); + }); + }); + + describe("hideContinueWithoutUpgradingButton", () => { + it("should show the continue without upgrading button by default", () => { + const button = fixture.nativeElement.querySelector('button[bitLink][linkType="primary"]'); + expect(button).toBeTruthy(); + }); + + it("should hide the continue without upgrading button when input is true", async () => { + TestBed.resetTestingModule(); + + mockI18nService.t.mockImplementation((key) => key); + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( + of(mockPricingTiers), + ); + + await TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + UpgradeAccountComponent, + PricingCardComponent, + CdkTrapFocus, + ], + providers: [ + { provide: I18nService, useValue: mockI18nService }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: mockSubscriptionPricingService, + }, + ], + }) + .overrideComponent(UpgradeAccountComponent, { + remove: { imports: [BillingServicesModule] }, + }) + .compileComponents(); + + const customFixture = TestBed.createComponent(UpgradeAccountComponent); + customFixture.componentRef.setInput("hideContinueWithoutUpgradingButton", true); + customFixture.detectChanges(); + + const button = customFixture.nativeElement.querySelector( + 'button[bitLink][linkType="primary"]', + ); + expect(button).toBeNull(); + }); + }); +}); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts new file mode 100644 index 00000000000..a4089d7a47a --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts @@ -0,0 +1,146 @@ +import { CdkTrapFocus } from "@angular/cdk/a11y"; +import { CommonModule } from "@angular/common"; +import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { catchError, of } from "rxjs"; + +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, + PersonalSubscriptionPricingTierIds, + SubscriptionCadence, + SubscriptionCadenceIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { ButtonType, DialogModule, ToastService } from "@bitwarden/components"; +import { PricingCardComponent } from "@bitwarden/pricing"; + +import { SharedModule } from "../../../../shared"; +import { BillingServicesModule } from "../../../services"; + +export const UpgradeAccountStatus = { + Closed: "closed", + ProceededToPayment: "proceeded-to-payment", +} as const; + +export type UpgradeAccountStatus = UnionOfValues; + +export type UpgradeAccountResult = { + status: UpgradeAccountStatus; + plan: PersonalSubscriptionPricingTierId | null; +}; + +type CardDetails = { + title: string; + tagline: string; + price: { amount: number; cadence: SubscriptionCadence }; + button: { text: string; type: ButtonType }; + features: string[]; +}; + +// 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-upgrade-account", + imports: [ + CommonModule, + DialogModule, + SharedModule, + BillingServicesModule, + PricingCardComponent, + CdkTrapFocus, + ], + templateUrl: "./upgrade-account.component.html", +}) +export class UpgradeAccountComponent implements OnInit { + readonly dialogTitleMessageOverride = input(null); + readonly hideContinueWithoutUpgradingButton = input(false); + planSelected = output(); + closeClicked = output(); + protected readonly loading = signal(true); + protected premiumCardDetails!: CardDetails; + protected familiesCardDetails!: CardDetails; + + protected familiesPlanType = PersonalSubscriptionPricingTierIds.Families; + protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium; + protected closeStatus = UpgradeAccountStatus.Closed; + + protected readonly dialogTitle = computed(() => { + return this.dialogTitleMessageOverride() || "individualUpgradeWelcomeMessage"; + }); + + constructor( + private i18nService: I18nService, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, + private toastService: ToastService, + private destroyRef: DestroyRef, + ) {} + + ngOnInit(): void { + this.subscriptionPricingService + .getPersonalSubscriptionPricingTiers$() + .pipe( + catchError((error: unknown) => { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("unexpectedError"), + }); + this.loading.set(false); + return of([]); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((plans) => { + this.setupCardDetails(plans); + this.loading.set(false); + }); + } + + /** Setup card details for the pricing tiers. + * This can be extended in the future for business plans, etc. + */ + private setupCardDetails(plans: PersonalSubscriptionPricingTier[]): void { + const premiumTier = plans.find( + (tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium, + ); + const familiesTier = plans.find( + (tier) => tier.id === PersonalSubscriptionPricingTierIds.Families, + ); + + if (premiumTier) { + this.premiumCardDetails = this.createCardDetails(premiumTier, "primary"); + } + + if (familiesTier) { + this.familiesCardDetails = this.createCardDetails(familiesTier, "secondary"); + } + } + + private createCardDetails( + tier: PersonalSubscriptionPricingTier, + buttonType: ButtonType, + ): CardDetails { + return { + title: tier.name, + tagline: tier.description, + price: { + amount: tier.passwordManager.annualPrice / 12, + cadence: SubscriptionCadenceIds.Monthly, + }, + button: { + text: this.i18nService.t( + this.isFamiliesPlan(tier.id) ? "startFreeFamiliesTrial" : "upgradeToPremium", + ), + type: buttonType, + }, + features: tier.passwordManager.features.map((f: { key: string; value: string }) => f.value), + }; + } + + private isFamiliesPlan(plan: PersonalSubscriptionPricingTierId): boolean { + return plan === PersonalSubscriptionPricingTierIds.Families; + } +} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.html new file mode 100644 index 00000000000..a028839f8f0 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.html @@ -0,0 +1,14 @@ +
      +
      + + +
      +
      diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts new file mode 100644 index 00000000000..787936c102e --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts @@ -0,0 +1,161 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +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 { UserId } from "@bitwarden/common/types/guid"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { DialogRef, DialogService } from "@bitwarden/components"; + +import { + UnifiedUpgradeDialogResult, + UnifiedUpgradeDialogStatus, +} from "../../unified-upgrade-dialog/unified-upgrade-dialog.component"; + +import { UpgradeNavButtonComponent } from "./upgrade-nav-button.component"; + +describe("UpgradeNavButtonComponent", () => { + let component: UpgradeNavButtonComponent; + let fixture: ComponentFixture; + let mockDialogService: MockProxy; + let mockAccountService: MockProxy; + let mockSyncService: MockProxy; + let mockApiService: MockProxy; + let mockRouter: MockProxy; + let mockI18nService: MockProxy; + let mockPlatformUtilsService: MockProxy; + let activeAccount$: BehaviorSubject; + + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + emailVerified: true, + name: "Test User", + }; + + beforeEach(async () => { + mockDialogService = mock(); + mockAccountService = mock(); + mockSyncService = mock(); + mockApiService = mock(); + mockRouter = mock(); + mockI18nService = mock(); + mockPlatformUtilsService = mock(); + + activeAccount$ = new BehaviorSubject(mockAccount); + mockAccountService.activeAccount$ = activeAccount$; + mockI18nService.t.mockImplementation((key) => key); + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + + await TestBed.configureTestingModule({ + imports: [UpgradeNavButtonComponent], + providers: [ + { provide: DialogService, useValue: mockDialogService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: SyncService, useValue: mockSyncService }, + { provide: ApiService, useValue: mockApiService }, + { provide: Router, useValue: mockRouter }, + { provide: I18nService, useValue: mockI18nService }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(UpgradeNavButtonComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("upgrade()", () => { + describe("when self-hosted", () => { + beforeEach(() => { + mockPlatformUtilsService.isSelfHost.mockReturnValue(true); + }); + + it("should navigate to subscription page", async () => { + await component.upgrade(); + + expect(mockRouter.navigate).toHaveBeenCalledWith(["/settings/subscription/premium"]); + expect(mockDialogService.open).not.toHaveBeenCalled(); + }); + }); + + describe("when not self-hosted", () => { + beforeEach(() => { + mockPlatformUtilsService.isSelfHost.mockReturnValue(false); + }); + + it("should return early if no active account exists", async () => { + activeAccount$.next(null); + + await component.upgrade(); + + expect(mockDialogService.open).not.toHaveBeenCalled(); + }); + + it("should open upgrade dialog with correct configuration", async () => { + const mockDialogRef = mock>(); + mockDialogRef.closed = of({ status: UnifiedUpgradeDialogStatus.Closed }); + mockDialogService.open.mockReturnValue(mockDialogRef); + + await component.upgrade(); + + expect(mockDialogService.open).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + data: { + account: mockAccount, + planSelectionStepTitleOverride: "upgradeYourPlan", + hideContinueWithoutUpgradingButton: true, + }, + }), + ); + }); + + it("should full sync after upgrading to premium", async () => { + const mockDialogRef = mock>(); + mockDialogRef.closed = of({ status: UnifiedUpgradeDialogStatus.UpgradedToPremium }); + mockDialogService.open.mockReturnValue(mockDialogRef); + + await component.upgrade(); + + expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); + }); + + it("should navigate to organization vault after upgrading to families", async () => { + const organizationId = "org-123"; + const mockDialogRef = mock>(); + mockDialogRef.closed = of({ + status: UnifiedUpgradeDialogStatus.UpgradedToFamilies, + organizationId, + }); + mockDialogService.open.mockReturnValue(mockDialogRef); + + await component.upgrade(); + + expect(mockRouter.navigate).toHaveBeenCalledWith([ + `/organizations/${organizationId}/vault`, + ]); + }); + + it("should do nothing when dialog closes without upgrade", async () => { + const mockDialogRef = mock>(); + mockDialogRef.closed = of({ status: UnifiedUpgradeDialogStatus.Closed }); + mockDialogService.open.mockReturnValue(mockDialogRef); + + await component.upgrade(); + + expect(mockApiService.refreshIdentityToken).not.toHaveBeenCalled(); + expect(mockSyncService.fullSync).not.toHaveBeenCalled(); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts new file mode 100644 index 00000000000..4dda16674ff --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts @@ -0,0 +1,69 @@ +import { Component, inject } from "@angular/core"; +import { Router } from "@angular/router"; +import { firstValueFrom, lastValueFrom } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { + UnifiedUpgradeDialogComponent, + UnifiedUpgradeDialogStatus, +} from "../../unified-upgrade-dialog/unified-upgrade-dialog.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-upgrade-nav-button", + imports: [I18nPipe], + templateUrl: "./upgrade-nav-button.component.html", + standalone: true, +}) +export class UpgradeNavButtonComponent { + private dialogService = inject(DialogService); + private accountService = inject(AccountService); + private syncService = inject(SyncService); + private apiService = inject(ApiService); + private router = inject(Router); + private platformUtilsService = inject(PlatformUtilsService); + + upgrade = async () => { + if (this.platformUtilsService.isSelfHost()) { + await this.navigateToSelfHostSubscriptionPage(); + } else { + await this.openUpgradeDialog(); + } + }; + + private async navigateToSelfHostSubscriptionPage(): Promise { + const subscriptionUrl = "/settings/subscription/premium"; + await this.router.navigate([subscriptionUrl]); + } + + private async openUpgradeDialog() { + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + return; + } + + const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, { + data: { + account, + planSelectionStepTitleOverride: "upgradeYourPlan", + hideContinueWithoutUpgradingButton: true, + }, + }); + + const result = await lastValueFrom(dialogRef.closed); + + if (result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium) { + await this.syncService.fullSync(true); + } else if (result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies) { + const redirectUrl = `/organizations/${result.organizationId}/vault`; + await this.router.navigate([redirectUrl]); + } + } +} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.stories.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.stories.ts new file mode 100644 index 00000000000..e4ac9eed22c --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.stories.ts @@ -0,0 +1,86 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { of } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { 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 { UserId } from "@bitwarden/common/types/guid"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { DialogService, I18nMockService } from "@bitwarden/components"; +import { UpgradeNavButtonComponent } from "@bitwarden/web-vault/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component"; + +export default { + title: "Billing/Upgrade Navigation Button", + component: UpgradeNavButtonComponent, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + upgradeYourPlan: "Upgrade your plan", + }); + }, + }, + { + provide: DialogService, + useValue: { + open: () => ({ + closed: of({}), + }), + }, + }, + { + provide: AccountService, + useValue: { + activeAccount$: of({ + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }), + }, + }, + { + provide: ApiService, + useValue: { + refreshIdentityToken: () => {}, + }, + }, + { + provide: SyncService, + useValue: { + fullSync: () => {}, + }, + }, + { + provide: PlatformUtilsService, + useValue: { + isSelfHost: () => false, + }, + }, + ], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/nuFrzHsgEoEk2Sm8fWOGuS/Premium---business-upgrade-flows?node-id=858-44274&t=EiNqDGuccfhF14on-1", + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: ` +
      + +
      + `, + }), +}; diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts new file mode 100644 index 00000000000..9d17d62e4dc --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -0,0 +1,701 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, mockReset } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; +import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; +import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { UserId } from "@bitwarden/common/types/guid"; +import { LogService } from "@bitwarden/logging"; + +import { + AccountBillingClient, + SubscriberBillingClient, + TaxAmounts, + TaxClient, +} from "../../../../clients"; +import { + BillingAddress, + NonTokenizablePaymentMethods, + NonTokenizedPaymentMethod, + TokenizedPaymentMethod, +} from "../../../../payment/types"; + +import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service"; + +describe("UpgradePaymentService", () => { + const mockOrganizationBillingService = mock(); + const mockAccountBillingClient = mock(); + const mockTaxClient = mock(); + const mockLogService = mock(); + const mockSyncService = mock(); + const mockOrganizationService = mock(); + const mockAccountService = mock(); + const mockSubscriberBillingClient = mock(); + const mockConfigService = mock(); + + mockSyncService.fullSync.mockResolvedValue(true); + + let sut: UpgradePaymentService; + + const mockAccount = { + id: "user-id" as UserId, + email: "test@example.com", + emailVerified: true, + name: "Test User", + }; + + const mockTokenizedPaymentMethod: TokenizedPaymentMethod = { + token: "test-token", + type: "card", + }; + + const mockBillingAddress: BillingAddress = { + line1: "123 Test St", + line2: null, + city: "Test City", + state: "TS", + country: "US", + postalCode: "12345", + taxId: null, + }; + + const mockPremiumPlanDetails: PlanDetails = { + tier: PersonalSubscriptionPricingTierIds.Premium, + details: { + id: PersonalSubscriptionPricingTierIds.Premium, + name: "Premium", + description: "Premium plan", + availableCadences: ["annually"], + passwordManager: { + type: "standalone", + annualPrice: 10, + annualPricePerAdditionalStorageGB: 4, + features: [ + { key: "feature1", value: "Feature 1" }, + { key: "feature2", value: "Feature 2" }, + ], + }, + }, + }; + + const mockFamiliesPlanDetails: PlanDetails = { + tier: PersonalSubscriptionPricingTierIds.Families, + details: { + id: PersonalSubscriptionPricingTierIds.Families, + name: "Families", + description: "Families plan", + availableCadences: ["annually"], + passwordManager: { + type: "packaged", + annualPrice: 40, + annualPricePerAdditionalStorageGB: 4, + features: [ + { key: "feature1", value: "Feature 1" }, + { key: "feature2", value: "Feature 2" }, + ], + users: 6, + }, + }, + }; + + beforeEach(() => { + mockReset(mockOrganizationBillingService); + mockReset(mockAccountBillingClient); + mockReset(mockTaxClient); + mockReset(mockLogService); + mockReset(mockOrganizationService); + mockReset(mockAccountService); + mockReset(mockSubscriberBillingClient); + + mockAccountService.activeAccount$ = of(null); + mockOrganizationService.organizations$.mockReturnValue(of([])); + + TestBed.configureTestingModule({ + providers: [ + UpgradePaymentService, + { + provide: SubscriberBillingClient, + useValue: mockSubscriberBillingClient, + }, + { + provide: OrganizationBillingServiceAbstraction, + useValue: mockOrganizationBillingService, + }, + { provide: AccountBillingClient, useValue: mockAccountBillingClient }, + { provide: TaxClient, useValue: mockTaxClient }, + { provide: LogService, useValue: mockLogService }, + { provide: SyncService, useValue: mockSyncService }, + { provide: OrganizationService, useValue: mockOrganizationService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }); + + sut = TestBed.inject(UpgradePaymentService); + }); + + describe("userIsOwnerOfFreeOrg$", () => { + it("should return true when user is owner of a free organization", (done) => { + // Arrange + mockReset(mockAccountService); + mockReset(mockOrganizationService); + + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }; + + const paidOrgData = { + id: "org-1", + name: "Paid Org", + useTotp: true, // useTotp = true means NOT free + type: OrganizationUserType.Owner, + } as OrganizationData; + + const freeOrgData = { + id: "org-2", + name: "Free Org", + useTotp: false, // useTotp = false means IS free + type: OrganizationUserType.Owner, + } as OrganizationData; + + const paidOrg = new Organization(paidOrgData); + const freeOrg = new Organization(freeOrgData); + const mockOrganizations = [paidOrg, freeOrg]; + + mockAccountService.activeAccount$ = of(mockAccount); + mockOrganizationService.organizations$.mockReturnValue(of(mockOrganizations)); + + const service = new UpgradePaymentService( + mockOrganizationBillingService, + mockAccountBillingClient, + mockTaxClient, + mockLogService, + mockSyncService, + mockOrganizationService, + mockAccountService, + mockSubscriberBillingClient, + mockConfigService, + ); + + // Act & Assert + service.userIsOwnerOfFreeOrg$.subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it("should return false when user is not owner of any free organization", (done) => { + // Arrange + mockReset(mockAccountService); + mockReset(mockOrganizationService); + + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }; + + const paidOrgData = { + id: "org-1", + name: "Paid Org", + useTotp: true, // useTotp = true means NOT free + type: OrganizationUserType.Owner, + } as OrganizationData; + + const freeOrgData = { + id: "org-2", + name: "Free Org", + useTotp: false, // useTotp = false means IS free + type: OrganizationUserType.User, // Not owner + } as OrganizationData; + + const paidOrg = new Organization(paidOrgData); + const freeOrg = new Organization(freeOrgData); + const mockOrganizations = [paidOrg, freeOrg]; + + mockAccountService.activeAccount$ = of(mockAccount); + mockOrganizationService.organizations$.mockReturnValue(of(mockOrganizations)); + + const service = new UpgradePaymentService( + mockOrganizationBillingService, + mockAccountBillingClient, + mockTaxClient, + mockLogService, + mockSyncService, + mockOrganizationService, + mockAccountService, + mockSubscriberBillingClient, + mockConfigService, + ); + + // Act & Assert + service.userIsOwnerOfFreeOrg$.subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + + it("should return false when user has no organizations", (done) => { + // Arrange + mockReset(mockAccountService); + mockReset(mockOrganizationService); + + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }; + + mockAccountService.activeAccount$ = of(mockAccount); + mockOrganizationService.organizations$.mockReturnValue(of([])); + + const service = new UpgradePaymentService( + mockOrganizationBillingService, + mockAccountBillingClient, + mockTaxClient, + mockLogService, + mockSyncService, + mockOrganizationService, + mockAccountService, + mockSubscriberBillingClient, + mockConfigService, + ); + + // Act & Assert + service.userIsOwnerOfFreeOrg$.subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + }); + + describe("accountCredit$", () => { + it("should correctly fetch account credit for subscriber", (done) => { + // Arrange + + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }; + const expectedCredit = 25.5; + + mockAccountService.activeAccount$ = of(mockAccount); + mockSubscriberBillingClient.getCredit.mockResolvedValue(expectedCredit); + + const service = new UpgradePaymentService( + mockOrganizationBillingService, + mockAccountBillingClient, + mockTaxClient, + mockLogService, + mockSyncService, + mockOrganizationService, + mockAccountService, + mockSubscriberBillingClient, + mockConfigService, + ); + + // Act & Assert + service.accountCredit$.subscribe((credit) => { + expect(credit).toBe(expectedCredit); + expect(mockSubscriberBillingClient.getCredit).toHaveBeenCalledWith({ + data: mockAccount, + type: "account", + }); + done(); + }); + }); + + it("should handle empty account", (done) => { + // Arrange + mockAccountService.activeAccount$ = of(null); + const service = new UpgradePaymentService( + mockOrganizationBillingService, + mockAccountBillingClient, + mockTaxClient, + mockLogService, + mockSyncService, + mockOrganizationService, + mockAccountService, + mockSubscriberBillingClient, + mockConfigService, + ); + // Act & Assert + service?.accountCredit$.subscribe({ + error: () => { + expect(mockSubscriberBillingClient.getCredit).not.toHaveBeenCalled(); + done(); + }, + }); + }); + }); + + describe("adminConsoleRouteForOwnedOrganization$", () => { + it("should return the admin console route for the first free organization the user owns", (done) => { + // Arrange + mockReset(mockAccountService); + mockReset(mockOrganizationService); + + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }; + + const paidOrgData = { + id: "org-1", + name: "Paid Org", + useTotp: true, // useTotp = true means NOT free + type: OrganizationUserType.Owner, + } as OrganizationData; + + const freeOrgData = { + id: "org-2", + name: "Free Org", + useTotp: false, // useTotp = false means IS free + type: OrganizationUserType.Owner, + } as OrganizationData; + + const paidOrg = new Organization(paidOrgData); + const freeOrg = new Organization(freeOrgData); + const mockOrganizations = [paidOrg, freeOrg]; + + mockAccountService.activeAccount$ = of(mockAccount); + mockOrganizationService.organizations$.mockReturnValue(of(mockOrganizations)); + + const service = new UpgradePaymentService( + mockOrganizationBillingService, + mockAccountBillingClient, + mockTaxClient, + mockLogService, + mockSyncService, + mockOrganizationService, + mockAccountService, + mockSubscriberBillingClient, + mockConfigService, + ); + + // Act & Assert + service.adminConsoleRouteForOwnedOrganization$.subscribe((result) => { + expect(result).toBe("/organizations/org-2/billing/subscription"); + done(); + }); + }); + }); + + describe("calculateEstimatedTax", () => { + it("should calculate tax for premium plan", async () => { + // Arrange + const mockResponse = mock(); + mockResponse.tax = 2.5; + + mockTaxClient.previewTaxForPremiumSubscriptionPurchase.mockResolvedValue(mockResponse); + + // Act + const result = await sut.calculateEstimatedTax(mockPremiumPlanDetails, mockBillingAddress); + + // Assert + expect(result).toEqual(2.5); + expect(mockTaxClient.previewTaxForPremiumSubscriptionPurchase).toHaveBeenCalledWith( + 0, + mockBillingAddress, + ); + }); + + it("should calculate tax for families plan", async () => { + // Arrange + const mockResponse = mock(); + mockResponse.tax = 5.0; + + mockTaxClient.previewTaxForOrganizationSubscriptionPurchase.mockResolvedValue(mockResponse); + + // Act + const result = await sut.calculateEstimatedTax(mockFamiliesPlanDetails, mockBillingAddress); + + // Assert + expect(result).toEqual(5.0); + expect(mockTaxClient.previewTaxForOrganizationSubscriptionPurchase).toHaveBeenCalledWith( + { + cadence: "annually", + tier: "families", + passwordManager: { + additionalStorage: 0, + seats: 1, + sponsored: false, + }, + }, + mockBillingAddress, + ); + }); + + it("should throw and log error if personal tax calculation fails", async () => { + // Arrange + const error = new Error("Tax service error"); + mockTaxClient.previewTaxForPremiumSubscriptionPurchase.mockRejectedValue(error); + + // Act & Assert + await expect( + sut.calculateEstimatedTax(mockPremiumPlanDetails, mockBillingAddress), + ).rejects.toThrow(); + expect(mockLogService.error).toHaveBeenCalledWith("Tax calculation failed:", error); + }); + + it("should throw and log error if organization tax calculation fails", async () => { + // Arrange + const error = new Error("Tax service error"); + mockTaxClient.previewTaxForOrganizationSubscriptionPurchase.mockRejectedValue(error); + // Act & Assert + await expect( + sut.calculateEstimatedTax(mockFamiliesPlanDetails, mockBillingAddress), + ).rejects.toThrow(); + expect(mockLogService.error).toHaveBeenCalledWith("Tax calculation failed:", error); + }); + }); + + describe("upgradeToPremium", () => { + it("should call accountBillingClient to purchase premium subscription and refresh data", async () => { + // Arrange + mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue(); + + // Act + await sut.upgradeToPremium(mockTokenizedPaymentMethod, mockBillingAddress); + + // Assert + expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith( + mockTokenizedPaymentMethod, + mockBillingAddress, + ); + expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); + }); + + it("should handle upgrade with account credit payment method and refresh data", async () => { + // Arrange + const accountCreditPaymentMethod: NonTokenizedPaymentMethod = { + type: NonTokenizablePaymentMethods.accountCredit, + }; + mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue(); + + // Act + await sut.upgradeToPremium(accountCreditPaymentMethod, mockBillingAddress); + + // Assert + expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith( + accountCreditPaymentMethod, + mockBillingAddress, + ); + expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); + }); + + it("should validate payment method type and token", async () => { + // Arrange + const noTypePaymentMethod = { token: "test-token" } as any; + const noTokenPaymentMethod = { type: "card" } as TokenizedPaymentMethod; + + // Act & Assert + await expect(sut.upgradeToPremium(noTypePaymentMethod, mockBillingAddress)).rejects.toThrow( + "Payment method type is missing", + ); + + await expect(sut.upgradeToPremium(noTokenPaymentMethod, mockBillingAddress)).rejects.toThrow( + "Payment method token is missing", + ); + }); + + it("should validate billing address fields", async () => { + // Arrange + const missingCountry = { postalCode: "12345" } as any; + const missingPostal = { country: "US" } as any; + const nullFields = { country: "US", postalCode: null } as any; + + // Act & Assert + await expect( + sut.upgradeToPremium(mockTokenizedPaymentMethod, missingCountry), + ).rejects.toThrow("Billing address information is incomplete"); + + await expect(sut.upgradeToPremium(mockTokenizedPaymentMethod, missingPostal)).rejects.toThrow( + "Billing address information is incomplete", + ); + + await expect(sut.upgradeToPremium(mockTokenizedPaymentMethod, nullFields)).rejects.toThrow( + "Billing address information is incomplete", + ); + }); + }); + + describe("upgradeToFamilies", () => { + it("should call organizationBillingService to purchase subscription and refresh data", async () => { + // Arrange + mockOrganizationBillingService.purchaseSubscription.mockResolvedValue({ + id: "org-id", + name: "Test Organization", + billingEmail: "test@example.com", + } as OrganizationResponse); + + // Act + await sut.upgradeToFamilies( + mockAccount, + mockFamiliesPlanDetails, + mockTokenizedPaymentMethod, + { + organizationName: "Test Organization", + billingAddress: mockBillingAddress, + }, + ); + + // Assert + expect(mockOrganizationBillingService.purchaseSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + organization: { + name: "Test Organization", + billingEmail: "test@example.com", + }, + plan: { + type: PlanType.FamiliesAnnually2025, + passwordManagerSeats: 6, + }, + payment: { + paymentMethod: ["test-token", PaymentMethodType.Card], + billing: { + country: "US", + postalCode: "12345", + }, + }, + }), + "user-id", + ); + expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); + }); + + it("should use FamiliesAnnually2025 plan when feature flag is disabled", async () => { + // Arrange + mockConfigService.getFeatureFlag.mockResolvedValue(false); + mockOrganizationBillingService.purchaseSubscription.mockResolvedValue({ + id: "org-id", + name: "Test Organization", + billingEmail: "test@example.com", + } as OrganizationResponse); + + // Act + await sut.upgradeToFamilies( + mockAccount, + mockFamiliesPlanDetails, + mockTokenizedPaymentMethod, + { + organizationName: "Test Organization", + billingAddress: mockBillingAddress, + }, + ); + + // Assert + expect(mockOrganizationBillingService.purchaseSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + plan: { + type: PlanType.FamiliesAnnually2025, + passwordManagerSeats: 6, + }, + }), + "user-id", + ); + }); + + it("should use FamiliesAnnually plan when feature flag is enabled", async () => { + // Arrange + mockConfigService.getFeatureFlag.mockResolvedValue(true); + mockOrganizationBillingService.purchaseSubscription.mockResolvedValue({ + id: "org-id", + name: "Test Organization", + billingEmail: "test@example.com", + } as OrganizationResponse); + + // Act + await sut.upgradeToFamilies( + mockAccount, + mockFamiliesPlanDetails, + mockTokenizedPaymentMethod, + { + organizationName: "Test Organization", + billingAddress: mockBillingAddress, + }, + ); + + // Assert + expect(mockOrganizationBillingService.purchaseSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + plan: { + type: PlanType.FamiliesAnnually, + passwordManagerSeats: 6, + }, + }), + "user-id", + ); + }); + + it("should throw error if password manager seats are 0", async () => { + // Arrange + const invalidPlanDetails: PlanDetails = { + tier: PersonalSubscriptionPricingTierIds.Families, + details: { + passwordManager: { + type: "packaged", + users: 0, + annualPrice: 0, + features: [], + annualPricePerAdditionalStorageGB: 0, + }, + id: "families", + name: "", + description: "", + availableCadences: ["annually"], + }, + }; + + mockOrganizationBillingService.purchaseSubscription.mockRejectedValue( + new Error("Seats must be greater than 0 for families plan"), + ); + + // Act & Assert + await expect( + sut.upgradeToFamilies(mockAccount, invalidPlanDetails, mockTokenizedPaymentMethod, { + organizationName: "Test Organization", + billingAddress: mockBillingAddress, + }), + ).rejects.toThrow("Seats must be greater than 0 for families plan"); + expect(mockOrganizationBillingService.purchaseSubscription).toHaveBeenCalledTimes(1); + }); + + it("should throw error if payment token is missing with card type", async () => { + const incompletePaymentMethod = { type: "card" } as TokenizedPaymentMethod; + + await expect( + sut.upgradeToFamilies(mockAccount, mockFamiliesPlanDetails, incompletePaymentMethod, { + organizationName: "Test Organization", + billingAddress: mockBillingAddress, + }), + ).rejects.toThrow("Payment method token is missing"); + }); + it("should throw error if organization name is missing", async () => { + await expect( + sut.upgradeToFamilies(mockAccount, mockFamiliesPlanDetails, mockTokenizedPaymentMethod, { + organizationName: "", + billingAddress: mockBillingAddress, + }), + ).rejects.toThrow("Organization name is required for families upgrade"); + }); + }); +}); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts new file mode 100644 index 00000000000..94f1c816168 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -0,0 +1,240 @@ +import { Injectable } from "@angular/core"; +import { defaultIfEmpty, find, map, mergeMap, Observable, switchMap } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { + OrganizationBillingServiceAbstraction, + SubscriptionInformation, +} from "@bitwarden/common/billing/abstractions"; +import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { LogService } from "@bitwarden/logging"; + +import { + AccountBillingClient, + OrganizationSubscriptionPurchase, + SubscriberBillingClient, + TaxAmounts, + TaxClient, +} from "../../../../clients"; +import { + BillingAddress, + NonTokenizablePaymentMethods, + NonTokenizedPaymentMethod, + tokenizablePaymentMethodToLegacyEnum, + TokenizedPaymentMethod, +} from "../../../../payment/types"; +import { mapAccountToSubscriber } from "../../../../types"; + +export type PlanDetails = { + tier: PersonalSubscriptionPricingTierId; + details: PersonalSubscriptionPricingTier; +}; + +export type PaymentFormValues = { + organizationName?: string | null; + billingAddress: { + country: string; + postalCode: string; + }; +}; + +/** + * Service for handling payment submission and sales tax calculation for upgrade payment component + */ +@Injectable() +export class UpgradePaymentService { + constructor( + private organizationBillingService: OrganizationBillingServiceAbstraction, + private accountBillingClient: AccountBillingClient, + private taxClient: TaxClient, + private logService: LogService, + private syncService: SyncService, + private organizationService: OrganizationService, + private accountService: AccountService, + private subscriberBillingClient: SubscriberBillingClient, + private configService: ConfigService, + ) {} + + userIsOwnerOfFreeOrg$: Observable = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((id) => this.organizationService.organizations$(id)), + mergeMap((userOrganizations) => userOrganizations), + find((org) => org.isFreeOrg && org.isOwner), + defaultIfEmpty(false), + map((value) => value instanceof Organization), + ); + + adminConsoleRouteForOwnedOrganization$: Observable = + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((id) => this.organizationService.organizations$(id)), + mergeMap((userOrganizations) => userOrganizations), + find((org) => org.isFreeOrg && org.isOwner), + map((org) => `/organizations/${org!.id}/billing/subscription`), + ); + + // Fetch account credit + accountCredit$: Observable = this.accountService.activeAccount$.pipe( + mapAccountToSubscriber, + switchMap((account) => this.subscriberBillingClient.getCredit(account)), + ); + + /** + * Calculate estimated tax for the selected plan + */ + async calculateEstimatedTax( + planDetails: PlanDetails, + billingAddress: BillingAddress, + ): Promise { + const isFamiliesPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Families; + const isPremiumPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Premium; + + let taxClientCall: Promise | null = null; + + if (isFamiliesPlan) { + // Currently, only Families plan is supported for organization plans + const request: OrganizationSubscriptionPurchase = { + tier: "families", + cadence: "annually", + passwordManager: { seats: 1, additionalStorage: 0, sponsored: false }, + }; + + taxClientCall = this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + request, + billingAddress, + ); + } + + if (isPremiumPlan) { + taxClientCall = this.taxClient.previewTaxForPremiumSubscriptionPurchase(0, billingAddress); + } + + if (taxClientCall === null) { + throw new Error("Tax client call is not defined"); + } + + try { + const preview = await taxClientCall; + return preview.tax; + } catch (error) { + this.logService.error("Tax calculation failed:", error); + throw error; + } + } + + /** + * Process premium upgrade + */ + async upgradeToPremium( + paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod, + billingAddress: Pick, + ): Promise { + this.validatePaymentAndBillingInfo(paymentMethod, billingAddress); + + await this.accountBillingClient.purchasePremiumSubscription(paymentMethod, billingAddress); + + await this.refreshAndSync(); + } + + /** + * Process families upgrade + */ + async upgradeToFamilies( + account: Account, + planDetails: PlanDetails, + paymentMethod: TokenizedPaymentMethod, + formValues: PaymentFormValues, + ): Promise { + const billingAddress = formValues.billingAddress; + + if (!formValues.organizationName) { + throw new Error("Organization name is required for families upgrade"); + } + + this.validatePaymentAndBillingInfo(paymentMethod, billingAddress); + + const passwordManagerSeats = this.getPasswordManagerSeats(planDetails); + const milestone3FeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM26462_Milestone_3, + ); + const familyPlan = milestone3FeatureEnabled + ? PlanType.FamiliesAnnually + : PlanType.FamiliesAnnually2025; + + const subscriptionInformation: SubscriptionInformation = { + organization: { + name: formValues.organizationName, + billingEmail: account.email, // Use account email as billing email + }, + plan: { + type: familyPlan, + passwordManagerSeats: passwordManagerSeats, + }, + payment: { + paymentMethod: [paymentMethod.token, this.getPaymentMethodType(paymentMethod)], + billing: { + country: billingAddress.country, + postalCode: billingAddress.postalCode, + }, + }, + }; + + const result = await this.organizationBillingService.purchaseSubscription( + subscriptionInformation, + account.id, + ); + await this.refreshAndSync(); + return result; + } + + private getPasswordManagerSeats(planDetails: PlanDetails): number { + return "users" in planDetails.details.passwordManager + ? planDetails.details.passwordManager.users + : 0; + } + + private validatePaymentAndBillingInfo( + paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod, + billingAddress: { country: string; postalCode: string }, + ): void { + if (!paymentMethod?.type) { + throw new Error("Payment method type is missing"); + } + + // Account credit does not require a token + if ( + paymentMethod.type !== NonTokenizablePaymentMethods.accountCredit && + !paymentMethod?.token + ) { + throw new Error("Payment method token is missing"); + } + + if (!billingAddress?.country || !billingAddress?.postalCode) { + throw new Error("Billing address information is incomplete"); + } + } + + private async refreshAndSync(): Promise { + await this.syncService.fullSync(true); + } + + private getPaymentMethodType( + paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod, + ): PaymentMethodType { + return paymentMethod.type === NonTokenizablePaymentMethods.accountCredit + ? PaymentMethodType.Credit + : tokenizablePaymentMethodToLegacyEnum(paymentMethod.type); + } +} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html new file mode 100644 index 00000000000..45a68136a00 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html @@ -0,0 +1,87 @@ +
      + + {{ upgradeToMessage() }} + +
      + @if (isFamiliesPlan) { + @if (userIsOwnerOfFreeOrg$ | async) { +
      + + {{ "formWillCreateNewFamiliesOrgMessage" | i18n }} + + {{ "upgradeNow" | i18n }} + + + +
      + } +
      + + {{ "organizationName" | i18n }} + + +

      + {{ "organizationNameDescription" | i18n }} +

      +
      + } +
      +
      {{ "paymentMethod" | i18n }}
      + +
      {{ "billingAddress" | i18n }}
      + + +
      +
      + +
      + + @if (isFamiliesPlan) { +

      + {{ "paymentChargedWithTrial" | i18n }} +

      + } +
      +
      + + + + + +
      +
      diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts new file mode 100644 index 00000000000..a824e850db6 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -0,0 +1,372 @@ +import { + AfterViewInit, + Component, + computed, + DestroyRef, + input, + OnInit, + output, + signal, + viewChild, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { + debounceTime, + Observable, + switchMap, + startWith, + from, + catchError, + of, + combineLatest, + map, + shareReplay, +} from "rxjs"; + +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; +import { CartSummaryComponent } from "@bitwarden/pricing"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, + getBillingAddressFromForm, +} from "../../../payment/components"; +import { + BillingAddress, + NonTokenizablePaymentMethods, + NonTokenizedPaymentMethod, + TokenizedPaymentMethod, +} from "../../../payment/types"; +import { BillingServicesModule } from "../../../services"; +import { BitwardenSubscriber } from "../../../types"; + +import { + PaymentFormValues, + PlanDetails, + UpgradePaymentService, +} from "./services/upgrade-payment.service"; + +/** + * Status types for upgrade payment dialog + */ +export const UpgradePaymentStatus = { + Back: "back", + Closed: "closed", + UpgradedToPremium: "upgradedToPremium", + UpgradedToFamilies: "upgradedToFamilies", +} as const; + +export type UpgradePaymentStatus = UnionOfValues; + +export type UpgradePaymentResult = { + status: UpgradePaymentStatus; + organizationId: string | null; +}; + +/** + * Parameters for upgrade payment + */ +export type UpgradePaymentParams = { + plan: PersonalSubscriptionPricingTierId | null; + subscriber: BitwardenSubscriber; +}; + +// 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-upgrade-payment", + imports: [ + DialogModule, + SharedModule, + CartSummaryComponent, + ButtonModule, + EnterPaymentMethodComponent, + EnterBillingAddressComponent, + BillingServicesModule, + ], + providers: [UpgradePaymentService], + templateUrl: "./upgrade-payment.component.html", +}) +export class UpgradePaymentComponent implements OnInit, AfterViewInit { + private readonly INITIAL_TAX_VALUE = 0; + protected readonly selectedPlanId = input.required(); + protected readonly account = input.required(); + protected goBack = output(); + protected complete = output(); + + readonly paymentComponent = viewChild.required(EnterPaymentMethodComponent); + readonly cartSummaryComponent = viewChild.required(CartSummaryComponent); + + protected formGroup = new FormGroup({ + organizationName: new FormControl("", [Validators.required]), + paymentForm: EnterPaymentMethodComponent.getFormGroup(), + billingAddress: EnterBillingAddressComponent.getFormGroup(), + }); + + protected readonly selectedPlan = signal(null); + protected readonly loading = signal(true); + protected readonly upgradeToMessage = signal(""); + // Cart Summary data + protected readonly passwordManager = computed(() => { + if (!this.selectedPlan()) { + return { name: "", cost: 0, quantity: 0, cadence: "year" as const }; + } + + return { + name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", + cost: this.selectedPlan()!.details.passwordManager.annualPrice, + quantity: 1, + cadence: "year" as const, + }; + }); + + protected hasEnoughAccountCredit$!: Observable; + private pricingTiers$!: Observable; + protected estimatedTax$!: Observable; + + constructor( + private i18nService: I18nService, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, + private toastService: ToastService, + private logService: LogService, + private destroyRef: DestroyRef, + private upgradePaymentService: UpgradePaymentService, + ) {} + + protected userIsOwnerOfFreeOrg$ = this.upgradePaymentService.userIsOwnerOfFreeOrg$; + protected adminConsoleRouteForOwnedOrganization$ = + this.upgradePaymentService.adminConsoleRouteForOwnedOrganization$; + + async ngOnInit(): Promise { + if (!this.isFamiliesPlan) { + this.formGroup.controls.organizationName.disable(); + } + + this.pricingTiers$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$(); + this.pricingTiers$ + .pipe( + catchError((error: unknown) => { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("error"), + message: this.i18nService.t("unexpectedError"), + }); + this.loading.set(false); + return of([]); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((plans) => { + const planDetails = plans.find((plan) => plan.id === this.selectedPlanId()); + + if (planDetails) { + this.selectedPlan.set({ + tier: this.selectedPlanId(), + details: planDetails, + }); + + this.upgradeToMessage.set( + this.i18nService.t(this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium"), + ); + } else { + this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null }); + return; + } + }); + + this.estimatedTax$ = this.formGroup.controls.billingAddress.valueChanges.pipe( + startWith(this.formGroup.controls.billingAddress.value), + debounceTime(1000), + // Only proceed when form has required values + switchMap(() => this.refreshSalesTax$()), + ); + + this.loading.set(false); + } + + ngAfterViewInit(): void { + const cartSummaryComponent = this.cartSummaryComponent(); + cartSummaryComponent.isExpanded.set(false); + + this.hasEnoughAccountCredit$ = combineLatest([ + cartSummaryComponent.total$, + this.upgradePaymentService.accountCredit$, + this.formGroup.controls.paymentForm.valueChanges.pipe( + startWith(this.formGroup.controls.paymentForm.value), + ), + ]).pipe( + map(([total, credit, currentFormValue]) => { + const selectedPaymentType = currentFormValue?.type; + if (selectedPaymentType !== NonTokenizablePaymentMethods.accountCredit) { + return true; // Not using account credit, so this check doesn't apply + } + return credit ? credit >= total : false; + }), + shareReplay({ bufferSize: 1, refCount: true }), // Cache the latest for two async pipes + ); + } + + protected get isPremiumPlan(): boolean { + return this.selectedPlanId() === PersonalSubscriptionPricingTierIds.Premium; + } + + protected get isFamiliesPlan(): boolean { + return this.selectedPlanId() === PersonalSubscriptionPricingTierIds.Families; + } + + protected submit = async (): Promise => { + if (!this.isFormValid()) { + this.formGroup.markAllAsTouched(); + return; + } + + if (!this.selectedPlan()) { + throw new Error("No plan selected"); + } + + try { + const result = await this.processUpgrade(); + if (result.status === UpgradePaymentStatus.UpgradedToFamilies) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("familiesUpdated"), + }); + } else if (result.status === UpgradePaymentStatus.UpgradedToPremium) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("premiumUpdated"), + }); + } + this.complete.emit(result); + } catch (error: unknown) { + this.logService.error("Upgrade failed:", error); + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("upgradeErrorMessage"), + }); + } + }; + + protected isFormValid(): boolean { + return this.formGroup.valid && this.paymentComponent().validate(); + } + + private async processUpgrade(): Promise { + if (!this.selectedPlan()) { + throw new Error("No plan selected"); + } + + const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); + const organizationName = this.formGroup.value?.organizationName; + + if (!billingAddress.country || !billingAddress.postalCode) { + throw new Error("Billing address is incomplete"); + } + + if (this.isFamiliesPlan && !organizationName) { + throw new Error("Organization name is required"); + } + + const paymentMethod = await this.getPaymentMethod(); + + if (!paymentMethod) { + throw new Error("Payment method is required"); + } + + const isTokenizedPayment = "token" in paymentMethod; + + if (!isTokenizedPayment && this.isFamiliesPlan) { + throw new Error("Tokenized payment is required for families plan"); + } + + return this.isFamiliesPlan + ? this.processFamiliesUpgrade( + organizationName!, + billingAddress, + paymentMethod as TokenizedPaymentMethod, + ) + : this.processPremiumUpgrade(paymentMethod, billingAddress); + } + + private async processFamiliesUpgrade( + organizationName: string, + billingAddress: BillingAddress, + paymentMethod: TokenizedPaymentMethod, + ): Promise { + const paymentFormValues: PaymentFormValues = { + organizationName, + billingAddress, + }; + + const response = await this.upgradePaymentService.upgradeToFamilies( + this.account(), + this.selectedPlan()!, + paymentMethod, + paymentFormValues, + ); + + return { status: UpgradePaymentStatus.UpgradedToFamilies, organizationId: response.id }; + } + + private async processPremiumUpgrade( + paymentMethod: NonTokenizedPaymentMethod | TokenizedPaymentMethod, + billingAddress: BillingAddress, + ): Promise { + await this.upgradePaymentService.upgradeToPremium(paymentMethod, billingAddress); + return { status: UpgradePaymentStatus.UpgradedToPremium, organizationId: null }; + } + + /** + * Get payment method based on selected type + * If using account credit, returns a non-tokenized payment method + * Otherwise, tokenizes the payment method from the payment component + */ + private async getPaymentMethod(): Promise< + NonTokenizedPaymentMethod | TokenizedPaymentMethod | null + > { + const isAccountCreditSelected = + this.formGroup.value?.paymentForm?.type === NonTokenizablePaymentMethods.accountCredit; + + if (isAccountCreditSelected) { + return { type: NonTokenizablePaymentMethods.accountCredit }; + } + + return await this.paymentComponent().tokenize(); + } + + // Create an observable for tax calculation + private refreshSalesTax$(): Observable { + if (this.formGroup.invalid || !this.selectedPlan()) { + return of(this.INITIAL_TAX_VALUE); + } + + const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); + if (!billingAddress.country || !billingAddress.postalCode) { + return of(this.INITIAL_TAX_VALUE); + } + return from( + this.upgradePaymentService.calculateEstimatedTax(this.selectedPlan()!, billingAddress), + ).pipe( + catchError((error: unknown) => { + this.logService.error("Tax calculation failed:", error); + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("taxCalculationError"), + }); + return of(this.INITIAL_TAX_VALUE); // Return default value on error + }), + ); + } +} diff --git a/apps/web/src/app/billing/individual/user-subscription.component.html b/apps/web/src/app/billing/individual/user-subscription.component.html index e801237467a..b7e490cdf2e 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.html +++ b/apps/web/src/app/billing/individual/user-subscription.component.html @@ -37,41 +37,63 @@
      {{ sub.expiration | date: "mediumDate" }}
      {{ "neverExpires" | i18n }}
      -
      -
      -
      -
      {{ "status" | i18n }}
      -
      +
      +
      +
      +
      {{ "plan" | i18n }}
      +
      {{ "premiumMembership" | i18n }}
      +
      +
      +
      {{ "status" | i18n }}
      +
      {{ (subscription && subscriptionStatus) || "-" }} - {{ - "pendingCancellation" | i18n - }} -
      -
      {{ "nextCharge" | i18n }}
      -
      - {{ - nextInvoice - ? (sub.subscription.periodEndDate | date: "mediumDate") + - ", " + - (nextInvoice.amount | currency: "$") - : "-" - }} -
      -
      -
      -
      - {{ "details" | i18n }} - - -
      - {{ i.name }} {{ i.quantity > 1 ? "×" + i.quantity : "" }} @ - {{ i.amount | currency: "$" }} - {{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }}