diff --git a/.claude/prompts/review-code.md b/.claude/prompts/review-code.md index 4e5f40b2743..1888b7cd503 100644 --- a/.claude/prompts/review-code.md +++ b/.claude/prompts/review-code.md @@ -1,25 +1,57 @@ -Please review this pull request with a focus on: +# Bitwarden Clients Repo Code Review - Careful Consideration Required -- Code quality and best practices -- Potential bugs or issues -- Security implications -- Performance considerations +## Think Twice Before Recommending -Note: The PR branch is already checked out in the current working directory. +Angular has multiple valid patterns. Before suggesting changes: -Provide a comprehensive review including: +- **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 -- Summary of changes since last review -- Critical issues found (be thorough) -- Suggested improvements (be thorough) -- Good practices observed (be concise - list only the most notable items without elaboration) -- Action items for the author -- Leverage collapsible
sections where appropriate for lengthy explanations or code snippets to enhance human readability +## Angular Modernization - Handle with Care -When reviewing subsequent commits: +**Control Flow Syntax (@if, @for, @switch):** -- Track status of previously identified issues (fixed/unfixed/reopened) -- Identify NEW problems introduced since last review -- Note if fixes introduced new issues +- 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 -IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively. +**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/.claude/skills/angular-modernization/SKILL.md b/.claude/skills/angular-modernization/SKILL.md new file mode 100644 index 00000000000..187f715bde2 --- /dev/null +++ b/.claude/skills/angular-modernization/SKILL.md @@ -0,0 +1,156 @@ +--- +name: angular-modernization +description: Modernizes Angular code such as components and directives to follow best practices using both automatic CLI migrations and Bitwarden-specific patterns. YOU must use this skill when someone requests modernizing Angular code. DO NOT invoke for general Angular discussions unrelated to modernization. +allowed-tools: Read, Write, Glob, Bash(npx ng generate:*) +--- + +# Angular Modernization + +Transforms legacy Angular components to modern architecture using a two-step approach: + +1. **Automated migrations** - Angular CLI schematics for standalone, control flow, and signals +2. **Bitwarden patterns** - ADR compliance, OnPush change detection, proper visibility, thin components + +## Workflow + +### Step 1: Run Angular CLI Migrations + +**⚠️ CRITICAL: ALWAYS use Angular CLI migrations when available. DO NOT manually migrate features that have CLI schematics.** + +Angular provides automated schematics that handle edge cases, update tests, and ensure correctness. Manual migration should ONLY be used for patterns not covered by CLI tools. + +**IMPORTANT:** + +- Always run the commands using `npx ng`. +- All the commands must be run on directories and NOT files. Use the `--path` option to target directories. +- Run migrations in order (some depend on others) + +#### 1. Standalone Components + +```bash +npx ng generate @angular/core:standalone --path= --mode=convert-to-standalone +``` + +NgModule-based → standalone architecture + +#### 2. Control Flow Syntax + +```bash +npx ng generate @angular/core:control-flow +``` + +`*ngIf`, `*ngFor`, `*ngSwitch` → `@if`, `@for`, `@switch` + +#### 3. Signal Inputs + +```bash +npx ng generate @angular/core:signal-input-migration +``` + +`@Input()` → signal inputs + +#### 4. Signal Outputs + +```bash +npx ng generate @angular/core:output-migration +``` + +`@Output()` → signal outputs + +#### 5. Signal Queries + +```bash +npx ng generate @angular/core:signal-queries-migration +``` + +`@ViewChild`, `@ContentChild`, etc. → signal queries + +#### 6. inject() Function + +```bash +npx ng generate @angular/core:inject-migration +``` + +Constructor injection → `inject()` function + +#### 7. Self-Closing Tag + +```bash +npx ng generate @angular/core:self-closing-tag +``` + +Updates templates to self-closing syntax + +#### 8. Unused Imports + +```bash +npx ng generate @angular/core:unused-imports +``` + +Removes unused imports + +### Step 2: Apply Bitwarden Patterns + +See [migration-patterns.md](migration-patterns.md) for detailed examples. + +1. Add OnPush change detection +2. Apply visibility modifiers (`protected` for template access, `private` for internal) +3. Convert local component state to signals +4. Keep service observables (don't convert to signals) +5. Extract business logic to services +6. Organize class members correctly +7. Update tests for standalone + +### Step 3: Validate + +- Fix linting and formatting using `npm run lint:fix` +- Run tests using `npm run test` + +If any errors occur, fix them accordingly. + +## Key Decisions + +### Signals vs Observables + +- **Signals** - Component-local state only (ADR-0027) +- **Observables** - Service state and cross-component communication (ADR-0003) +- Use `toSignal()` to bridge observables into signal-based components + +### Visibility + +- `protected` - Template-accessible members +- `private` - Internal implementation + +### Other Rules + +- Always add OnPush change detection +- No TypeScript enums (use const objects with type aliases per ADR-0025) +- No code regions (refactor instead) +- Thin components (business logic in services) + +## Validation Checklist + +Before completing migration: + +- [ ] OnPush change detection added +- [ ] Visibility modifiers applied (`protected`/`private`) +- [ ] Signals for component state, observables for service state +- [ ] Class members organized (see [migration-patterns.md](migration-patterns.md#class-member-organization)) +- [ ] Tests updated and passing +- [ ] No new TypeScript enums +- [ ] No code regions + +## References + +### Bitwarden ADRs + +- [ADR-0003: Observable Data Services](https://contributing.bitwarden.com/architecture/adr/observable-data-services) +- [ADR-0025: No TypeScript Enums](https://contributing.bitwarden.com/architecture/adr/no-enums) +- [ADR-0027: Angular Signals](https://contributing.bitwarden.com/architecture/adr/angular-signals) +- [Bitwarden Angular Style Guide](https://contributing.bitwarden.com/contributing/code-style/web/angular) + +### Angular Resources + +- [Angular Style Guide](https://angular.dev/style-guide) +- [Angular Migrations](https://angular.dev/reference/migrations) +- [Angular CLI Schematics](https://angular.dev/tools/cli/schematics) diff --git a/.claude/skills/angular-modernization/migration-patterns.md b/.claude/skills/angular-modernization/migration-patterns.md new file mode 100644 index 00000000000..284f90a410f --- /dev/null +++ b/.claude/skills/angular-modernization/migration-patterns.md @@ -0,0 +1,253 @@ +# Angular Migration Patterns Reference + +## Table of Contents + +- [Component Architecture](#component-architecture) +- [Dependency Injection](#dependency-injection) +- [Reactivity Patterns](#reactivity-patterns) +- [Template Syntax](#template-syntax) +- [Type Safety](#type-safety) + +## Component Architecture + +### Standalone Components + +Angular defaults to standalone components. Components should omit `standalone: true`, and any component specifying `standalone: false` SHALL be migrated to standalone. + +```typescript +@Component({ + selector: "app-user-profile", + imports: [CommonModule, ReactiveFormsModule, AsyncPipe], + templateUrl: "./user-profile.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UserProfileComponent {} +``` + +### Class Member Organization + +```typescript +@Component({...}) +export class MyComponent { + // 1. Inputs (public) + @Input() data: string; + + // 2. Outputs (public) + @Output() valueChange = new EventEmitter(); + + // 3. ViewChild/ContentChild + @ViewChild('template') template: TemplateRef; + + // 4. Injected dependencies (private/protected) + private userService = inject(UserService); + protected dialogService = inject(DialogService); + + // 5. Public properties + public formGroup: FormGroup; + + // 6. Protected properties (template-accessible) + protected isLoading = signal(false); + protected items$ = this.itemService.items$; + + // 7. Private properties + private cache = new Map(); + + // 8. Lifecycle hooks + ngOnInit() {} + + // 9. Public methods + public save() {} + + // 10. Protected methods (template-accessible) + protected handleClick() {} + + // 11. Private methods + private processData() {} +} +``` + +## Dependency Injection + +### Modern inject() Function + +**Before:** + +```typescript +constructor( + private userService: UserService, + private route: ActivatedRoute +) {} +``` + +**After:** + +```typescript +private userService = inject(UserService); +private route = inject(ActivatedRoute); +``` + +## Reactivity Patterns + +### Signals for Component State (ADR-0027) + +```typescript +// Local state +protected selectedFolder = signal(null); +protected isLoading = signal(false); + +// Derived state +protected hasSelection = computed(() => this.selectedFolder() !== null); +``` + +### Prefer computed() Over effect() + +Use `computed()` for derived values. Use `effect()` only for side effects (logging, analytics, DOM sync). + +**❌ Bad:** + +```typescript +constructor() { + effect(() => { + const id = this.selectedId(); + this.selectedItem.set(this.items().find(i => i.id === id) ?? null); + }); +} +``` + +**✅ Good:** + +```typescript +selectedItem = computed(() => this.items().find((i) => i.id === this.selectedId()) ?? null); +``` + +### Observables for Service Communication (ADR-0003) + +```typescript +// In component +protected folders$ = this.folderService.folders$; + +// Template +//
+ +// For explicit subscriptions +constructor() { + this.userService.user$ + .pipe(takeUntilDestroyed()) + .subscribe(user => this.handleUser(user)); +} +``` + +### Bridging Observables to Signals + +Use `toSignal()` to convert service observables to signals in components. Keep service state as observables (ADR-0003). + +**Before:** + +```typescript +private destroy$ = new Subject(); +users: User[] = []; + +ngOnInit() { + this.userService.users$.pipe(takeUntil(this.destroy$)) + .subscribe(users => this.users = users); +} + +ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); +} +``` + +**After:** + +```typescript +protected users = toSignal(this.userService.users$, { initialValue: [] }); +``` + +## Template Syntax + +### New Control Flow + +**Before:** + +```html +
+

{{ item.name }}

+
+Loading... +``` + +**After:** + +```html +@if (user$ | async; as user) { @for (item of user.items; track item.id) { +

{{ item.name }}

+} } @else { +

Loading...

+} +``` + +### Prefer Class/Style Bindings Over ngClass/ngStyle + +Use `[class.*]` and `[style.*]` bindings instead of `ngClass`/`ngStyle`. + +**❌ Bad:** + +```html +
+
+
+``` + +**✅ Good:** + +```html +
+
+
+``` + +## Type Safety + +### No TypeScript Enums (ADR-0025) + +**Before:** + +```typescript +enum CipherType { + Login = 1, + SecureNote = 2, +} +``` + +**After:** + +```typescript +export const CipherType = Object.freeze({ + Login: 1, + SecureNote: 2, +} as const); +export type CipherType = (typeof CipherType)[keyof typeof CipherType]; +``` + +### Reactive Forms + +```typescript +protected formGroup = new FormGroup({ + name: new FormControl('', { nonNullable: true }), + email: new FormControl('', { validators: [Validators.email] }), +}); +``` + +## Anti-Patterns to Avoid + +- ❌ Manually refactoring when CLI migrations exist +- ❌ Manual subscriptions without `takeUntilDestroyed()` +- ❌ TypeScript enums (use const objects per ADR-0025) +- ❌ Mixing constructor injection with `inject()` +- ❌ Signals in services shared with non-Angular code (ADR-0003) +- ❌ Business logic in components +- ❌ Code regions +- ❌ Converting service observables to signals (ADR-0003) +- ❌ Using `effect()` for derived state (use `computed()`) +- ❌ Using `ngClass`/`ngStyle` (use `[class.*]`/`[style.*]`) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8affac3387b..89fff27b217 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,7 +8,9 @@ apps/desktop/desktop_native @bitwarden/team-platform-dev apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-desktop-dev apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-desktop-dev +apps/desktop/desktop_native/macos_provider @bitwarden/team-autofill-desktop-dev apps/desktop/desktop_native/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 @@ -29,8 +31,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/src/app/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/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 @@ -46,12 +49,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 @@ -174,6 +177,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 diff --git a/.github/renovate.json5 b/.github/renovate.json5 index ae7c2b023cb..96e16776545 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -119,7 +119,7 @@ "rimraf", "ssh-encoding", "ssh-key", - "@storybook/web-components-webpack5", + "@storybook/web-components-vite", "tabbable", "tldts", "wait-on", @@ -187,7 +187,6 @@ "json5", "keytar", "libc", - "log", "lowdb", "mini-css-extract-plugin", "napi", @@ -198,7 +197,6 @@ "nx", "oo7", "oslog", - "parse5", "pin-project", "pkg", "postcss", @@ -216,6 +214,10 @@ "simplelog", "style-loader", "sysinfo", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", "ts-node", "ts-loader", "tsconfig-paths-webpack-plugin", @@ -230,6 +232,7 @@ "webpack-node-externals", "widestring", "windows", + "windows-core", "windows-future", "windows-registry", "zbus", @@ -254,6 +257,16 @@ 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 need to group all tokio-related packages together to avoid build errors caused by version incompatibilities. + groupName: "tokio", + matchPackageNames: ["bytes", "tokio", "tokio-util"], + }, { // 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. @@ -304,26 +317,24 @@ "@compodoc/compodoc", "@ng-select/ng-select", "@storybook/addon-a11y", - "@storybook/addon-actions", "@storybook/addon-designs", - "@storybook/addon-essentials", - "@storybook/addon-interactions", + "@storybook/addon-docs", "@storybook/addon-links", "@storybook/test-runner", "@storybook/addon-themes", "@storybook/angular", - "@storybook/manager-api", - "@storybook/theming", "@types/react", "autoprefixer", "bootstrap", "chromatic", "ngx-toastr", + "path-browserify", "react", "react-dom", "remark-gfm", "storybook", "tailwindcss", + "vite-tsconfig-paths", "zone.js", "@tailwindcss/container-queries", ], @@ -356,6 +367,8 @@ "@types/jsdom", "@types/papaparse", "@types/zxcvbn", + "async-trait", + "clap", "jsdom", "jszip", "oidc-client-ts", @@ -428,5 +441,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 4acab6b1c62..90c055a97b8 100644 --- a/.github/workflows/alert-ddg-files-modified.yml +++ b/.github/workflows/alert-ddg-files-modified.yml @@ -14,7 +14,7 @@ jobs: pull-requests: write steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/auto-branch-updater.yml b/.github/workflows/auto-branch-updater.yml index dcd031af0de..02176b3169e 100644 --- a/.github/workflows/auto-branch-updater.yml +++ b/.github/workflows/auto-branch-updater.yml @@ -30,7 +30,7 @@ jobs: run: echo "branch=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" - name: Checkout repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: 'eu-web-${{ steps.setup.outputs.branch }}' fetch-depth: 0 diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 1c805e8efbe..ab932c561ba 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -55,7 +55,7 @@ jobs: has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -94,7 +94,7 @@ jobs: working-directory: apps/browser steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -146,7 +146,7 @@ jobs: _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -193,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 @@ -218,6 +218,7 @@ 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-" @@ -225,6 +226,7 @@ jobs: 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: @@ -232,6 +234,8 @@ jobs: 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" @@ -250,7 +254,7 @@ jobs: artifact_name: "dist-opera-MV3" steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -268,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 @@ -332,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 @@ -369,7 +386,7 @@ jobs: _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -506,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 @@ -525,7 +542,7 @@ jobs: - build-safari steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -548,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 c2abbdf5e5c..964cbc834c5 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -59,7 +59,7 @@ jobs: has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -93,8 +93,8 @@ 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: [ @@ -114,7 +114,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -268,7 +268,7 @@ jobs: 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 @@ -311,7 +311,7 @@ jobs: _WIN_PKG_VERSION: 3.5 steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -482,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 @@ -490,7 +490,7 @@ 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 @@ -503,7 +503,7 @@ jobs: - 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 @@ -520,7 +520,7 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -535,7 +535,7 @@ 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 @@ -572,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 39549c4580c..efb94e44c7a 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -55,7 +55,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -88,7 +88,7 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: true @@ -173,11 +173,25 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: + fetch-depth: 1 ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false + - name: Free disk space for build + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/share/swift + sudo rm -rf /usr/local/.ghcup + sudo rm -rf /usr/share/miniconda + sudo rm -rf /usr/share/az_* + sudo rm -rf /usr/local/julia* + sudo rm -rf /usr/lib/mono + sudo rm -rf /usr/lib/heroku + sudo rm -rf /usr/local/aws-cli + sudo rm -rf /usr/local/aws-sam-cli + - name: Set up Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: @@ -185,10 +199,17 @@ jobs: cache-dependency-path: '**/package-lock.json' node-version: ${{ env._NODE_VERSION }} + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 + 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 flatpak flatpak-builder - name: Set up Snap run: sudo snap install snapcraft --classic @@ -225,7 +246,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: | @@ -241,51 +262,51 @@ jobs: env: PKG_CONFIG_ALLOW_CROSS: true PKG_CONFIG_ALL_STATIC: true - TARGET: musl + # Note: It is important that we use the release build because some compute heavy + # operations such as key derivation for oo7 on linux are too slow in debug mode run: | - rustup target add x86_64-unknown-linux-musl - node build.js --target=x86_64-unknown-linux-musl --release + node build.js --release - 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 @@ -298,13 +319,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 @@ -323,7 +343,7 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -335,17 +355,34 @@ jobs: cache-dependency-path: '**/package-lock.json' node-version: ${{ env._NODE_VERSION }} + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 + 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 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 @@ -372,7 +409,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: | @@ -388,10 +425,10 @@ jobs: env: PKG_CONFIG_ALLOW_CROSS: true PKG_CONFIG_ALL_STATIC: true - TARGET: musl + # Note: It is important that we use the release build because some compute heavy + # operations such as key derivation for oo7 on linux are too slow in debug mode run: | - rustup target add aarch64-unknown-linux-musl - node build.js --target=aarch64-unknown-linux-musl --release + node build.js --release - name: Check index.d.ts generated if: github.event_name == 'pull_request' && steps.cache.outputs.cache-hit != 'true' @@ -403,23 +440,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 @@ -430,7 +491,7 @@ jobs: NODE_OPTIONS: --max_old_space_size=4096 steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -442,6 +503,13 @@ jobs: cache-dependency-path: '**/package-lock.json' node-version: ${{ env._NODE_VERSION }} + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" + - name: Install AST run: dotnet tool install --global AzureSignTool --version 4.0.1 @@ -504,7 +572,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: | @@ -515,7 +583,9 @@ jobs: - name: Build Native Module if: steps.cache.outputs.cache-hit != 'true' working-directory: apps/desktop/desktop_native - run: node build.js cross-platform + env: + MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }} + run: node build.js cross-platform "$env:MODE" - name: Build run: npm run build @@ -570,7 +640,7 @@ jobs: -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 @@ -578,15 +648,15 @@ 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 + name: Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/nsis-web/Bitwarden-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@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 @@ -594,7 +664,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 @@ -602,7 +672,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 @@ -610,7 +680,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 @@ -618,7 +688,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 @@ -626,7 +696,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 @@ -634,7 +704,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 @@ -642,7 +712,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 @@ -650,7 +720,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 @@ -658,7 +728,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 @@ -666,7 +736,7 @@ 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 @@ -677,8 +747,8 @@ jobs: runs-on: windows-2022 needs: setup permissions: - contents: read - id-token: write + contents: read + id-token: write defaults: run: shell: pwsh @@ -689,9 +759,10 @@ jobs: NODE_OPTIONS: --max_old_space_size=4096 steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + 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 @@ -700,6 +771,13 @@ jobs: cache-dependency-path: '**/package-lock.json' node-version: ${{ env._NODE_VERSION }} + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" + - name: Install AST run: dotnet tool install --global AzureSignTool --version 4.0.1 @@ -759,7 +837,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: | @@ -770,7 +848,9 @@ jobs: - name: Build Native Module if: steps.cache.outputs.cache-hit != 'true' working-directory: apps/desktop/desktop_native - run: node build.js cross-platform + env: + MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }} + run: node build.js cross-platform "$env:MODE" - name: Build run: npm run build @@ -793,25 +873,27 @@ jobs: - 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" + 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\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@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + 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 @@ -819,7 +901,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-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/nsis-web/Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe @@ -827,7 +909,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-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx @@ -835,7 +917,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-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx @@ -843,7 +925,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-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z @@ -851,7 +933,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-Beta-${{ env._PACKAGE_VERSION }}-x64.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx @@ -859,7 +941,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-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx @@ -867,7 +949,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-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z @@ -875,7 +957,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-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx @@ -883,7 +965,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-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx @@ -891,7 +973,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-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z @@ -899,21 +981,20 @@ 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 }}-beta.yml - path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml + 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 }} @@ -923,7 +1004,7 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -935,8 +1016,20 @@ jobs: 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.2' + - name: Set up Node-gyp - run: python3 -m pip install setuptools + run: python -m pip install setuptools + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" - name: Print environment run: | @@ -945,17 +1038,18 @@ jobs: rustup show echo "GitHub ref: $GITHUB_REF" echo "GitHub event: $GITHUB_EVENT" + xcodebuild -showsdks - 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 @@ -1101,7 +1195,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: | @@ -1112,12 +1206,13 @@ jobs: - name: Build Native Module if: steps.cache.outputs.cache-hit != 'true' working-directory: apps/desktop/desktop_native - run: node build.js cross-platform + env: + MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }} + run: node build.js cross-platform "$MODE" - name: Build application (dev) run: npm run build - browser-build: name: Browser Build needs: setup @@ -1129,18 +1224,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 }} @@ -1150,7 +1244,7 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -1162,8 +1256,20 @@ jobs: 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.2' + - name: Set up Node-gyp - run: python3 -m pip install setuptools + run: python -m pip install setuptools + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" - name: Print environment run: | @@ -1172,17 +1278,18 @@ jobs: rustup show echo "GitHub ref: $GITHUB_REF" echo "GitHub event: $GITHUB_EVENT" + xcodebuild -showsdks - 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 @@ -1312,7 +1419,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: | @@ -1323,14 +1430,16 @@ jobs: - name: Build Native Module if: steps.cache.outputs.cache-hit != 'true' working-directory: apps/desktop/desktop_native - run: node build.js cross-platform + env: + MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }} + run: node build.js cross-platform "$MODE" - name: Build if: steps.build-cache.outputs.cache-hit != 'true' 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 @@ -1363,45 +1472,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 }} @@ -1411,7 +1519,7 @@ jobs: working-directory: apps/desktop steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -1423,8 +1531,20 @@ jobs: 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.2' + - name: Set up Node-gyp - run: python3 -m pip install setuptools + run: python -m pip install setuptools + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" - name: Print environment run: | @@ -1433,17 +1553,18 @@ jobs: rustup show echo "GitHub ref: $GITHUB_REF" echo "GitHub event: $GITHUB_EVENT" + xcodebuild -showsdks - 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 @@ -1581,7 +1702,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: | @@ -1592,14 +1713,16 @@ jobs: - name: Build Native Module if: steps.cache.outputs.cache-hit != 'true' working-directory: apps/desktop/desktop_native - run: node build.js cross-platform + env: + MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }} + run: node build.js cross-platform "$MODE" - name: Build if: steps.build-cache.outputs.cache-hit != 'true' 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 @@ -1642,14 +1765,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 @@ -1702,7 +1825,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 @@ -1731,13 +1854,13 @@ 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -1760,7 +1883,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 }} @@ -1771,7 +1894,6 @@ jobs: upload_sources: true upload_translations: false - check-failures: name: Check for failures if: always() @@ -1787,8 +1909,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: | @@ -1823,4 +1945,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 0ea3ad7af78..02ab7727c24 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -64,7 +64,7 @@ jobs: has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -144,7 +144,7 @@ jobs: _VERSION: ${{ needs.setup.outputs.version }} steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -174,7 +174,7 @@ jobs: echo "server_ref=$SERVER_REF" >> "$GITHUB_OUTPUT" - name: Check out Server repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: path: server repository: bitwarden/server @@ -204,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: | { @@ -215,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 @@ -273,7 +273,7 @@ jobs: - 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 }} @@ -307,7 +307,7 @@ jobs: 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 @@ -315,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' @@ -334,7 +334,7 @@ jobs: - 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 @@ -367,7 +367,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -390,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 }} diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index ccac9cb32bb..677d3dfc1df 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -31,7 +31,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 @@ -65,7 +65,7 @@ jobs: - 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') }} @@ -98,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 f195afa86da..5475c4dd692 100644 --- a/.github/workflows/crowdin-pull.yml +++ b/.github/workflows/crowdin-pull.yml @@ -49,14 +49,16 @@ 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: token: ${{ steps.app-token.outputs.token }} persist-credentials: false diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 5aa0918048b..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: @@ -373,10 +372,16 @@ jobs: - 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 diff --git a/.github/workflows/lint-crowdin-config.yml b/.github/workflows/lint-crowdin-config.yml index ee22a03963c..b0efeb50823 100644 --- a/.github/workflows/lint-crowdin-config.yml +++ b/.github/workflows/lint-crowdin-config.yml @@ -22,7 +22,7 @@ jobs: ] steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 1 persist-credentials: false @@ -45,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 21786339299..f2e6db96b30 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -31,7 +31,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false @@ -52,6 +52,7 @@ jobs: ! -path "*/Cargo.lock" \ ! -path "./apps/desktop/macos/*" \ ! -path "*/CLAUDE.md" \ + ! -path "*/SKILL.md" \ > tmp.txt diff <(sort .github/whitelist-capital-letters.txt) <(sort tmp.txt) @@ -94,16 +95,31 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + 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 @@ -118,10 +134,17 @@ jobs: 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@v2 + uses: taiki-e/install-action@81ee1d48d9194cdcab880cbdc7d36e87d39874cb # v2.62.45 with: - tool: cargo-deny + tool: cargo-deny@0.18.5 - name: Run cargo deny working-directory: ./apps/desktop/desktop_native diff --git a/.github/workflows/locales-lint.yml b/.github/workflows/locales-lint.yml index da79f9aa21f..8335d6aacad 100644 --- a/.github/workflows/locales-lint.yml +++ b/.github/workflows/locales-lint.yml @@ -17,11 +17,11 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Checkout base branch repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.event.pull_request.base.sha }} path: base diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 43361bc983d..0f01aa27899 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index bcae79d077e..8fcd1fe7c98 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -66,15 +66,17 @@ jobs: - name: Version output id: version-output env: - _INPUT_VERSION: ${{ inputs.version }} + INPUT_VERSION: ${{ inputs.version }} run: | - if [[ "$_INPUT_VERSION" == "latest" || "$_INPUT_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" else - echo "Release Version: $_INPUT_VERSION" - echo "version=$_INPUT_VERSION" >> "$GITHUB_OUTPUT" + echo "Release Version: $INPUT_VERSION" + echo "version=$INPUT_VERSION" >> "$GITHUB_OUTPUT" fi - name: Create GitHub deployment @@ -101,7 +103,7 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} steps: - name: Checkout repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false @@ -126,14 +128,14 @@ jobs: uses: samuelmeuli/action-snapcraft@fceeb3c308e76f3487e72ef608618de625fb7fe8 # v3.0.1 - name: Download artifacts - run: wget "https://github.com/bitwarden/clients/releases/download/cli-v$_PKG_VERSION/bw_$_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_$_PKG_VERSION_amd64.snap" --release stable + snapcraft upload "bw_${_PKG_VERSION}_amd64.snap" --release stable snapcraft logout choco: @@ -149,7 +151,7 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} steps: - name: Checkout repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false @@ -179,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$_PKG_VERSION/bitwarden-cli.$_PKG_VERSION.nupkg" -OutFile bitwarden-cli.$_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 @@ -201,10 +203,10 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} steps: - name: Checkout repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - + - name: Get Node version id: retrieve-node-version working-directory: ./ @@ -227,8 +229,8 @@ jobs: - name: Download and set up artifact run: | mkdir -p 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 + 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 2e9ba635e7a..3d512d49559 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -73,12 +73,11 @@ jobs: - name: Check Publish Version id: version env: - _INPUT_VERSION: ${{ inputs.version }} + INPUT_VERSION: ${{ inputs.version }} run: | - if [[ "$_INPUT_VERSION" == "latest" || "$_INPUT_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) + 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" @@ -87,7 +86,7 @@ jobs: echo "Tag name: $TAG_NAME" echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" else - VERSION="$_INPUT_VERSION" + VERSION="$INPUT_VERSION" TAG_NAME="desktop-v$VERSION" echo "Release Version: $VERSION" @@ -100,9 +99,9 @@ jobs: - name: Get Version Channel id: release_channel env: - _VERSION: ${{ steps.version.outputs.version }} + VERSION: ${{ steps.version.outputs.version }} run: | - case "${_VERSION}" in + case "${VERSION}" in *"alpha"*) echo "channel=alpha" >> "$GITHUB_OUTPUT" echo "[!] We do not yet support 'alpha'" @@ -192,22 +191,6 @@ jobs: --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 @@ -221,7 +204,7 @@ jobs: _RELEASE_TAG: ${{ needs.setup.outputs.tag_name }} steps: - name: Checkout Repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false @@ -251,14 +234,14 @@ jobs: - name: Download artifacts working-directory: apps/desktop/dist - run: wget "https://github.com/bitwarden/clients/releases/download/$_RELEASE_TAG/bitwarden_$_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_$_PKG_VERSION_amd64.snap" --release stable + snapcraft upload "bitwarden_${_PKG_VERSION}_amd64.snap" --release stable snapcraft logout working-directory: apps/desktop/dist @@ -275,7 +258,7 @@ jobs: _RELEASE_TAG: ${{ needs.setup.outputs.tag_name }} steps: - name: Checkout Repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false @@ -312,7 +295,7 @@ jobs: - name: Download artifacts working-directory: apps/desktop/dist - run: Invoke-WebRequest -Uri "https://github.com/bitwarden/clients/releases/download/$_RELEASE_TAG/bitwarden.$_PKG_VERSION.nupkg" -OutFile "bitwarden.$_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' }} @@ -332,12 +315,12 @@ jobs: _RELEASE_TAG: ${{ needs.setup.outputs.tag_name }} steps: - name: Checkout repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + 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" @@ -345,15 +328,15 @@ jobs: - name: Download MacOS App Store build number working-directory: apps/desktop - run: wget "https://github.com/bitwarden/clients/releases/download/$_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 @@ -379,32 +362,32 @@ 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 }} - _RELEASE_NOTES: ${{ inputs.release_notes }} - _PUBLISH_TYPE: ${{ inputs.publish_type }} + CHANGELOG: ${{ inputs.release_notes }} + PUBLISH_TYPE: ${{ inputs.publish_type }} working-directory: apps/desktop run: | BUILD_NUMBER=$(jq -r '.buildNumber' macos-build-number.json) - CHANGELOG="$_RELEASE_NOTES" - IS_DRY_RUN="$_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:"$PKG_VERSION" \ + app_version:"${_PKG_VERSION}" \ build_number:"$BUILD_NUMBER" \ changelog:"$CHANGELOG" \ dry_run:"$IS_DRY_RUN" diff --git a/.github/workflows/publish-web.yml b/.github/workflows/publish-web.yml index 6bf2b282b38..fb1de5a1bc5 100644 --- a/.github/workflows/publish-web.yml +++ b/.github/workflows/publish-web.yml @@ -28,7 +28,7 @@ jobs: contents: read steps: - name: Checkout repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false @@ -74,7 +74,7 @@ jobs: echo "Github Release Option: $_RELEASE_OPTION" - name: Checkout repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false @@ -157,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: @@ -172,27 +171,37 @@ 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 + - 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 }} + owner: ${{ github.repository_owner }} + repositories: self-host + + - name: Trigger Bitwarden lite build uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + 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 39f54a6e2db..ff5fb669faf 100644 --- a/.github/workflows/release-browser.yml +++ b/.github/workflows/release-browser.yml @@ -28,7 +28,7 @@ jobs: release_version: ${{ steps.version.outputs.version }} steps: - name: Checkout repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false @@ -61,7 +61,7 @@ jobs: contents: read steps: - name: Checkout repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false @@ -132,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 d5013770476..08045b8d3c7 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -29,7 +29,7 @@ jobs: release_version: ${{ steps.version.outputs.version }} steps: - name: Checkout repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false @@ -80,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 9239914aeff..2239cb1268f 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -31,7 +31,7 @@ jobs: release_channel: ${{ steps.release_channel.outputs.channel }} steps: - name: Checkout repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false @@ -58,9 +58,9 @@ jobs: - name: Get Version Channel id: release_channel env: - _VERSION: ${{ steps.version.outputs.version }} + VERSION: ${{ steps.version.outputs.version }} run: | - case "$_VERSION" in + case "$VERSION" in *"alpha"*) echo "channel=alpha" >> "$GITHUB_OUTPUT" echo "[!] We do not yet support 'alpha'" @@ -96,10 +96,18 @@ jobs: env: PKG_VERSION: ${{ steps.version.outputs.version }} working-directory: apps/desktop/artifacts - run: mv "Bitwarden-$PKG_VERSION-universal.pkg" "Bitwarden-$PKG_VERSION-universal.pkg.archive" + run: mv "Bitwarden-${PKG_VERSION}-universal.pkg" "Bitwarden-${PKG_VERSION}-universal.pkg.archive" + + - name: Rename .tar.gz to include version + env: + PKG_VERSION: ${{ steps.version.outputs.version }} + working-directory: apps/desktop/artifacts + run: | + mv "bitwarden_desktop_x64.tar.gz" "bitwarden_${PKG_VERSION}_x64.tar.gz" + mv "bitwarden_desktop_arm64.tar.gz" "bitwarden_${PKG_VERSION}_arm64.tar.gz" - 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 }} @@ -107,8 +115,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 8c8f8ed86af..fc0ac340234 100644 --- a/.github/workflows/release-web.yml +++ b/.github/workflows/release-web.yml @@ -25,7 +25,7 @@ jobs: tag_version: ${{ steps.version.outputs.tag }} steps: - name: Checkout repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false @@ -52,8 +52,7 @@ jobs: release: name: Create GitHub Release runs-on: ubuntu-22.04 - needs: - - setup + needs: setup permissions: contents: write steps: @@ -82,14 +81,14 @@ jobs: - name: Rename assets working-directory: apps/web/artifacts env: - _RELEASE_VERSION: ${{ needs.setup.outputs.release_version }} + RELEASE_VERSION: ${{ needs.setup.outputs.release_version }} run: | - mv web-*-selfhosted-COMMERCIAL.zip "web-$_RELEASE_VERSION-selfhosted-COMMERCIAL.zip" - mv web-*-selfhosted-open-source.zip "web-$_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 ce9b70118b2..0a343be878c 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -29,7 +29,7 @@ on: default: false target_ref: default: "main" - description: "Branch/Tag to target for cut" + description: "Branch/Tag to target for cut (ignored if not cutting rc)" required: true type: string version_number_override: @@ -97,16 +97,17 @@ 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 current branch - name: Check out branch - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: - ref: main + ref: ${{ github.ref }} token: ${{ steps.app-token.outputs.token }} persist-credentials: true @@ -462,14 +463,15 @@ 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 and pushing new branch - name: Check out target ref - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ inputs.target_ref }} token: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/review-code.yml b/.github/workflows/review-code.yml index 46309af38ea..0e0597fccf0 100644 --- a/.github/workflows/review-code.yml +++ b/.github/workflows/review-code.yml @@ -15,6 +15,7 @@ jobs: 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/stale-bot.yml b/.github/workflows/stale-bot.yml index 246e0d48c5d..2a27a9b3101 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@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 + uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 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 fb31a93d51f..c8f4c959c52 100644 --- a/.github/workflows/test-browser-interactions.yml +++ b/.github/workflows/test-browser-interactions.yml @@ -18,7 +18,7 @@ jobs: id-token: write steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 persist-credentials: false @@ -49,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: @@ -73,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@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0 + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 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 d468ca74ed6..faee7220e7b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false @@ -62,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 @@ -71,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 @@ -103,7 +103,7 @@ jobs: sudo apt-get install -y gnome-keyring dbus-x11 - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false @@ -137,7 +137,7 @@ jobs: runs-on: macos-14 steps: - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false @@ -148,7 +148,7 @@ jobs: components: llvm-tools - name: Cache cargo registry - uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2.7.5 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: "apps/desktop/desktop_native -> target" @@ -160,7 +160,7 @@ jobs: run: cargo llvm-cov --all-features --lcov --output-path lcov.info --workspace --no-cfg-coverage - name: Upload test coverage - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: rust-coverage path: ./apps/desktop/desktop_native/lcov.info @@ -173,24 +173,24 @@ jobs: - rust-coverage steps: - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + 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 fee34d14e83..65f004149de 100644 --- a/.github/workflows/version-auto-bump.yml +++ b/.github/workflows/version-auto-bump.yml @@ -31,14 +31,15 @@ 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: main token: ${{ steps.app-token.outputs.token }} diff --git a/.storybook/main.ts b/.storybook/main.ts index d3811bb178d..e1f3561a1b7 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -28,15 +28,13 @@ const config: StorybookConfig = { ], addons: [ getAbsolutePath("@storybook/addon-links"), - getAbsolutePath("@storybook/addon-essentials"), getAbsolutePath("@storybook/addon-a11y"), getAbsolutePath("@storybook/addon-designs"), - getAbsolutePath("@storybook/addon-interactions"), getAbsolutePath("@storybook/addon-themes"), { // @storybook/addon-docs is part of @storybook/addon-essentials - // eslint-disable-next-line storybook/no-uninstalled-addons - name: "@storybook/addon-docs", + + name: getAbsolutePath("@storybook/addon-docs"), options: { mdxPluginOptions: { mdxCompileOptions: { @@ -60,6 +58,10 @@ const config: StorybookConfig = { webpackFinal: async (config, { configType }) => { if (config.resolve) { config.resolve.plugins = [new TsconfigPathsPlugin()] as any; + config.resolve.fallback = { + ...config.resolve.fallback, + path: require.resolve("path-browserify"), + }; } return config; }, diff --git a/.storybook/manager.js b/.storybook/manager.js index e0ec04fd375..7ba923a0b2d 100644 --- a/.storybook/manager.js +++ b/.storybook/manager.js @@ -1,5 +1,5 @@ -import { addons } from "@storybook/manager-api"; -import { create } from "@storybook/theming/create"; +import { addons } from "storybook/manager-api"; +import { create } from "storybook/theming"; const lightTheme = create({ base: "light", 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 2480eef505d..266cf79d8b1 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -4,6 +4,7 @@ import { componentWrapperDecorator } from "@storybook/angular"; import type { Preview } from "@storybook/angular"; import docJson from "../documentation.json"; + setCompodocJson(docJson); const wrapperDecorator = componentWrapperDecorator((story) => { @@ -27,7 +28,7 @@ const preview: Preview = { ], parameters: { a11y: { - element: "#storybook-root", + context: "#storybook-root", }, controls: { matchers: { @@ -48,7 +49,7 @@ const preview: Preview = { }, }, backgrounds: { - disable: true, + disabled: true, }, }, tags: ["autodocs"], diff --git a/angular.json b/angular.json index 87ee7dc57b2..e1cc2aad82a 100644 --- a/angular.json +++ b/angular.json @@ -220,5 +220,31 @@ } } } + }, + "schematics": { + "@schematics/angular:component": { + "type": "component" + }, + "@schematics/angular:directive": { + "type": "directive" + }, + "@schematics/angular:service": { + "type": "service" + }, + "@schematics/angular:guard": { + "typeSeparator": "." + }, + "@schematics/angular:interceptor": { + "typeSeparator": "." + }, + "@schematics/angular:module": { + "typeSeparator": "." + }, + "@schematics/angular:pipe": { + "typeSeparator": "." + }, + "@schematics/angular:resolver": { + "typeSeparator": "." + } } } diff --git a/apps/browser/package.json b/apps/browser/package.json index 744b53688b2..cf2be624a22 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,11 +1,13 @@ { "name": "@bitwarden/browser", - "version": "2025.10.1", + "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/scripts/update-manifest-dev.sh b/apps/browser/scripts/update-manifest-dev.sh new file mode 100644 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 35d21b59be9..452cbafe6d5 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": "مرحبًا بعودتك" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "عند قفل النظام" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "عند إعادة تشغيل المتصفح" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "تم تعديل العنصر" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "هل تريد حقاً أن ترسل إلى سلة المهملات؟" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "معرفة المزيد" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "مفتاح المصادقة (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 جيغابايت وحدة تخزين مشفرة لمرفقات الملفات." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "الوصول الطارئ." }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "اسأل عن القياسات الحيوية عند الإطلاق" }, - "premiumRequired": { - "message": "حساب البريميوم مطلوب" - }, - "premiumRequiredDesc": { - "message": "هذه المِيزة متاحة فقط للعضوية المميزة." - }, "authenticationTimeout": { "message": "مهلة المصادقة" }, @@ -1623,6 +1680,9 @@ "selfHostedEnvFormInvalid": { "message": "يجب عليك إضافة رابط الخادم الأساسي أو على الأقل بيئة مخصصة." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "بيئة مخصصة" }, @@ -1676,9 +1736,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": "عرض الهويات كاقتراحات" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "سنة الإنتهاء" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "تاريخ الانتهاء" }, @@ -2391,6 +2475,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": "تعيين كلمة مرور رئيسية" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "خطأ فك التشفير" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "تعذر على بتواردن فك تشفير العنصر (العناصر) المدرجة أدناه." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 2c9a496a95c..c4197f831d3 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" }, @@ -582,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": "Arxivi istifadə etmək üçün premium üzvlük tələb olunur." + }, "edit": { "message": "Düzəliş et" }, "view": { "message": "Bax" }, + "viewAll": { + "message": "Hamısına bax" + }, + "showAll": { + "message": "Hamısını göstər" + }, + "viewLess": { + "message": "Daha azına bax" + }, "viewLogin": { "message": "Girişə bax" }, @@ -619,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" @@ -679,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" @@ -790,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" }, @@ -910,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" @@ -1028,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?" }, @@ -1221,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." @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Daha ətraflı" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Kimlik doğrulayıcı açarı (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "Fayl qoşmaları üçün 1 GB şifrələnmiş anbar sahəsi" }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Fövqəladə hal erişimi." }, @@ -1505,17 +1568,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.", @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Son istifadə ili" }, + "monthly": { + "message": "ay" + }, "expiration": { "message": "Bitmə vaxtı" }, @@ -2113,7 +2197,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", @@ -2183,7 +2267,7 @@ "message": "Təzəlikcə heç nə yaratmamısınız" }, "remove": { - "message": "Çıxart" + "message": "Xaric et" }, "default": { "message": "İlkin" @@ -2391,6 +2475,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" }, @@ -3022,10 +3109,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." @@ -3181,7 +3268,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" @@ -3240,6 +3327,9 @@ "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." }, @@ -3684,7 +3774,7 @@ "message": "Cihazları idarə et" }, "currentSession": { - "message": "Hazırkı seans" + "message": "Hazırkı sessiya" }, "mobile": { "message": "Mobil", @@ -3881,7 +3971,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": { @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Premium ilə şikayət göndərmə, fövqəladə hal erişimi və daha çox təhlükəsizlik özəlliyinin kilidini açın." + }, "freeOrgsCannotUseAttachments": { "message": "Ödənişsiz təşkilatlar qoşmaları istifadə edə bilməz" }, @@ -4926,6 +5028,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": { @@ -5651,7 +5763,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." @@ -5718,8 +5830,56 @@ "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": "Qabaqcıl təhlükəsizlik özəlliklərinin kilidini aç" + }, + "unlockAdvancedSecurityDesc": { + "message": "Premium abunəlik, güvəndə qalmağınız və nəzarəti əlinizdə saxlamağınız üçün sizə daha çox alət verir" + }, + "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 f9fd41cf6e7..806aedfab27 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": "З вяртаннем" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "Пры блакіраванні сістэмы" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Пры перазапуску браўзера" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "Элемент адрэдагаваны" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Вы сапраўды хочаце адправіць гэты элемент у сметніцу?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Даведацца больш" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Ключ аўтэнтыфікацыі (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 ГБ зашыфраванага сховішча для далучаных файлаў." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Экстранны доступ." }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "Пытацца пра біяметрыю пры запуску" }, - "premiumRequired": { - "message": "Патрабуецца прэміяльны статус" - }, - "premiumRequiredDesc": { - "message": "Для выкарыстання гэтай функцыі патрабуецца прэміяльны статус." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1623,6 +1680,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": "Карыстальніцкае асяроддзе" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Год завяршэння" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Тэрмін дзеяння" }, @@ -2391,6 +2475,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": "Прызначыць асноўны пароль" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 d8c288d9fca..76b20f8bd2e 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": "Добре дошли отново" }, @@ -582,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Архивираните елементи са изключени от общите резултати при търсене и от предложенията за автоматично попълване. Наистина ли искате да архивирате този елемент?" }, + "upgradeToUseArchive": { + "message": "За да се възползвате от архивирането, трябва да ползвате платен абонамент." + }, "edit": { "message": "Редактиране" }, "view": { "message": "Преглед" }, + "viewAll": { + "message": "Показване на всички" + }, + "showAll": { + "message": "Показване на всички" + }, + "viewLess": { + "message": "Преглед на по-малко" + }, "viewLogin": { "message": "Преглед на елемента за вписване" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "При заключване на системата" }, + "onIdle": { + "message": "При бездействие на системата" + }, + "onSleep": { + "message": "При заспиване на системата" + }, "onRestart": { "message": "При повторно пускане на браузъра" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "Елементът е редактиран" }, + "savedWebsite": { + "message": "Запазен уеб сайт" + }, + "savedWebsites": { + "message": "Запазени уеб сайтове ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Наистина ли искате да изтриете елемента?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Научете повече" }, + "migrationsFailed": { + "message": "Възникна грешка при обновяването на настройките за шифроване." + }, + "updateEncryptionSettingsTitle": { + "message": "Обновете настройките си за шифроване" + }, + "updateEncryptionSettingsDesc": { + "message": "Новите препоръчани настройки за шифроване ще подобрят сигурността на акаунта Ви. Въведете главната си парола, за да ги обновите сега." + }, + "confirmIdentityToContinue": { + "message": "Потвърдете самоличността си, за да продължите" + }, + "enterYourMasterPassword": { + "message": "Въведете главната си парола" + }, + "updateSettings": { + "message": "Обновяване на настройките" + }, + "later": { + "message": "По-късно" + }, "authenticatorKeyTotp": { "message": "Удостоверителен ключ (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB пространство за файлове, които се шифрират." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ пространство за файлове, които се шифрират.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Авариен достъп" }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "Питане за биометрични данни при пускане" }, - "premiumRequired": { - "message": "Изисква се платен абонамент" - }, - "premiumRequiredDesc": { - "message": "За да се възползвате от тази възможност, трябва да ползвате платен абонамент." - }, "authenticationTimeout": { "message": "Време на давност за удостоверяването" }, @@ -1623,6 +1680,9 @@ "selfHostedEnvFormInvalid": { "message": "Трябва да добавите или основния адрес на сървъра, или поне една специална среда." }, + "selfHostedEnvMustUseHttps": { + "message": "Адресите трябва да ползват HTTPS." + }, "customEnvironment": { "message": "Специална среда" }, @@ -1676,9 +1736,30 @@ "turnOffAutofill": { "message": "Изключване на автоматичното попълване" }, + "confirmAutofill": { + "message": "Потвърждаване на автоматичното попълване" + }, + "confirmAutofillDesc": { + "message": "Този уеб сайт не съвпада със запазените данни за вписване. Преди да попълните данните си, уверете се, че имате вяра на сайта." + }, "showInlineMenuLabel": { "message": "Показване на предложения за авт. попълване на полетата във формуляри" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Как Битуорден защитава данните Ви от измами?" + }, + "currentWebsite": { + "message": "Текущ уеб сайт" + }, + "autofillAndAddWebsite": { + "message": "Автоматично попълване и добавяне на този уеб сайт" + }, + "autofillWithoutAdding": { + "message": "Автоматично попълване без добавяне" + }, + "doNotAutofill": { + "message": "Да не се попълва автоматично" + }, "showInlineMenuIdentitiesLabel": { "message": "Показване на идентичности като предложения" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Година на изтичане" }, + "monthly": { + "message": "месец" + }, "expiration": { "message": "Изтичане" }, @@ -2391,6 +2475,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Тази страница пречи на работата на Битуорден. Вмъкнатото меню на Битуорден е временно изключено, като мярка за сигурност." + }, "setMasterPassword": { "message": "Задаване на главна парола" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Грешка при дешифриране" }, + "errorGettingAutoFillData": { + "message": "Грешка при получаването на данните за автоматично попълване" + }, "couldNotDecryptVaultItemsBelow": { "message": "Битоурден не може да дешифрира елементите от трезора посочени по-долу." }, @@ -4011,6 +4101,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": "Превключване на страничната навигация" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Премиум" }, + "unlockFeaturesWithPremium": { + "message": "Отключете докладите, аварийния достъп и още функционалности свързани със сигурността, с платения план." + }, "freeOrgsCannotUseAttachments": { "message": "Безплатните организации не могат да използват прикачени файлове" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 1b8c289f717..4845282e825 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", @@ -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?" @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "সিস্টেম লকে" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "ব্রাউজার পুনঃসূচনাই" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "সম্পাদিত বস্তু" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "আপনি কি সত্যিই আবর্জনাতে পাঠাতে চান?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "আরও জানুন" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "প্রমাণীকরণকারী কী (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "ফাইল সংযুক্তির জন্য ১ জিবি এনক্রিপ্টেড স্থান।" }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "প্রিমিয়াম আবশ্যক" - }, - "premiumRequiredDesc": { - "message": "এই বৈশিষ্ট্যটি ব্যবহার করতে একটি প্রিমিয়াম সদস্যতার প্রয়োজন।" - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1623,6 +1680,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": "পছন্দসই পরিবেশ" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "মেয়াদোত্তীর্ণ বছর" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "মেয়াদোত্তীর্ণতা" }, @@ -2391,6 +2475,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": "মূল পাসওয়ার্ড ধার্য করুন" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 8cc0d947199..cff1dd150cb 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Learn more" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 4483967ab33..ff0e2de51af 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" }, @@ -582,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" }, @@ -790,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" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Més informació" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Clau d'autenticació (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB d'emmagatzematge xifrat per als fitxers adjunts." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Accés d’emergència." }, @@ -1505,12 +1568,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ó" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Any de venciment" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Caducitat" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "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ó." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 b9383416eb4..fa8dc426c05 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" }, @@ -582,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" }, @@ -790,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" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Dozvědět se více" }, + "migrationsFailed": { + "message": "Došlo k chybě při aktualizaci nastavení šifrování." + }, + "updateEncryptionSettingsTitle": { + "message": "Aktualizovat nastavení šifrování" + }, + "updateEncryptionSettingsDesc": { + "message": "Nové doporučené nastavení šifrování zlepší bezpečnost Vašeho účtu. Pokud chcete aktualizovat nyní, zadejte hlavní heslo." + }, + "confirmIdentityToContinue": { + "message": "Pro pokračování potvrďte svou identitu" + }, + "enterYourMasterPassword": { + "message": "Zadejte své hlavní heslo" + }, + "updateSettings": { + "message": "Aktualizovat nastavení" + }, + "later": { + "message": "Později" + }, "authenticatorKeyTotp": { "message": "Autentizační klíč (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB šifrovaného úložiště pro přílohy." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ šifrovaného úložiště pro přílohy.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Nouzový přístup" }, @@ -1505,12 +1568,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í" }, @@ -1623,6 +1680,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í" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Rok expirace" }, + "monthly": { + "message": "měsíčně" + }, "expiration": { "message": "Expirace" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "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." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,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" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 c18633c281c..45f91f5ccc7 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "wrth ailgychwyn y porwr" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Dysgu mwy" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "Storfa 1GB wedi'i hamgryptio ar gyfer atodiadau ffeiliau." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Blwyddyn dod i ben" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Dod i ben" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 0f92552c9c1..50d79ff1f9d 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" }, @@ -582,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" }, @@ -790,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" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Lær mere" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Autentificeringsnøgle (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB krypteret lager til vedhæftede filer." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Nødadgang" }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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ø" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Udløbsår" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Udløb" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Dekrypteringsfejl" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden kunne ikke dekryptere boks-emne(r) anført nedenfor." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,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" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 411b73be447..969f8c63de3 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" @@ -559,7 +562,7 @@ "description": "Verb" }, "unArchive": { - "message": "Nicht mehr archivieren" + "message": "Wiederherstellen" }, "itemsInArchive": { "message": "Einträge im Archiv" @@ -571,10 +574,10 @@ "message": "Archivierte Einträge werden hier angezeigt und von allgemeinen Suchergebnissen sowie Vorschlägen zum automatischen Ausfüllen ausgeschlossen." }, "itemWasSentToArchive": { - "message": "Eintrag wurde ins Archiv verschoben" + "message": "Eintrag wurde archiviert" }, "itemUnarchived": { - "message": "Eintrag wird nicht mehr archiviert" + "message": "Eintrag wurde wiederhergestellt" }, "archiveItem": { "message": "Eintrag archivieren" @@ -582,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Archivierte Einträge werden von allgemeinen Suchergebnissen sowie Vorschlägen zum automatischen Ausfüllen ausgeschlossen. Bist du sicher, dass du diesen Eintrag archivieren möchtest?" }, + "upgradeToUseArchive": { + "message": "Für die Nutzung des Archivs ist eine Premium-Mitgliedschaft erforderlich." + }, "edit": { "message": "Bearbeiten" }, "view": { "message": "Anzeigen" }, + "viewAll": { + "message": "Alles anzeigen" + }, + "showAll": { + "message": "Alles anzeigen" + }, + "viewLess": { + "message": "Weniger anzeigen" + }, "viewLogin": { "message": "Zugangsdaten anzeigen" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "Wenn System gesperrt" }, + "onIdle": { + "message": "Bei Systeminaktivität" + }, + "onSleep": { + "message": "Im Standby" + }, "onRestart": { "message": "Bei Browser-Neustart" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "Eintrag gespeichert" }, + "savedWebsite": { + "message": "Gespeicherte Website" + }, + "savedWebsites": { + "message": "Gespeicherte Websites ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Soll dieser Eintrag wirklich in den Papierkorb verschoben werden?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Erfahre mehr" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authentifizierungsschlüssel (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB verschlüsselter Speicherplatz für Dateianhänge." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ verschlüsselter Speicher für Dateianhänge.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Notfallzugriff." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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. Stelle sicher, dass dies eine vertrauenswürdige Website ist, bevor du deine Zugangsdaten eingibst." + }, "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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Ablaufjahr" }, + "monthly": { + "message": "Monatlich" + }, "expiration": { "message": "Gültig bis" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "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." }, @@ -4011,6 +4101,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 ist auf „Exakte Übereinstimmung“ eingestellt. Die aktuelle Website stimmt nicht genau mit den gespeicherten Zugangsdaten für diesen Eintrag überein." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Seitennavigation umschalten" }, @@ -4842,6 +4941,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" }, @@ -4926,6 +5028,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": { @@ -5583,7 +5695,7 @@ "message": "Phishing-Versuch erkannt" }, "phishingPageSummary": { - "message": "Die Website, die du versuchst zu öffnen, ist eine bekannte böswillige Website und ein Sicherheitsrisiko." + "message": "Die Website, die du öffnen möchtest, ist als böswillige Website bekannt und stellt ein Sicherheitsrisiko dar." }, "phishingPageCloseTabV2": { "message": "Diesen Tab schließen" @@ -5718,8 +5830,56 @@ "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 025a66c5cde..2be53da65ec 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": "Καλώς ήρθατε" }, @@ -551,18 +554,18 @@ "message": "Επαναφορά αναζήτησης" }, "archiveNoun": { - "message": "Archive", + "message": "Αρχειοθήκη", "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "Αρχειοθέτηση", "description": "Verb" }, "unArchive": { "message": "Unarchive" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Στοιχεία στην αρχειοθήκη" }, "noItemsInArchive": { "message": "No items in archive" @@ -582,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": "Δεν υπάρχουν στοιχεία στη λίστα." @@ -790,6 +805,12 @@ "onLocked": { "message": "Κατά το Κλείδωμα Συστήματος" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Κατά την Επανεκκίνηση του Browser" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "Το αντικείμενο αποθηκεύτηκε" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το στοιχείο;" }, @@ -1213,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": { @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Μάθετε περισσότερα" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Κλειδί επαλήθευσης (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB κρυπτογραφημένο αποθηκευτικό χώρο για συνημμένα αρχεία." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Πρόσβαση έκτακτης ανάγκης." }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "Ζητήστε βιομετρικά κατά την εκκίνηση" }, - "premiumRequired": { - "message": "Απαιτείται το Premium" - }, - "premiumRequiredDesc": { - "message": "Για να χρησιμοποιήσετε αυτή τη λειτουργία, απαιτείται συνδρομή Premium." - }, "authenticationTimeout": { "message": "Χρονικό όριο επαλήθευσης" }, @@ -1549,7 +1606,7 @@ "message": "Read security key" }, "readingPasskeyLoading": { - "message": "Reading passkey..." + "message": "Ανάγνωση κλειδιού πρόσβασης..." }, "passkeyAuthenticationFailed": { "message": "Passkey authentication failed" @@ -1623,6 +1680,9 @@ "selfHostedEnvFormInvalid": { "message": "Πρέπει να προσθέσετε είτε το βασικό URL του διακομιστή ή τουλάχιστον ένα προσαρμοσμένο περιβάλλον." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Προσαρμοσμένο περιβάλλον" }, @@ -1676,9 +1736,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": "Εμφάνιση ταυτοτήτων ως προτάσεις" }, @@ -1779,7 +1860,7 @@ "message": "Σύρετε για ταξινόμηση" }, "dragToReorder": { - "message": "Drag to reorder" + "message": "Σύρετε για αναδιάταξη" }, "cfTypeText": { "message": "Κείμενο" @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Έτος λήξης" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Λήξη" }, @@ -1865,7 +1949,7 @@ "message": "Κωδικός ασφαλείας" }, "cardNumber": { - "message": "card number" + "message": "αριθμός κάρτας" }, "ex": { "message": "πχ." @@ -1967,82 +2051,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": { @@ -2391,6 +2475,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": "Ορισμός κύριου κωδικού πρόσβασης" }, @@ -2594,7 +2681,7 @@ "message": "Αποκλεισμένοι τομείς" }, "learnMoreAboutBlockedDomains": { - "message": "Learn more about blocked domains" + "message": "Μάθετε περισσότερα για τους αποκλεισμένους τομείς" }, "excludedDomains": { "message": "Εξαιρούμενοι Τομείς" @@ -2618,7 +2705,7 @@ "message": "Αλλαγή" }, "changePassword": { - "message": "Change password", + "message": "Αλλαγή κωδικού πρόσβασής", "description": "Change password button for browser at risk notification on login." }, "changeButtonTitle": { @@ -2631,7 +2718,7 @@ } }, "atRiskPassword": { - "message": "At-risk password" + "message": "Κωδικός πρόσβασης σε κίνδυνο" }, "atRiskPasswords": { "message": "Κωδικοί πρόσβασης σε κίνδυνο" @@ -2726,7 +2813,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.", @@ -3157,7 +3244,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" @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Σφάλμα αποκρυπτογράφησης" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Το Bitwarden δεν μπόρεσε να αποκρυπτογραφήσει τα αντικείμενα θησαυ/κίου που αναφέρονται παρακάτω." }, @@ -3582,10 +3672,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" @@ -3681,17 +3771,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": { @@ -3715,7 +3805,7 @@ "message": "Request pending" }, "firstLogin": { - "message": "First login" + "message": "Πρώτη σύνδεση" }, "trusted": { "message": "Trusted" @@ -3724,10 +3814,10 @@ "message": "Needs approval" }, "devices": { - "message": "Devices" + "message": "Συσκευές" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "Απόπειρα πρόσβασης από το $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -3736,31 +3826,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", @@ -4011,6 +4101,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": "Εναλλαγή πλευρικής πλοήγησης" }, @@ -4476,7 +4575,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": { @@ -4831,10 +4930,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": "Είστε σίγουροι ότι θέλετε να διαγράψετε οριστικά αυτό το συνημμένο;" @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Οι δωρεάν οργανισμοί δεν μπορούν να χρησιμοποιήσουν συνημμένα" }, @@ -4926,6 +5028,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,7 +5650,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" @@ -5547,13 +5659,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." @@ -5600,7 +5712,7 @@ "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." }, "phishingPageLearnMore": { - "message": "Learn more about phishing detection" + "message": "Μάθετε περισσότερα για την ανίχνευση ηλεκτρονικού «ψαρέματος»" }, "protectedBy": { "message": "Protected by $PRODUCT$", @@ -5621,7 +5733,7 @@ "message": "Search your vault for something else" }, "newLoginNudgeTitle": { - "message": "Save time with autofill" + "message": "Εξοικονομήστε χρόνο με την αυτόματη συμπλήρωση" }, "newLoginNudgeBodyOne": { "message": "Include a", @@ -5700,13 +5812,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", @@ -5718,8 +5830,56 @@ "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 29601bfa70c..09ea964823c 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Learn more" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" }, @@ -1403,6 +1457,15 @@ "attachmentSaved": { "message": "Attachment saved" }, + "fixEncryption": { + "message": "Fix encryption" + }, + "fixEncryptionTooltip": { + "message": "This file is using an outdated encryption method." + }, + "attachmentUpdated": { + "message": "Attachment updated" + }, "file": { "message": "File" }, @@ -1412,6 +1475,9 @@ "selectFile": { "message": "Select a file" }, + "itemsTransferred": { + "message": "Items transferred" + }, "maxFileSize": { "message": "Maximum file size is 500 MB." }, @@ -1442,6 +1508,15 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1580,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" }, @@ -1623,6 +1692,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" }, @@ -1676,9 +1748,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" }, @@ -1822,6 +1915,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2391,6 +2487,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" }, @@ -3153,9 +3252,6 @@ "copyCustomFieldNameNotUnique": { "message": "No unique identifier found." }, - "removeMasterPasswordForOrganizationUserKeyConnector": { - "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" }, @@ -3240,6 +3336,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4110,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" }, @@ -4842,6 +4950,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5037,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": { @@ -5718,6 +5839,45 @@ "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!" + }, + "advancedOnlineSecurity": { + "message": "Advanced 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." @@ -5727,5 +5887,142 @@ }, "cardNumberLabel": { "message": "Card number" + }, + "removeMasterPasswordForOrgUserKeyConnector":{ + "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." + }, + "continueWithLogIn": { + "message": "Continue with log in" + }, + "doNotContinue": { + "message": "Do not continue" + }, + "domain": { + "message": "Domain" + }, + "keyConnectorDomainTooltip": { + "message": "This domain will store your account encryption keys, so make sure you trust it. If you're not sure, check with your admin." + }, + "verifyYourOrganization": { + "message": "Verify your organization to log in" + }, + "organizationVerified":{ + "message": "Organization verified" + }, + "domainVerified":{ + "message": "Domain verified" + }, + "leaveOrganizationContent": { + "message": "If you don't verify your organization, your access to the organization will be revoked." + }, + "leaveNow": { + "message": "Leave now" + }, + "verifyYourDomainToLogin": { + "message": "Verify your domain to log in" + }, + "verifyYourDomainDescription": { + "message": "To continue with log in, verify this domain." + }, + "confirmKeyConnectorOrganizationUserDescription": { + "message": "To continue with log in, verify the organization and domain." + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutSettingsManagedByOrganization": { + "message": "This setting is managed by your organization." + }, + "sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes": { + "message": "Your organization has set the maximum session timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).", + "placeholders": { + "hours": { + "content": "$1", + "example": "8" + }, + "minutes": { + "content": "$2", + "example": "2" + } + } + }, + "sessionTimeoutSettingsPolicySetDefaultTimeoutToImmediately": { + "message": "Your organization has set the default session timeout to Immediately." + }, + "sessionTimeoutSettingsPolicySetDefaultTimeoutToOnLocked": { + "message": "Your organization has set the default session timeout to On system lock." + }, + "sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart": { + "message": "Your organization has set the default session timeout to On browser restart." + }, + "sessionTimeoutSettingsPolicyMaximumError": { + "message": "Maximum timeout cannot exceed $HOURS$ hour(s) and $MINUTES$ minute(s)", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + }, + "minutes": { + "content": "$2", + "example": "5" + } + } + }, + "sessionTimeoutOnRestart": { + "message": "On browser restart" + }, + "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": { + "message": "Set an unlock method to change your timeout action" + }, + "upgrade": { + "message": "Upgrade" + }, + "leaveConfirmationDialogTitle": { + "message": "Are you sure you want to leave?" + }, + "leaveConfirmationDialogContentOne": { + "message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features." + }, + "leaveConfirmationDialogContentTwo": { + "message": "Contact your admin to regain access." + }, + "leaveConfirmationDialogConfirmButton": { + "message": "Leave $ORGANIZATION$", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "howToManageMyVault": { + "message": "How do I manage my vault?" + }, + "transferItemsToOrganizationTitle": { + "message": "Transfer items to $ORGANIZATION$", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "transferItemsToOrganizationContent": { + "message": "$ORGANIZATION$ is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "acceptTransfer": { + "message": "Accept transfer" + }, + "declineAndLeave": { + "message": "Decline and leave" + }, + "whyAmISeeingThis": { + "message": "Why am I seeing this?" } } diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 7fd3091ef75..63d9214632a 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Learn more" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access" }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organisations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 88b95533ff1..9ef5cb2a061 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Learn more" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access" }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organisations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 2adf87d63f3..92f6226a3de 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" }, @@ -582,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" }, @@ -790,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" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Más información" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Clave de autenticación (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB de espacio cifrado en disco para adjuntos." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Acceso de emergencia." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Año de expiración" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiración" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "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." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,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" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 1500e20e3aa..bade6b0dff9 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "Arvutist väljalogimisel" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Brauseri taaskäivitamisel" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Loe edasi" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Autentimise võti (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB ulatuses krüpteeritud salvestusruum." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Aegumise aasta" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Aegumine" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 81106464f69..90cfc13f6ef 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "Sistema blokeatzean" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Nabigatzailea berrabiaraztean" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Gehiago ikasi" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Autentifikazio-gakoa (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "Eranskinentzako 1GB-eko zifratutako biltegia." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Iraungitze urtea" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Iraungitze data" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 6617ad085cc..7a4c8744429 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": "خوش آمدید" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "هنگام قفل سیستم" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "هنگام راه‌اندازی مجدد" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "مورد ذخیره شد" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "واقعاً می‌خواهید این مورد را به سطل زباله ارسال کنید؟" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "بیشتر بدانید" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "کلید احراز هویت (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "۱ گیگابایت فضای ذخیره‌سازی رمزگذاری شده برای پیوست‌های پرونده." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "دسترسی اضطراری." }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "درخواست بیومتریک هنگام راه‌اندازی" }, - "premiumRequired": { - "message": "در نسخه پرمیوم کار می‌کند" - }, - "premiumRequiredDesc": { - "message": "برای استفاده از این ویژگی عضویت پرمیوم لازم است." - }, "authenticationTimeout": { "message": "پایان زمان احراز هویت" }, @@ -1623,6 +1680,9 @@ "selfHostedEnvFormInvalid": { "message": "شما باید یا نشانی اینترنتی پایه سرور را اضافه کنید، یا حداقل یک محیط سفارشی تعریف کنید." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "محیط سفارشی" }, @@ -1676,9 +1736,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": "نمایش هویت‌ها به‌عنوان پیشنهاد" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "سال انقضاء" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "انقضاء" }, @@ -2391,6 +2475,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": "تنظیم کلمه عبور اصلی" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "خطای رمزگشایی" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden نتوانست مورد(های) گاوصندوق فهرست شده زیر را رمزگشایی کند." }, @@ -4011,6 +4101,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": "تغییر وضعیت ناوبری کناری" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "پرمیوم" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "سازمان‌های رایگان نمی‌توانند از پرونده‌های پیوست استفاده کنند" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 57a6ecfedd0..d7f0f600b9e 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" }, @@ -551,21 +554,21 @@ "message": "Nollaa haku" }, "archiveNoun": { - "message": "Archive", + "message": "Arkistoi", "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "Arkistoi", "description": "Verb" }, "unArchive": { - "message": "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." @@ -577,17 +580,29 @@ "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" }, @@ -790,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" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Lue lisää" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Todennusavain (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 Gt salattua tallennustilaa tiedostoliitteille." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Varmuuskäyttö" }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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ö" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Erääntymisvuosi" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Voimassaolo päättyy" }, @@ -1970,11 +2054,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": { @@ -1986,23 +2070,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": { @@ -2014,23 +2098,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": { @@ -2042,7 +2126,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": { @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Salauksen purkuvirhe" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden ei pystynyt purkamaan alla lueteltuja holvin kohteita." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,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ä" }, @@ -4926,6 +5028,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": { @@ -5589,7 +5701,7 @@ "message": "Close this tab" }, "phishingPageContinueV2": { - "message": "Continue to this site (not recommended)" + "message": "Jatka tälle sivustolle (ei suositeltavaa)" }, "phishingPageExplanation1": { "message": "This site was found in ", @@ -5706,7 +5818,7 @@ "message": "Show less" }, "next": { - "message": "Next" + "message": "Seuraava" }, "moreBreadcrumbs": { "message": "More breadcrumbs", @@ -5718,8 +5830,56 @@ "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 88b94d9b9c1..50964716ad0 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" }, @@ -582,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" }, @@ -790,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" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Matuto nang higit pa" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Susi ng Authenticator (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage para sa mga file attachment." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Taon ng Pag-expire" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Pag-expire" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 15d1cdecacf..6ddf7ea5873 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Utiliser l'authentification unique" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Votre organisation exige l’authentification unique." + }, "welcomeBack": { "message": "Content de vous revoir" }, @@ -559,7 +562,7 @@ "description": "Verb" }, "unArchive": { - "message": "Unarchive" + "message": "Désarchiver" }, "itemsInArchive": { "message": "Éléments dans l'archive" @@ -571,10 +574,10 @@ "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." }, "itemWasSentToArchive": { - "message": "Item was sent to archive" + "message": "L'élément a été envoyé à l'archive" }, "itemUnarchived": { - "message": "Item was unarchived" + "message": "L'élément a été désarchivé" }, "archiveItem": { "message": "Archiver l'élément" @@ -582,12 +585,24 @@ "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 ?" }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." + }, "edit": { "message": "Modifier" }, "view": { "message": "Afficher" }, + "viewAll": { + "message": "Tout afficher" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "Afficher moins" + }, "viewLogin": { "message": "Afficher l'Identifiant" }, @@ -790,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" }, @@ -1028,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 ?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "En savoir plus" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Clé Authenticator (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 Go de stockage chiffré pour les fichiers joints." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Accès d'urgence." }, @@ -1505,12 +1568,6 @@ "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é" }, @@ -1623,6 +1680,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é" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Année d'expiration" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2241,7 +2325,7 @@ "message": "Déverrouiller avec un code PIN" }, "setYourPinTitle": { - "message": "Définir PIN" + "message": "Définir NIP" }, "setYourPinButton": { "message": "Définir PIN" @@ -2391,6 +2475,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" }, @@ -3240,11 +3327,14 @@ "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": { @@ -4011,6 +4101,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" }, @@ -4142,7 +4241,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" @@ -4344,7 +4443,7 @@ "message": "Code incorrect" }, "incorrectPin": { - "message": "Code PIN incorrect" + "message": "Code NIP incorrect" }, "multifactorAuthenticationFailed": { "message": "Authentification multifacteur échouée" @@ -4842,6 +4941,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" }, @@ -4926,6 +5028,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": { @@ -5304,7 +5416,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" @@ -5580,30 +5692,30 @@ "message": "Bienvenue dans votre coffre !" }, "phishingPageTitleV2": { - "message": "Phishing attempt detected" + "message": "Tentative d'hameçonnage détectée" }, "phishingPageSummary": { - "message": "The site you are attempting to visit is a known malicious site and a security risk." + "message": "Le site que vous essayez de visiter est un site malveillant connu et un risque de sécurité." }, "phishingPageCloseTabV2": { - "message": "Close this tab" + "message": "Fermer cet onglet" }, "phishingPageContinueV2": { - "message": "Continue to this site (not recommended)" + "message": "Continuer vers ce site (non recommandé)" }, "phishingPageExplanation1": { - "message": "This site was found in ", + "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": ", an open-source list of known phishing sites used for stealing personal and sensitive information.", + "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": "Learn more about phishing detection" + "message": "En savoir plus sur la détection d'hameçonnage" }, "protectedBy": { - "message": "Protected by $PRODUCT$", + "message": "Protégé par $PRODUCT$", "placeholders": { "product": { "content": "$1", @@ -5716,10 +5828,58 @@ "message": "Confirmez le domaine de Key Connector" }, "atRiskLoginsSecured": { - "message": "Great job securing your at-risk logins!" + "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": "This setting is disabled by your organization's policy.", + "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 137576cfb1f..5695ae16035 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "Ó bloquear o sistema" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Ó reiniciar o navegador" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Máis información" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Clave de autenticación (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB de almacenamento cifrado para arquivos anexos." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Acceso de emerxencia." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Ano de vencemento" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Vencemento" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Erro de descifrado" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden non puido descifrar os seguintes elementos." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,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" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 2164d197b0e..67ca71338b2 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": "ברוך שובך" }, @@ -551,36 +554,39 @@ "message": "אפס חיפוש" }, "archiveNoun": { - "message": "Archive", + "message": "ארכיון", "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "העבר לארכיון", "description": "Verb" }, "unArchive": { - "message": "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": "פריטים בארכיון יופיעו כאן ויוחרגו מתוצאות חיפוש כללי והצעות למילוי אוטומטי." }, "itemWasSentToArchive": { - "message": "Item was sent to archive" + "message": "הפריט נשלח לארכיון" }, "itemUnarchived": { - "message": "Item was unarchived" + "message": "הפריט הוסר מהארכיון" }, "archiveItem": { - "message": "Archive item" + "message": "העבר פריט לארכיון" }, "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "message": "פריטים בארכיון מוחרגים מתוצאות חיפוש כללי והצעות למילוי אוטומטי. האם אתה בטוח שברצונך להעביר פריט זה לארכיון?" + }, + "upgradeToUseArchive": { + "message": "A premium membership is required to use Archive." }, "edit": { "message": "ערוך" @@ -588,6 +594,15 @@ "view": { "message": "הצג" }, + "viewAll": { + "message": "הצג הכל" + }, + "showAll": { + "message": "Show all" + }, + "viewLess": { + "message": "הצג פחות" + }, "viewLogin": { "message": "הצג כניסה" }, @@ -734,7 +749,7 @@ "message": "סיסמה ראשית שגויה" }, "invalidMasterPasswordConfirmEmailAndHost": { - "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "message": "סיסמה ראשית אינה תקינה. יש לאשר שהדוא\"ל שלך נכון ושהחשבון שלך נוצר ב־$HOST$.", "placeholders": { "host": { "content": "$1", @@ -790,6 +805,12 @@ "onLocked": { "message": "בנעילת המערכת" }, + "onIdle": { + "message": "כשהמערכת מזהה חוסר פעילות" + }, + "onSleep": { + "message": "כשהמערכת נכנסת למצב שינה" + }, "onRestart": { "message": "בהפעלת הדפדפן מחדש" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "הפריט נשמר" }, + "savedWebsite": { + "message": "אתר אינטרנט שנשמר" + }, + "savedWebsites": { + "message": "אתרי אינטרנט שנשמרו ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "האם אתה בטוח שברצונך למחוק פריט זה?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "למידע נוסף" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "מפתח מאמת (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 ג'יגה של מקום אחסון עבור קבצים מצורפים." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "גישת חירום." }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "בקש זיהוי ביומטרי בפתיחה" }, - "premiumRequired": { - "message": "נדרש פרימיום" - }, - "premiumRequiredDesc": { - "message": "נדרשת חברות פרימיום כדי להשתמש בתכונה זו." - }, "authenticationTimeout": { "message": "פסק זמן לאימות" }, @@ -1549,13 +1606,13 @@ "message": "קרא מפתח אבטחה" }, "readingPasskeyLoading": { - "message": "Reading passkey..." + "message": "קורא מפתח גישה..." }, "passkeyAuthenticationFailed": { - "message": "Passkey authentication failed" + "message": "אימות מפתח גישה נכשל" }, "useADifferentLogInMethod": { - "message": "Use a different log in method" + "message": "השתמש בשיטת כניסה אחרת" }, "awaitingSecurityKeyInteraction": { "message": "ממתין לאינטראקציה עם מפתח אבטחה..." @@ -1623,6 +1680,9 @@ "selfHostedEnvFormInvalid": { "message": "אתה מוכרח להוסיף או את בסיס ה־URL של השרת או לפחות סביבה מותאמת אישית אחת." }, + "selfHostedEnvMustUseHttps": { + "message": "כתובות URL מוכרחות להשתמש ב־HTTPS." + }, "customEnvironment": { "message": "סביבה מותאמת אישית" }, @@ -1676,9 +1736,30 @@ "turnOffAutofill": { "message": "השבת מילוי אוטומטי" }, + "confirmAutofill": { + "message": "אשר מילוי אוטומטי" + }, + "confirmAutofillDesc": { + "message": "אתר זה אינו תואם את פרטי הכניסה השמורה שלך. לפני שאתה ממלא את אישורי הכניסה שלך, וודא שזהו אתר מהימן." + }, "showInlineMenuLabel": { "message": "הצג הצעות למילוי אוטומטי על שדות טופס" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "כיצד Bitwarden מגנה על הנתונים שלך מדיוג?" + }, + "currentWebsite": { + "message": "אתר נוכחי" + }, + "autofillAndAddWebsite": { + "message": "מלא אוטומטית והוסף אתר אינטרנט זה" + }, + "autofillWithoutAdding": { + "message": "מלא אוטומטית מבלי להוסיף" + }, + "doNotAutofill": { + "message": "אל תמלא אוטומטית" + }, "showInlineMenuIdentitiesLabel": { "message": "הצג זהויות כהצעות" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "שנת תפוגה" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "תוקף" }, @@ -1970,79 +2054,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": { @@ -2391,6 +2475,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": "הגדר סיסמה ראשית" }, @@ -3217,7 +3304,7 @@ } }, "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "message": "רק הכספת הארגונית המשויכת עם $ORGANIZATION$ תיוצא.", "placeholders": { "organization": { "content": "$1", @@ -3226,7 +3313,7 @@ } }, "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "message": "רק הכספת הארגונית המשויכת עם $ORGANIZATION$ תיוצא. פריטי האוספים שלי לא יכללו.", "placeholders": { "organization": { "content": "$1", @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "שגיאת פענוח" }, + "errorGettingAutoFillData": { + "message": "שגיאה בקבלת נתוני מילוי אוטומטי" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden לא יכל לפענח את פריט(י) הכספת המפורט(ים) להלן." }, @@ -4011,6 +4101,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": "החלף מצב ניווט צדדי" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "פרימיום" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "ארגונים חינמיים לא יכולים להשתמש בצרופות" }, @@ -4926,6 +5028,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": { @@ -5535,7 +5647,7 @@ "message": "אפשרויות כספת" }, "emptyVaultDescription": { - "message": "הכספת מגינה על יותר מרק הסיסמאות שלך. אחסן כניסות מאובטחות, זהויות, כרטיסים והערות באופן מאובטח כאן." + "message": "הכספת מגנה על יותר מרק הסיסמאות שלך. אחסן כניסות מאובטחות, זהויות, כרטיסים והערות באופן מאובטח כאן." }, "introCarouselLabel": { "message": "ברוך בואך אל Bitwarden" @@ -5580,30 +5692,30 @@ "message": "ברוך בואך אל הכספת שלך!" }, "phishingPageTitleV2": { - "message": "Phishing attempt detected" + "message": "זוהה ניסיון דיוג" }, "phishingPageSummary": { - "message": "The site you are attempting to visit is a known malicious site and a security risk." + "message": "האתר שאתה מנסה לבקר הוא אתר זדוני ידוע וסכנת אבטחה." }, "phishingPageCloseTabV2": { - "message": "Close this tab" + "message": "סגור כרטיסיה זו" }, "phishingPageContinueV2": { - "message": "Continue to this site (not recommended)" + "message": "המשך לאתר זה (לא מומלץ)" }, "phishingPageExplanation1": { - "message": "This site was found in ", + "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": ", an open-source list of known phishing sites used for stealing personal and sensitive information.", + "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": "Learn more about phishing detection" + "message": "למד עוד על זיהוי דיוג" }, "protectedBy": { - "message": "Protected by $PRODUCT$", + "message": "מוגן על ידי $PRODUCT$", "placeholders": { "product": { "content": "$1", @@ -5716,10 +5828,58 @@ "message": "אשר דומיין של Key Connector" }, "atRiskLoginsSecured": { - "message": "Great job securing your at-risk logins!" + "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": "This setting is disabled by your organization's policy.", + "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 bc36073156b..b839e31cd96 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": "आपका पुन: स्वागत है!" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "On Locked" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On Restart" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "संपादित आइटम " }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "क्या आप वास्तव में थ्रैश में भेजना चाहते हैं?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "अधिक जानें" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authenticator Key (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB of encrypted file storage." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "लॉन्च पर बायोमेट्रिक्स के लिए पूछें" }, - "premiumRequired": { - "message": "Premium Required" - }, - "premiumRequiredDesc": { - "message": "इस सुविधा का उपयोग करने के लिए प्रीमियम सदस्यता की आवश्यकता होती है।" - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Expiration Year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "समय सीमा समाप्ति" }, @@ -2391,6 +2475,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": "मास्टर पासवर्ड सेट करें" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 e678f506387..da78924b1eb 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" }, @@ -582,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" }, @@ -790,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" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Saznaj više" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Ključ autentifikatora (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB šifriranog prostora za pohranu podataka." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Pristup u nuždi." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Godina isteka" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Istek" }, @@ -2391,6 +2475,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Ova stranica ometa Bitwarden iskustvo. Kao sigurnosna mjera, Bitwarden inline izbornik je privremeno onemogućen." + }, "setMasterPassword": { "message": "Postavi glavnu lozinku" }, @@ -3240,6 +3327,9 @@ "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." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Besplatne organizacije ne mogu koristiti privitke" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 e2674595f4b..388a069e05a 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" }, @@ -582,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" }, @@ -790,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" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Tudjon meg többet" }, + "migrationsFailed": { + "message": "Hiba történt a titkosítási beállítások frissítésekor." + }, + "updateEncryptionSettingsTitle": { + "message": "A titkosítási beállítások frissítése" + }, + "updateEncryptionSettingsDesc": { + "message": "Az új ajánlott titkosítási beállítások javítják a fiók biztonságát. Adjuk meg a mesterjelszót a frissítéshez most." + }, + "confirmIdentityToContinue": { + "message": "A folytatáshoz meg kell erősíteni a személyazonosságot." + }, + "enterYourMasterPassword": { + "message": "Mesterjelszó megadása" + }, + "updateSettings": { + "message": "Beállítások frissítése" + }, + "later": { + "message": "Később" + }, "authenticatorKeyTotp": { "message": "Hitelesítő kulcs (egyszeri időalapú)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB titkosított tárhely a fájlmellékleteknek." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ titkosított tárhely a fájlmellékletekhez.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Sürgősségi hozzáférés" }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,30 @@ "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": "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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Lejárati év" }, + "monthly": { + "message": "hónap" + }, "expiration": { "message": "Lejárat" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "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." }, @@ -4011,6 +4101,15 @@ "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": "Oldalnavigáció váltás" }, @@ -4842,6 +4941,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." }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 a5757e38caf..d18b25c51ed 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "Saat Komputer Terkunci" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Saat Peramban Dimulai Ulang" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Pelajari lebih lanjut" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Kunci Otentikasi (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB penyimpanan berkas yang dienkripsi." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Akses darurat." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Tahun Kedaluwarsa" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Masa Berlaku" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Kesalahan dekripsi" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden tidak bisa mendekripsi butir brankas yang tercantum di bawah." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Organisasi gratis tidak dapat menggunakan lampiran" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 233ae413e5f..9bb75d7f449 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -31,8 +31,11 @@ "useSingleSignOn": { "message": "Usa il Single Sign-On" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "La tua organizzazione richiede un accesso Single Sign-On (SSO)." + }, "welcomeBack": { - "message": "Bentornato/a" + "message": "Bentornato" }, "setAStrongPassword": { "message": "Imposta una password robusta" @@ -582,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Gli elementi archiviati sono esclusi dai risultati di ricerca e suggerimenti di autoriempimento. Vuoi davvero archiviare questo elemento?" }, + "upgradeToUseArchive": { + "message": "Per utilizzare Archivio è necessario un abbonamento premium." + }, "edit": { "message": "Modifica" }, "view": { "message": "Visualizza" }, + "viewAll": { + "message": "Mostra tutto" + }, + "showAll": { + "message": "Mostra tutto" + }, + "viewLess": { + "message": "Vedi meno" + }, "viewLogin": { "message": "Visualizza login" }, @@ -790,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" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Ulteriori informazioni" }, + "migrationsFailed": { + "message": "Si è verificato un errore durante l'aggiornamento delle impostazioni di cifratura." + }, + "updateEncryptionSettingsTitle": { + "message": "Aggiorna le impostazioni di crittografia" + }, + "updateEncryptionSettingsDesc": { + "message": "Le nuove impostazioni di crittografia consigliate miglioreranno la sicurezza del tuo account. Inserisci la tua password principale per aggiornare." + }, + "confirmIdentityToContinue": { + "message": "Conferma la tua identità per continuare" + }, + "enterYourMasterPassword": { + "message": "Inserisci la tua password principale" + }, + "updateSettings": { + "message": "Aggiorna impostazioni" + }, + "later": { + "message": "Più tardi" + }, "authenticatorKeyTotp": { "message": "Chiave di autenticazione (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB di spazio di archiviazione crittografato per gli allegati." }, + "premiumSignUpStorageV2": { + "message": "Archivio crittografato di $SIZE$ per allegati.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Accesso di emergenza." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Anno di scadenza" }, + "monthly": { + "message": "mese" + }, "expiration": { "message": "Scadenza" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "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." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,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" }, @@ -4926,6 +5028,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": { @@ -5716,10 +5828,58 @@ "message": "Conferma dominio Key Connector" }, "atRiskLoginsSecured": { - "message": "Great job securing your at-risk logins!" + "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": "Sblocca funzionalità di sicurezza avanzate" + }, + "unlockAdvancedSecurityDesc": { + "message": "Un abbonamento Premium ti offre più strumenti per rimanere sicuro e in controllo" + }, + "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 4ab3cdc9c1b..0b0883beaf3 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": "ようこそ" }, @@ -551,15 +554,15 @@ "message": "Reset search" }, "archiveNoun": { - "message": "Archive", + "message": "アーカイブ", "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "アーカイブ", "description": "Verb" }, "unArchive": { - "message": "Unarchive" + "message": "アーカイブ解除" }, "itemsInArchive": { "message": "Items in archive" @@ -582,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": "表示するアイテムがありません" @@ -790,6 +805,12 @@ "onLocked": { "message": "ロック時" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "ブラウザ再起動時" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "編集されたアイテム" }, + "savedWebsite": { + "message": "保存されたウェブサイト" + }, + "savedWebsites": { + "message": "保存されたウェブサイト ($COUNT$ 件)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "このアイテムを削除しますか?" }, @@ -1127,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": { @@ -1172,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": { @@ -1373,6 +1406,27 @@ "learnMore": { "message": "さらに詳しく" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "認証キー (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1GB の暗号化されたファイルストレージ" }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "緊急アクセス" }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "起動時に生体認証を要求する" }, - "premiumRequired": { - "message": "プレミアム会員専用" - }, - "premiumRequiredDesc": { - "message": "この機能を使うにはプレミアム会員になってください。" - }, "authenticationTimeout": { "message": "認証のタイムアウト" }, @@ -1549,13 +1606,13 @@ "message": "セキュリティキーの読み取り" }, "readingPasskeyLoading": { - "message": "Reading passkey..." + "message": "パスキーを読み込み中…" }, "passkeyAuthenticationFailed": { - "message": "Passkey authentication failed" + "message": "パスキー認証に失敗しました" }, "useADifferentLogInMethod": { - "message": "Use a different log in method" + "message": "別のログイン方法を使用する" }, "awaitingSecurityKeyInteraction": { "message": "セキュリティキーとの通信を待ち受け中…" @@ -1623,6 +1680,9 @@ "selfHostedEnvFormInvalid": { "message": "ベース サーバー URL または少なくとも 1 つのカスタム環境を追加する必要があります。" }, + "selfHostedEnvMustUseHttps": { + "message": "URL は HTTPS を使用する必要があります。" + }, "customEnvironment": { "message": "カスタム環境" }, @@ -1674,11 +1734,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 を候補として表示する" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "有効期限年" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "有効期限" }, @@ -1865,7 +1949,7 @@ "message": "セキュリティコード" }, "cardNumber": { - "message": "card number" + "message": "カード番号" }, "ex": { "message": "例:" @@ -1967,30 +2051,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": { @@ -1998,11 +2082,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": { @@ -2010,15 +2094,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": { @@ -2026,11 +2110,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": { @@ -2038,11 +2122,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": { @@ -2301,7 +2385,7 @@ "message": "このパスワードを使用する" }, "useThisPassphrase": { - "message": "Use this passphrase" + "message": "このパスフレーズを使用" }, "useThisUsername": { "message": "このユーザー名を使用する" @@ -2391,6 +2475,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": "マスターパスワードを設定" }, @@ -2618,7 +2705,7 @@ "message": "変更" }, "changePassword": { - "message": "Change password", + "message": "パスワードを変更", "description": "Change password button for browser at risk notification on login." }, "changeButtonTitle": { @@ -2631,7 +2718,7 @@ } }, "atRiskPassword": { - "message": "At-risk password" + "message": "リスクがあるパスワード" }, "atRiskPasswords": { "message": "リスクがあるパスワード" @@ -2807,7 +2894,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": { @@ -3157,7 +3244,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" @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "復号エラー" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden は以下の保管庫のアイテムを復号できませんでした。" }, @@ -3317,7 +3407,7 @@ "message": "サービス" }, "forwardedEmail": { - "message": "転送されたメールエイリアス" + "message": "転送されるメールエイリアス" }, "forwardedEmailDesc": { "message": "外部転送サービスを使用してメールエイリアスを生成します。" @@ -3566,7 +3656,7 @@ "message": "リクエストが送信されました" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "$EMAIL$ に $DEVICE$ でのログインを承認しました", "placeholders": { "email": { "content": "$1", @@ -3579,16 +3669,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": "流出したマスターパスワード" @@ -3681,28 +3771,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" @@ -3712,22 +3802,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", @@ -3736,31 +3826,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", @@ -3790,7 +3880,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." @@ -3860,13 +3950,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" @@ -3881,11 +3971,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": { @@ -4011,6 +4101,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": "サイドナビゲーションの切り替え" }, @@ -4220,10 +4319,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": "割り当てられていないアイテムがファイルに含まれています。" @@ -4460,7 +4559,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": { @@ -4476,7 +4575,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": { @@ -4663,7 +4762,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "$FIELD$ ($CIPHERNAME$) をコピー", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -4810,31 +4909,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": "この添付ファイルを完全に削除してもよろしいですか?" @@ -4842,6 +4941,9 @@ "premium": { "message": "プレミアム" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "無料の組織は添付ファイルを使用できません" }, @@ -4926,6 +5028,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": { @@ -5190,7 +5302,7 @@ "message": "拡張機能アイコンにログイン自動入力の候補の数を表示する" }, "accountAccessRequested": { - "message": "Account access requested" + "message": "アカウントへのアクセスが要求されました" }, "confirmAccessAttempt": { "message": "Confirm access attempt for $EMAIL$", @@ -5307,7 +5419,7 @@ "message": "Unlock PIN set" }, "unlockWithBiometricSet": { - "message": "Unlock with biometrics set" + "message": "生体認証でロック解除を設定しました" }, "authenticating": { "message": "認証中" @@ -5321,7 +5433,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": { @@ -5529,13 +5641,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 へようこそ" @@ -5565,19 +5677,19 @@ "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": "保管庫へようこそ!" }, "phishingPageTitleV2": { "message": "Phishing attempt detected" @@ -5586,7 +5698,7 @@ "message": "The site you are attempting to visit is a known malicious site and a security risk." }, "phishingPageCloseTabV2": { - "message": "Close this tab" + "message": "このタブを閉じる" }, "phishingPageContinueV2": { "message": "Continue to this site (not recommended)" @@ -5603,7 +5715,7 @@ "message": "Learn more about phishing detection" }, "protectedBy": { - "message": "Protected by $PRODUCT$", + "message": "$PRODUCT$ によって保護されています", "placeholders": { "product": { "content": "$1", @@ -5629,7 +5741,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." }, @@ -5657,20 +5769,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", @@ -5687,7 +5799,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." @@ -5700,13 +5812,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", @@ -5718,8 +5830,56 @@ "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 4ea5ab3390a..ebb01f095f3 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "გაიგეთ მეტი" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "ვადა" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "პრემიუმი" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 e3e6953b0df..684a04d9175 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Learn more" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 271db811810..05ea413b522 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "ಸಿಸ್ಟಮ್ ಲಾಕ್‌ನಲ್ಲಿ" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "ಬ್ರೌಸರ್ ಮರುಪ್ರಾರಂಭದಲ್ಲಿ" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "ಐಟಂ ಸಂಪಾದಿಸಲಾಗಿದೆ" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "ನೀವು ನಿಜವಾಗಿಯೂ ಅನುಪಯುಕ್ತಕ್ಕೆ ಕಳುಹಿಸಲು ಬಯಸುವಿರಾ?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "ಇನ್ನಷ್ಟು ತಿಳಿಯಿರಿ" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "ದೃಢೀಕರಣ ಕೀ (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "ಫೈಲ್ ಲಗತ್ತುಗಳಿಗಾಗಿ 1 ಜಿಬಿ ಎನ್‌ಕ್ರಿಪ್ಟ್ ಮಾಡಿದ ಸಂಗ್ರಹ." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "ಪ್ರೀಮಿಯಂ ಅಗತ್ಯವಿದೆ" - }, - "premiumRequiredDesc": { - "message": "ಈ ವೈಶಿಷ್ಟ್ಯವನ್ನು ಬಳಸಲು ಪ್ರೀಮಿಯಂ ಸದಸ್ಯತ್ವ ಅಗತ್ಯವಿದೆ." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1623,6 +1680,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": "ಕಸ್ಟಮ್ ಪರಿಸರ" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "ಮುಕ್ತಾಯ ವರ್ಷ" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "ಮುಕ್ತಾಯ" }, @@ -2391,6 +2475,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": "ಮಾಸ್ಟರ್ ಪಾಸ್ವರ್ಡ್ ಹೊಂದಿಸಿ" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 c45532076da..b35fe8283f7 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": "돌아온 것을 환영합니다." }, @@ -582,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "보관된 항목은 일반 검색 결과와 자동 완성 제안에서 제외됩니다. 이 항목을 보관하시겠습니까?" }, + "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": "로그인 보기" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "시스템 잠금 시" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "브라우저 재시작 시" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "항목 편집함" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "정말로 휴지통으로 이동시킬까요?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "더 알아보기" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "인증 키 (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1GB의 암호화된 파일 저장소." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "비상 접근" }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "실행 시 생체 인증 요구하기" }, - "premiumRequired": { - "message": "프리미엄 멤버십 필요" - }, - "premiumRequiredDesc": { - "message": "이 기능을 사용하려면 프리미엄 멤버십이 필요합니다." - }, "authenticationTimeout": { "message": "인증 시간 초과" }, @@ -1623,6 +1680,9 @@ "selfHostedEnvFormInvalid": { "message": "기본 서버 URL이나 최소한 하나의 사용자 지정 환경을 추가해야 합니다." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "사용자 지정 환경" }, @@ -1676,9 +1736,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": "신원을 제안으로 표시" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "만료 연도" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "만료" }, @@ -2391,6 +2475,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": "마스터 비밀번호 설정" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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": "사이드 내비게이션 전환" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "프리미엄" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "무료 조직에서는 첨부 파일을 사용할 수 없습니다." }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 e97a1cafcf9..7fcc2df0330 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įžę" }, @@ -582,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" }, @@ -790,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ę" }, @@ -1028,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ę?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Sužinoti daugiau" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Vienkartinio autentifikavimo raktas (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB užšifruotos vietos diske bylų prisegimams." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "Paleidžiant patvirtinti biometrinius duomenis" }, - "premiumRequired": { - "message": "Premium reikalinga" - }, - "premiumRequiredDesc": { - "message": "Premium narystė reikalinga šiai funkcijai naudoti." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Galiojimo pabaigos metai" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Galiojimo pabaiga" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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ą" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "„Premium“" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Nemokamos organizacijos negali naudoti priedų" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 e1189450671..a719320fc8c 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ļ" }, @@ -582,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" }, @@ -790,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" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Uzzināt vairāk" }, + "migrationsFailed": { + "message": "Atgadījās kļūda šifrēšanas iestatījumu atjaunināšanā." + }, + "updateEncryptionSettingsTitle": { + "message": "Atjaunini savus šifrēšanas iestatījumus" + }, + "updateEncryptionSettingsDesc": { + "message": "Jaunie ieteicamie šifrēšanas iestatījumi uzlabos Tava konta drošību. Jāievada sava galvenā parole, lai atjauninātu tagad." + }, + "confirmIdentityToContinue": { + "message": "Jāapliecina sava identitāte, lai turpinātu" + }, + "enterYourMasterPassword": { + "message": "Jāievada sava galvenā parole" + }, + "updateSettings": { + "message": "Atjaunināt Iestatījumus" + }, + "later": { + "message": "Vēlāk" + }, "authenticatorKeyTotp": { "message": "Autentificētāja atslēga (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB šifrētas krātuves datņu pielikumiem." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ šifrētas krātuves datņu pielikumiem.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Ārkārtas piekļuve" }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Derīguma gads" }, + "monthly": { + "message": "mēnesī" + }, "expiration": { "message": "Derīgums" }, @@ -2391,6 +2475,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" }, @@ -3217,7 +3304,7 @@ } }, "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "message": "Tiks izgūta tikai apvienības glabātava, kas ir saistīta ar $ORGANIZATION$.", "placeholders": { "organization": { "content": "$1", @@ -3226,7 +3313,7 @@ } }, "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "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", @@ -3240,6 +3327,9 @@ "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." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,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" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 fcf73a37e45..b72d82cd93f 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "സിസ്റ്റം ലോക്കിൽ" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "ബ്രൌസർ പുനരാരംഭിക്കുമ്പോൾ" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "തിരുത്തപ്പെട്ട ഇനം" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "ഈ ഇനം ഇല്ലാതാക്കാൻ നിങ്ങൾ ആഗ്രഹിക്കുന്നുണ്ടോ?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "കൂടുതലറിവ് നേടുക" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "ഓതന്റിക്കേറ്റർ കീ (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "ഫയൽ അറ്റാച്ചുമെന്റുകൾക്കായി 1 ജിബി എൻക്രിപ്റ്റുചെയ്‌ത സംഭരണം." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "പ്രീമിയം അംഗത്വം ആവശ്യമാണ്" - }, - "premiumRequiredDesc": { - "message": "ഈ സവിശേഷത ഉപയോഗിക്കുന്നതിന് പ്രീമിയം അംഗത്വം ആവശ്യമാണ്." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1623,6 +1680,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": "ഇഷ്‌ടാനുസൃത എൻവിയോണ്മെന്റ്" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "കാലാവതി കഴിയുന്ന വർഷം" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "കാലഹരണപ്പെടൽ" }, @@ -2391,6 +2475,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": "പ്രാഥമിക പാസ്‌വേഡ് സജ്ജമാക്കുക" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 93f78303a5c..03c3b4a70ae 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Learn more" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 e3e6953b0df..684a04d9175 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Learn more" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 66d1ce615e1..6226d26312f 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "Ved maskinlåsing" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Ved nettleseromstart" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Lær mer" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Autentiseringsnøkkel (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB med kryptert fillagring for filvedlegg." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Nødtilgang." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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ø" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Utløpsår" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Utløp" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Dekrypteringsfeil" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 e3e6953b0df..684a04d9175 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Learn more" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 73b8afa2966..04e6e1bb7f3 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "Bij systeemvergrendeling" }, + "onIdle": { + "message": "Bij systeeminactiviteit" + }, + "onSleep": { + "message": "Bij slaapmodus" + }, "onRestart": { "message": "Bij herstart van de browser" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Meer informatie" }, + "migrationsFailed": { + "message": "Er is een fout opgetreden bij het bijwerken van de versleutelingsinstellingen." + }, + "updateEncryptionSettingsTitle": { + "message": "Je versleutelingsinstellingen bijwerken" + }, + "updateEncryptionSettingsDesc": { + "message": "De nieuwe aanbevolen versleutelingsinstellingen verbeteren de beveiliging van je account. Voer je hoofdwachtwoord in om nu bij te werken." + }, + "confirmIdentityToContinue": { + "message": "Bevestig je identiteit om door te gaan" + }, + "enterYourMasterPassword": { + "message": "Voer je hoofdwachtwoord in" + }, + "updateSettings": { + "message": "Instellingen bijwerken" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authenticatiecode (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB versleutelde opslag voor bijlagen." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ versleutelde opslag voor bijlagen.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Noodtoegang." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Vervaljaar" }, + "monthly": { + "message": "maand" + }, "expiration": { "message": "Vervaldatum" }, @@ -2391,6 +2475,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Deze pagina verstoort de Bitwarden-ervaring. Het inline-menu van Bitwarden is tijdelijk uitgeschakeld als veiligheidsmaatregel." + }, "setMasterPassword": { "message": "Hoofdwachtwoord instellen" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Ontsleutelingsfout" }, + "errorGettingAutoFillData": { + "message": "Fout bij ophalen van gegevens voor automatisch vullen" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden kon de onderstaande kluisitem(s) niet ontsleutelen." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Ontgrendel tapporteren, noodtoegang en meer beveiligingsfuncties met Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Gratis organisaties kunnen geen bijlagen gebruiken" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 e3e6953b0df..684a04d9175 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Learn more" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 e3e6953b0df..684a04d9175 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Learn more" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 78fb5e832a6..24729a2331b 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" }, @@ -582,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" }, @@ -734,7 +749,7 @@ "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$.", + "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", @@ -790,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" }, @@ -1028,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ąć?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Dowiedz się więcej" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Klucz uwierzytelniający (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB miejsca na zaszyfrowane załączniki." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Dostęp awaryjny." }, @@ -1505,12 +1568,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" }, @@ -1552,7 +1609,7 @@ "message": "Odczytywanie klucza dostępu..." }, "passkeyAuthenticationFailed": { - "message": "Passkey authentication failed" + "message": "Uwierzytelnienie za pomocą klucza nie powiodło się" }, "useADifferentLogInMethod": { "message": "Użyj innej metody logowania" @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Rok wygaśnięcia" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Data wygaśnięcia" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "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." }, @@ -4011,6 +4101,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ą" }, @@ -4842,6 +4941,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" }, @@ -4926,6 +5028,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": { @@ -5580,30 +5692,30 @@ "message": "Witaj w sejfie!" }, "phishingPageTitleV2": { - "message": "Phishing attempt detected" + "message": "Wykryto próbę phishingu" }, "phishingPageSummary": { - "message": "The site you are attempting to visit is a known malicious site and a security risk." + "message": "Witryna, którą próbujesz odwiedzić, jest znaną złośliwą witryną i zagrożeniem bezpieczeństwa." }, "phishingPageCloseTabV2": { "message": "Zamknij kartę" }, "phishingPageContinueV2": { - "message": "Continue to this site (not recommended)" + "message": "Przejdź do tej witryny (niezalecane)" }, "phishingPageExplanation1": { - "message": "This site was found in ", + "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": ", an open-source list of known phishing sites used for stealing personal and sensitive information.", + "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": "Learn more about phishing detection" + "message": "Dowiedz się więcej o wykrywaniu phishingu" }, "protectedBy": { - "message": "Protected by $PRODUCT$", + "message": "Chronione przez $PRODUCT$", "placeholders": { "product": { "content": "$1", @@ -5716,10 +5828,58 @@ "message": "Potwierdź domenę Key Connector" }, "atRiskLoginsSecured": { - "message": "Great job securing your at-risk logins!" + "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": "This setting is disabled by your organization's policy.", + "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 e3a82f42ca7..c7a5300a873 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -10,11 +10,11 @@ "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" @@ -26,19 +26,22 @@ "message": "Novo no Bitwarden?" }, "logInWithPasskey": { - "message": "Entrar com chave de acesso" + "message": "Conectar-se com chave de acesso" }, "useSingleSignOn": { "message": "Usar autenticação única" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "A sua organização requer o uso da autenticação única." + }, "welcomeBack": { "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": "Autenticação única empresarial" @@ -56,13 +59,13 @@ "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,10 +81,10 @@ } }, "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": "Pontuação de força da senha $SCORE$", @@ -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,7 +117,7 @@ "message": "Cofre" }, "myVault": { - "message": "Meu Cofre" + "message": "Meu cofre" }, "allVaults": { "message": "Todos os cofres" @@ -126,10 +129,10 @@ "message": "Configurações" }, "currentTab": { - "message": "Aba Atual" + "message": "Aba atual" }, "copyPassword": { - "message": "Copiar Senha" + "message": "Copiar senha" }, "copyPassphrase": { "message": "Copiar frase secreta" @@ -141,7 +144,7 @@ "message": "Copiar URI" }, "copyUsername": { - "message": "Copiar Nome de Usuário" + "message": "Copiar nome de usuário" }, "copyNumber": { "message": "Copiar número" @@ -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,7 +212,7 @@ "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", @@ -225,7 +228,7 @@ "message": "Nenhuma credencial correspondente" }, "noCards": { - "message": "Sem cartões" + "message": "Nenhum cartão" }, "noIdentities": { "message": "Nenhuma identidade" @@ -243,7 +246,7 @@ "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 preencher automaticamente na aba atual do navegador." @@ -261,13 +264,13 @@ "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 dica da sua senha será enviada para você" }, "getMasterPasswordHint": { - "message": "Obter dica da senha mestra" + "message": "Receber dica da senha principal" }, "continue": { "message": "Continuar" @@ -288,7 +291,7 @@ "message": "Confirme a sua identidade para continuar." }, "changeMasterPassword": { - "message": "Alterar senha mestra" + "message": "Alterar senha principal" }, "continueToWebApp": { "message": "Continuar no aplicativo web?" @@ -309,21 +312,21 @@ "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 do Bitwarden." + "message": "Você pode alterar a sua senha principal no aplicativo web do 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." }, "twoStepLogin": { - "message": "Login em Duas Etapas" + "message": "Autenticação em duas etapas" }, "logOut": { - "message": "Sair" + "message": "Desconectar" }, "aboutBitwarden": { "message": "Sobre o Bitwarden" @@ -335,7 +338,7 @@ "message": "Mais do Bitwarden" }, "continueToBitwardenDotCom": { - "message": "Continuar para bitwarden.com?" + "message": "Continuar em bitwarden.com?" }, "bitwardenForBusiness": { "message": "Bitwarden para Negócios" @@ -359,10 +362,10 @@ "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": "Bitwarden Families grátis" + "message": "Bitwarden Famílias grátis" }, "freeBitwardenFamiliesPageDesc": { - "message": "Você é elegível para o plano Bitwarden Families 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$", @@ -410,25 +413,25 @@ "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 do Bitwarden" }, "communityForums": { - "message": "Explore os fóruns da comunidade do Bitwarden" + "message": "Explorar os fóruns da comunidade do Bitwarden" }, "contactSupport": { - "message": "Contate o suporte do Bitwarden" + "message": "Entrar em contato com o suporte do Bitwarden" }, "sync": { "message": "Sincronizar" @@ -437,7 +440,7 @@ "message": "Sincronizar cofre agora" }, "lastSync": { - "message": "Última Sincronização:" + "message": "Última sincronização:" }, "passGen": { "message": "Gerador de senhas" @@ -459,7 +462,7 @@ "message": "Selecionar" }, "generatePassword": { - "message": "Gerar Senha" + "message": "Gerar senha" }, "generatePassphrase": { "message": "Gerar frase secreta" @@ -477,7 +480,7 @@ "message": "E-mail gerado" }, "regeneratePassword": { - "message": "Gerar nova senha" + "message": "Regerar senha" }, "options": { "message": "Opções" @@ -524,7 +527,7 @@ "message": "Separador de palavras" }, "capitalize": { - "message": "Iniciais em Maiúsculas", + "message": "Iniciais maiúsculas", "description": "Make the first letter of a work uppercase." }, "includeNumber": { @@ -545,10 +548,10 @@ "description": "Indicates that a policy limits the credential generator screen." }, "searchVault": { - "message": "Pesquisar no Cofre" + "message": "Buscar no cofre" }, "resetSearch": { - "message": "Redefinir pesquisa" + "message": "Apagar busca" }, "archiveNoun": { "message": "Arquivo", @@ -568,7 +571,7 @@ "message": "Nenhum item no arquivo" }, "noItemsInArchiveDesc": { - "message": "Os itens arquivados aparecerão aqui e serão excluídos dos resultados de pesquisa gerais e das sugestões de preenchimento automático." + "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." }, "itemWasSentToArchive": { "message": "O item foi enviado para o arquivo" @@ -580,7 +583,10 @@ "message": "Arquivar item" }, "archiveItemConfirmDesc": { - "message": "Itens arquivados são excluídos dos resultados da pesquisa geral e das sugestões de preenchimento automático. Tem certeza de que deseja arquivar este 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" @@ -588,6 +594,15 @@ "view": { "message": "Ver" }, + "viewAll": { + "message": "Ver tudo" + }, + "showAll": { + "message": "Mostrar tudo" + }, + "viewLess": { + "message": "Ver menos" + }, "viewLogin": { "message": "Ver credencial" }, @@ -598,7 +613,7 @@ "message": "Informações do item" }, "username": { - "message": "Nome de Usuário" + "message": "Nome de usuário" }, "password": { "message": "Senha" @@ -607,10 +622,10 @@ "message": "Segredo do autenticador" }, "passphrase": { - "message": "Frase Secreta" + "message": "Frase secreta" }, "favorite": { - "message": "Favorito" + "message": "Favoritar" }, "unfavorite": { "message": "Desfavoritar" @@ -622,13 +637,13 @@ "message": "Item removido dos favoritos" }, "notes": { - "message": "Notas" + "message": "Anotações" }, "privateNote": { "message": "Anotação privada" }, "note": { - "message": "Nota" + "message": "Anotação" }, "editItem": { "message": "Editar item" @@ -688,10 +703,10 @@ "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" @@ -703,7 +718,7 @@ "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" @@ -718,7 +733,7 @@ "message": "Desbloquear" }, "loggedInAsOn": { - "message": "Entrou como $EMAIL$ em $HOSTNAME$.", + "message": "Conectado como $EMAIL$ em $HOSTNAME$.", "placeholders": { "email": { "content": "$1", @@ -731,10 +746,10 @@ } }, "invalidMasterPassword": { - "message": "Senha mestra inválida" + "message": "Senha principal inválida" }, "invalidMasterPasswordConfirmEmailAndHost": { - "message": "Senha mestre inválida. Confirme que seu e-mail está correto e sua conta foi criada em $HOST$.", + "message": "Senha principal inválida. Confirme que seu e-mail está correto e sua conta foi criada em $HOST$.", "placeholders": { "host": { "content": "$1", @@ -749,7 +764,7 @@ "message": "Tempo limite" }, "lockNow": { - "message": "Bloquear Agora" + "message": "Bloquear agora" }, "lockAll": { "message": "Bloquear tudo" @@ -788,10 +803,16 @@ "message": "4 horas" }, "onLocked": { - "message": "Ao bloquear o sistema" + "message": "No bloqueio do sistema" + }, + "onIdle": { + "message": "Na inatividade do sistema" + }, + "onSleep": { + "message": "Na hibernação do sistema" }, "onRestart": { - "message": "Ao reiniciar o navegador" + "message": "No reinício do navegador" }, "never": { "message": "Nunca" @@ -800,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ê esquecê-la!" + "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" @@ -821,13 +842,13 @@ "message": "Endereço de e-mail inválido." }, "masterPasswordRequired": { - "message": "A senha mestre é necessária." + "message": "A senha principal é necessária." }, "confirmMasterPasswordRequired": { - "message": "É necessário digitar a senha mestra novamente." + "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": { @@ -837,10 +858,10 @@ } }, "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 conta nova foi criada! Agora você pode se conectar." }, "newAccountCreated2": { "message": "Sua nova conta foi criada!" @@ -849,25 +870,25 @@ "message": "Você foi conectado!" }, "youSuccessfullyLoggedIn": { - "message": "Você entrou na sua conta com sucesso" + "message": "Você se conectou 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": { @@ -904,7 +925,7 @@ "message": "Copiar chave do autenticador (TOTP)" }, "loggedOut": { - "message": "Sessão encerrada" + "message": "Desconectado" }, "loggedOutDesc": { "message": "Você foi desconectado de sua conta." @@ -913,13 +934,13 @@ "message": "A sua sessão expirou." }, "logIn": { - "message": "Entrar" + "message": "Conectar-se" }, "logInToBitwarden": { - "message": "Entre 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 do seu autenticador" @@ -928,19 +949,19 @@ "message": "Pressione sua YubiKey para autenticar-se" }, "duoTwoFactorRequiredPageSubtitle": { - "message": "A autenticação de dois fatores do Duo é 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 a autenticação." + "message": "Siga os passos abaixo para terminar de se conectar." }, "followTheStepsBelowToFinishLoggingInWithSecurityKey": { - "message": "Siga os passos abaixo para finalizar a autenticação com a sua chave de segurança." + "message": "Siga os passos abaixo para finalizar de se conectar com a sua chave de segurança." }, "restartRegistration": { "message": "Reiniciar cadastro" }, "expiredLink": { - "message": "Link expirado" + "message": "Link vencido" }, "pleaseRestartRegistrationOrTryLoggingIn": { "message": "Reinicie o cadastro ou tente conectar-se." @@ -949,7 +970,7 @@ "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" @@ -976,13 +997,13 @@ "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 salva" }, "deleteFolderConfirmation": { - "message": "Você tem certeza que deseja excluir esta pasta?" + "message": "Tem certeza que deseja apagar esta pasta?" }, "deletedFolder": { "message": "Pasta apagada" @@ -994,16 +1015,16 @@ "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$", @@ -1028,8 +1049,20 @@ "editedItem": { "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 enviado para a lixeira" @@ -1041,19 +1074,19 @@ "message": "Você tem certeza que deseja substituir a senha atual?" }, "overwriteUsername": { - "message": "Sobrescrever nome de usuário" + "message": "Substituir nome de usuário" }, "overwriteUsernameConfirmation": { "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": "Sem pasta", @@ -1087,7 +1120,7 @@ "message": "Mostrar identidades na página da aba" }, "showIdentitiesCurrentTabDesc": { - "message": "Liste as identidades na página da aba para facilitar o 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" @@ -1221,10 +1254,10 @@ "description": "Detailed error message shown when saving login details fails." }, "changePasswordWarning": { - "message": "Ao alterar sua senha, você precisará entrar com a sua senha nova. Sessões ativas em outros dispositivos serão desconectados dentro de uma hora." + "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": "Mude a sua senha mestre para completar a recuperação de conta." + "message": "Altere a sua senha principal para concluir a recuperação da conta." }, "enableChangedPasswordNotification": { "message": "Pedir para atualizar credencial existente" @@ -1239,7 +1272,7 @@ "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?" @@ -1266,7 +1299,7 @@ "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": { @@ -1293,7 +1326,7 @@ "message": "Exportar de" }, "exportVault": { - "message": "Exportar Cofre" + "message": "Exportar cofre" }, "fileFormat": { "message": "Formato do arquivo" @@ -1308,10 +1341,10 @@ "message": "Esta senha será usada para exportar e importar este arquivo" }, "accountRestrictedOptionDescription": { - "message": "Use a chave de criptografia da sua conta, derivada do nome de usuário e senha mestra da sua conta, para criptografar a exportação e restringir a 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 para criptografar a exportação e importá-la para qualquer conta do Bitwarden usando a senha para descriptografar." + "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" @@ -1320,21 +1353,21 @@ "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": { - "message": "Aviso", + "message": "Atenção", "description": "Warning (should maintain locale-relevant capitalization)" }, "confirmVaultExport": { "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." @@ -1343,7 +1376,7 @@ "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" @@ -1371,7 +1404,28 @@ "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" + }, + "migrationsFailed": { + "message": "Ocorreu um erro ao atualizar as configurações de criptografia." + }, + "updateEncryptionSettingsTitle": { + "message": "Atualize suas configurações de criptografia" + }, + "updateEncryptionSettingsDesc": { + "message": "As novas configurações de criptografia recomendadas melhorarão a segurança da sua conta. Digite sua senha principal para atualizar agora." + }, + "confirmIdentityToContinue": { + "message": "Confirme sua identidade para continuar" + }, + "enterYourMasterPassword": { + "message": "Digite sua senha principal" + }, + "updateSettings": { + "message": "Atualizar configurações" + }, + "later": { + "message": "Depois" }, "authenticatorKeyTotp": { "message": "Chave do autenticador (TOTP)" @@ -1386,10 +1440,10 @@ "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 apagado" @@ -1398,7 +1452,7 @@ "message": "Adicionar novo anexo" }, "noAttachments": { - "message": "Sem anexos." + "message": "Nenhum anexo." }, "attachmentSaved": { "message": "Anexo salvo" @@ -1410,7 +1464,7 @@ "message": "Arquivo para compartilhar" }, "selectFile": { - "message": "Selecione um arquivo." + "message": "Selecione um arquivo" }, "maxFileSize": { "message": "O tamanho máximo do arquivo é de 500 MB." @@ -1428,19 +1482,28 @@ "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": "Recarregar assinatura" + "message": "Recarregar plano" }, "premiumNotCurrentMember": { "message": "Você não é um membro Premium atualmente." }, "premiumSignUpAndGet": { - "message": "Inscreva-se para uma assinatura Premium e receba:" + "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." + }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ de armazenamento criptografado para anexos de arquivo.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } }, "premiumSignUpEmergency": { "message": "Acesso de emergência." @@ -1449,7 +1512,7 @@ "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." @@ -1458,7 +1521,7 @@ "message": "Prioridade no suporte ao cliente." }, "ppremiumSignUpFuture": { - "message": "Todas as recursos do Premium no futuro. Mais em breve!" + "message": "Todos as recursos do Premium no futuro. Mais em breve!" }, "premiumPurchase": { "message": "Comprar Premium" @@ -1476,7 +1539,7 @@ "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", @@ -1494,7 +1557,7 @@ } }, "refreshComplete": { - "message": "Atualização completa" + "message": "Recarregamento concluído" }, "enableAutoTotpCopy": { "message": "Copiar TOTP automaticamente" @@ -1505,14 +1568,8 @@ "enableAutoBiometricsPrompt": { "message": "Pedir biometria ao abrir" }, - "premiumRequired": { - "message": "Requer Assinatura Premium" - }, - "premiumRequiredDesc": { - "message": "Uma assinatura Premium é necessária para usar esse recurso." - }, "authenticationTimeout": { - "message": "Tempo de autenticação esgotado" + "message": "Tempo limite da autenticação atingido" }, "authenticationSessionTimedOut": { "message": "A sessão de autenticação expirou. Reinicie o processo de autenticação." @@ -1537,7 +1594,7 @@ "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 em uma nova aba" @@ -1555,7 +1612,7 @@ "message": "Falha na autenticação da chave de acesso" }, "useADifferentLogInMethod": { - "message": "Usar um método de entrada diferente" + "message": "Usar um método de autenticação diferente" }, "awaitingSecurityKeyInteraction": { "message": "Aguardando interação com a chave de segurança..." @@ -1564,10 +1621,10 @@ "message": "Autenticação indisponível" }, "noTwoStepProviders": { - "message": "Esta conta tem a verificação de duas etapas ativada, 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 autenticação em duas etapas" @@ -1589,14 +1646,14 @@ "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": "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": { @@ -1615,19 +1672,22 @@ "message": "Ambiente auto-hospedado" }, "selfHostedBaseUrlHint": { - "message": "Especifique o0 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 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" }, "baseUrl": { - "message": "URL do Servidor" + "message": "URL do servidor" }, "selfHostBaseUrl": { "message": "URL do servidor auto-hospedado", @@ -1676,9 +1736,30 @@ "turnOffAutofill": { "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 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" }, @@ -1692,7 +1773,7 @@ "message": "Aplica-se a todas as contas conectadas." }, "turnOffBrowserBuiltInPasswordManagerSettings": { - "message": "Desative o gerenciador de senhas integrado do seu navegador para evitar conflitos." + "message": "Desative o gerenciador de senhas embutido no seu navegador para evitar conflitos." }, "turnOffBrowserBuiltInPasswordManagerSettingsLink": { "message": "Edite as configurações do navegador." @@ -1728,7 +1809,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." @@ -1749,7 +1830,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" @@ -1758,7 +1839,7 @@ "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" @@ -1776,7 +1857,7 @@ "message": "Novo campo personalizado" }, "dragToSort": { - "message": "Arrastar para ordenar" + "message": "Arraste para ordenar" }, "dragToReorder": { "message": "Arraste para reorganizar" @@ -1785,7 +1866,7 @@ "message": "Texto" }, "cfTypeHidden": { - "message": "Ocultado" + "message": "Oculto" }, "cfTypeBoolean": { "message": "Booleano" @@ -1802,7 +1883,7 @@ "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": "Mostrar ícones de sites e obter URLs de alteração de senha" @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Ano de vencimento" }, + "monthly": { + "message": "mês" + }, "expiration": { "message": "Vencimento" }, @@ -1868,7 +1952,7 @@ "message": "número do cartão" }, "ex": { - "message": "ex." + "message": "p. ex." }, "title": { "message": "Título" @@ -1934,10 +2018,10 @@ "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" @@ -2026,23 +2110,23 @@ "description": "Header for edit file send" }, "viewItemHeaderLogin": { - "message": "Visualizar credencial", + "message": "Ver credencial", "description": "Header for view login item type" }, "viewItemHeaderCard": { - "message": "Visualizar cartão", + "message": "Ver cartão", "description": "Header for view card item type" }, "viewItemHeaderIdentity": { - "message": "Visualizar identidade", + "message": "Ver identidade", "description": "Header for view identity item type" }, "viewItemHeaderNote": { - "message": "Visualizar anotação", + "message": "Ver anotação", "description": "Header for view note item type" }, "viewItemHeaderSshKey": { - "message": "Visualizar chave SSH", + "message": "Ver chave SSH", "description": "Header for view SSH key item type" }, "passwordHistory": { @@ -2061,10 +2145,10 @@ "message": "Voltar" }, "collections": { - "message": "Coleções" + "message": "Conjuntos" }, "nCollections": { - "message": "$COUNT$ coleções", + "message": "$COUNT$ conjuntos", "placeholders": { "count": { "content": "$1", @@ -2079,7 +2163,7 @@ "message": "Abrir em uma nova janela" }, "refresh": { - "message": "Atualizar" + "message": "Recarregar" }, "cards": { "message": "Cartões" @@ -2101,10 +2185,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", @@ -2113,7 +2197,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", @@ -2171,7 +2255,7 @@ "message": "Todos os itens" }, "noPasswordsInList": { - "message": "Não existem senhas para listar." + "message": "Não há senhas para listar." }, "clearHistory": { "message": "Limpar histórico" @@ -2201,19 +2285,19 @@ "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", @@ -2228,17 +2312,17 @@ "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": "Configurar PIN" @@ -2247,10 +2331,10 @@ "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": "Você pode usar este PIN para desbloquear o Bitwarden. O seu PIN será redefinido se você sair da sua conta no aplicativo." + "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." @@ -2262,25 +2346,25 @@ "message": "Muitas tentativas de entrada de PIN inválidas. Desconectando." }, "unlockWithBiometrics": { - "message": "Desbloquear com a biometria" + "message": "Desbloquear com biometria" }, "unlockWithMasterPassword": { - "message": "Desbloquear com senha mestra" + "message": "Desbloquear com senha principal" }, "awaitDesktop": { - "message": "Aguardando confirmação do desktop" + "message": "Aguardando confirmação do computador" }, "awaitDesktopDesc": { "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" @@ -2298,16 +2382,16 @@ "message": "Usar este e-mail" }, "useThisPassword": { - "message": "Use esta senha" + "message": "Usar esta senha" }, "useThisPassphrase": { - "message": "Use esta frase secreta" + "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": "Use o gerador", @@ -2335,13 +2419,13 @@ "description": "Noun: a special folder to hold deleted items" }, "searchTrash": { - "message": "Pesquisar na lixeira" + "message": "Buscar na lixeira" }, "permanentlyDeleteItem": { "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 apagado para sempre" @@ -2356,7 +2440,7 @@ "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 da ação do tempo limite" @@ -2391,20 +2475,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": "Confirmar 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$", @@ -2425,16 +2512,16 @@ } }, "policyInEffectUppercase": { - "message": "Contém um ou mais caracteres em maiúsculo" + "message": "Conter um ou mais caracteres em maiúsculo" }, "policyInEffectLowercase": { - "message": "Contém um ou mais caracteres em minúsculo" + "message": "Conter um ou mais caracteres em minúsculo" }, "policyInEffectNumbers": { - "message": "Contém um ou mais números" + "message": "Conter um ou mais números" }, "policyInEffectSpecial": { - "message": "Contém um ou mais dos seguintes caracteres especiais $CHARS$", + "message": "Conter um ou mais dos seguintes caracteres especiais $CHARS$", "placeholders": { "chars": { "content": "$1", @@ -2443,7 +2530,7 @@ } }, "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": "Receba conselhos, novidades, e oportunidades de pesquisa do Bitwarden em sua caixa de entrada." @@ -2476,7 +2563,7 @@ "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" @@ -2491,7 +2578,7 @@ "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 foi configurada" @@ -2500,40 +2587,40 @@ "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 do Bitwarden para desktop precisa ser iniciado antes que o desbloqueio com biometria possa ser usado." + "message": "O aplicativo Bitwarden para computador precisa ser aberto antes que o desbloqueio com biometria possa ser usado." }, "errorEnableBiometricTitle": { "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": "Não correspondência da chave biométrica" }, "nativeMessagingWrongUserKeyDesc": { - "message": "O desbloqueio biométrico falhou. A chave secreta da biometria falhou ao desbloquear o cofre. Tente configurar a biometrica 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 configurada" }, "biometricsNotEnabledDesc": { - "message": "A biometria com o navegador requer que a biometria de desktop seja ativada 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" @@ -2557,22 +2644,22 @@ "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ê não pode salvar itens no 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." @@ -2581,7 +2668,7 @@ "message": "Uma política da organização bloqueou a importação de itens em seu cofre pessoal." }, "restrictCardTypeImport": { - "message": "Não é possível importar tipos de item de 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 em seus cofres." @@ -2603,10 +2690,10 @@ "message": "O Bitwarden não irá pedir para salvar os detalhes de credencial para estes domínios. Você deve atualizar a página para que as alterações entrem em vigor." }, "excludedDomainsDescAlt": { - "message": "O Bitwarden não irá pedir para salvar os detalhes de credencial para estes domínios, em todas as contas. Você deve 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 não serão oferecidos 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." @@ -2637,7 +2724,7 @@ "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", @@ -2646,7 +2733,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", @@ -2659,7 +2746,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", @@ -2668,7 +2755,7 @@ } }, "atRiskChangePrompt": { - "message": "Sua senha para este site está em risco. $ORGANIZATION$ solicitou alterá-la.", + "message": "Sua senha para este site está em risco. $ORGANIZATION$ solicitou que você altere ela.", "placeholders": { "organization": { "content": "$1", @@ -2739,10 +2826,10 @@ "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)", @@ -2766,7 +2853,7 @@ "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ções" @@ -2814,7 +2901,7 @@ "message": "Ocultar texto por padrão" }, "expired": { - "message": "Expirado" + "message": "Vencido" }, "passwordProtected": { "message": "Protegido por senha" @@ -2827,10 +2914,10 @@ "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" @@ -2850,11 +2937,11 @@ "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": { @@ -2906,7 +2993,7 @@ "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": { @@ -2918,7 +3005,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendExpiresInHoursSingle": { - "message": "O Send estará disponível para qualquer pessoa com o link pela próxima 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": { @@ -2932,7 +3019,7 @@ } }, "sendExpiresInDaysSingle": { - "message": "O Send estará disponível para qualquer pessoa com o link pelo próximo 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": { @@ -2954,7 +3041,7 @@ "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": { @@ -2971,7 +3058,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" @@ -2980,28 +3067,28 @@ "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": "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" @@ -3013,28 +3100,28 @@ "message": "Você precisa verificar o seu e-mail para usar este recurso. Você pode verificar seu e-mail no cofre web." }, "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ê entre 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ê entre 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. 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" }, "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..." @@ -3044,11 +3131,11 @@ "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": { @@ -3071,10 +3158,10 @@ "message": "Minutos" }, "vaultTimeoutPolicyAffectingOptions": { - "message": "Os requisitos de política empresarial foram aplicados às opções de tempo limite" + "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 definiram o tempo máximo de limite para o cofre como $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", @@ -3113,7 +3200,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", @@ -3130,7 +3217,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", @@ -3139,7 +3226,7 @@ } }, "vaultTimeoutTooLarge": { - "message": "O tempo limite do seu 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 indisponível" @@ -3148,28 +3235,28 @@ "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": "Uma senha mestra não é mais necessária para os membros da seguinte organização. Confirme o domínio abaixo com o administrador da sua organização." + "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": "Nome da organização" }, "keyConnectorDomain": { - "message": "Domínio do Conector de Chave" + "message": "Domínio do Key Connector" }, "leaveOrganization": { "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?" @@ -3181,7 +3268,7 @@ "message": "Habilitar contagem de caracteres" }, "sessionTimeout": { - "message": "Sua sessão expirou. Volte e tente entrar novamente." + "message": "Sua sessão expirou. Volte e tente se conectar novamente." }, "exportingPersonalVaultTitle": { "message": "Exportando cofre individual" @@ -3226,7 +3313,7 @@ } }, "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { - "message": "Apenas o cofre da organização associado com $ORGANIZATION$ será exportado. Os itens da minhas coleções não serão incluídos.", + "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", @@ -3238,17 +3325,20 @@ "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 conseguiu descriptografar o(s) item(ns) do cofre listado abaixo." }, "contactCSToAvoidDataLossPart1": { - "message": "Contate o sucesso do cliente", + "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": { - "message": "para evitar a perca adicional dos dados.", + "message": "para evitar a perca de dados adicionais.", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "generateUsername": { @@ -3302,7 +3392,7 @@ "message": "E-mail pega-tudo" }, "catchallEmailDesc": { - "message": "Use o pega-tudo configurado na caixa de entrada do seu domínio." + "message": "Use a caixa de entrada pega-tudo configurada no seu domínio." }, "random": { "message": "Aleatório" @@ -3317,10 +3407,10 @@ "message": "Serviço" }, "forwardedEmail": { - "message": "Alias do e-mail encaminhado" + "message": "Alias de encaminhamento de e-mail" }, "forwardedEmailDesc": { - "message": "Gere um alias 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", @@ -3331,7 +3421,7 @@ "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": { @@ -3359,7 +3449,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": { @@ -3369,7 +3459,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": { @@ -3417,7 +3507,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": { @@ -3427,7 +3517,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": { @@ -3437,7 +3527,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": { @@ -3467,10 +3557,10 @@ "message": "Chave da API" }, "ssoKeyConnectorError": { - "message": "Erro do conector de chave: certifique-se de que o conector de chave está disponível e funcionando corretamente." + "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 suspensa." @@ -3479,7 +3569,7 @@ "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": "Entrando em $DOMAIN$", + "message": "Conectando-se a $DOMAIN$", "placeholders": { "domain": { "content": "$1", @@ -3497,7 +3587,7 @@ "message": "Terceiros" }, "thirdPartyServerMessage": { - "message": "Conectado à implementação de servidores terceiros, $SERVERNAME$. Verifique bugs 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", @@ -3515,7 +3605,7 @@ } }, "loginWithMasterPassword": { - "message": "Entrar com a senha mestra" + "message": "Conectar-se com senha principal" }, "newAroundHere": { "message": "Novo por aqui?" @@ -3524,7 +3614,7 @@ "message": "Lembrar e-mail" }, "loginWithDevice": { - "message": "Entrar com dispositivo" + "message": "Conectar-se com dispositivo" }, "fingerprintPhraseHeader": { "message": "Frase biométrica" @@ -3579,7 +3669,7 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "Você negou uma tentativa de autenticação de outro dispositivo. Se era você, tente entrar com o dispositivo novamente." + "message": "Você negou uma tentativa de autenticação de outro dispositivo. Se era você, tente se conectar com o dispositivo novamente." }, "device": { "message": "Dispositivo" @@ -3588,16 +3678,16 @@ "message": "Estado de autenticação" }, "masterPasswordChanged": { - "message": "Senha mestre salva" + "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?" @@ -3609,7 +3699,7 @@ "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": "Mínimo de $LENGTH$ caracteres", @@ -3654,7 +3744,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.", @@ -3793,10 +3883,10 @@ "message": "Não é possível concluir a autenticação" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { - "message": "Você precisa entrar com 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." }, "ssoIdentifierRequired": { - "message": "Identificador de SSO da organização é necessário." + "message": "O identificador de SSO da organização é necessário." }, "creatingAccountOn": { "message": "Criando conta em" @@ -3814,7 +3904,7 @@ "message": "Nenhum e-mail?" }, "goBack": { - "message": "Voltar" + "message": "Volte" }, "toEditYourEmailAddress": { "message": "para editar o seu endereço de e-mail." @@ -3851,7 +3941,7 @@ "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 confiado" @@ -3869,13 +3959,13 @@ "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 frase biométrica dele coincide com a que é exibida em sua conta" + "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": "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." + "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": "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 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": "Confiar no usuário" @@ -3895,7 +3985,7 @@ "message": "obrigatório" }, "search": { - "message": "Pesquisar" + "message": "Buscar" }, "inputMinLength": { "message": "A entrada deve ter pelo menos $COUNT$ caracteres.", @@ -3943,7 +4033,7 @@ } }, "multipleInputEmails": { - "message": "Um ou mais e-mails são inválidos" + "message": "1 ou mais e-mails são inválidos" }, "inputTrimValidator": { "message": "A entrada não pode conter somente espaços em branco.", @@ -3962,7 +4052,7 @@ } }, "singleFieldNeedsAttention": { - "message": "Um campo precisa da sua atenção." + "message": "1 campo precisa de sua atenção." }, "multipleFieldsNeedAttention": { "message": "$COUNT$ campos precisam da sua atenção.", @@ -4005,12 +4095,21 @@ "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": "Habilitar navegação lateral" }, @@ -4042,7 +4141,7 @@ "description": "Button text to display in overlay when the account is locked." }, "unlockAccountAria": { - "message": "Desbloqueie sua conta, abre 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": { @@ -4070,7 +4169,7 @@ "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": { @@ -4086,7 +4185,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 de cartão no 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": { @@ -4139,10 +4238,10 @@ "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": "Verificar com biometria" @@ -4151,13 +4250,13 @@ "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" @@ -4190,10 +4289,10 @@ "message": "A autenticação em duas etapas do Duo é necessária para sua conta." }, "popoutExtension": { - "message": "Mostrar extensão" + "message": "Criar janela da extensão" }, "launchDuo": { - "message": "Abrir o Duo" + "message": "Abrir Duo" }, "importFormatError": { "message": "Os dados não estão formatados corretamente. Verifique o seu arquivo de importação e tente novamente." @@ -4217,13 +4316,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 para uma 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 para uma 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." @@ -4235,7 +4334,7 @@ "message": "Selecione o arquivo de importação" }, "chooseFile": { - "message": "Escolher arquivo" + "message": "Selecionar arquivo" }, "noFileChosen": { "message": "Nenhum arquivo escolhido" @@ -4281,7 +4380,7 @@ "message": "A chave de acesso não será copiada para o item clonado. Deseja continuar clonando este item?" }, "logInWithPasskeyQuestion": { - "message": "Entrar com chave de acesso?" + "message": "Conectar-se com chave de acesso?" }, "passkeyAlreadyExists": { "message": "Uma chave de acesso já existe para este aplicativo." @@ -4296,7 +4395,7 @@ "message": "Sem credenciais correspondentes para este site" }, "searchSavePasskeyNewLogin": { - "message": "Salvar ou pesquisar chave de acesso como uma nova credencial" + "message": "Buscar ou salvar chave de acesso como nova credencial" }, "confirm": { "message": "Confirmar" @@ -4305,19 +4404,19 @@ "message": "Salvar chave de acesso" }, "savePasskeyNewLogin": { - "message": "Salvar chave de acesso como uma nova credencial" + "message": "Salvar chave de acesso como nova credencial" }, "chooseCipherForPasskeySave": { "message": "Escolha uma credencial para salvar essa chave de acesso" }, "chooseCipherForPasskeyAuth": { - "message": "Escolha uma chave para conectar-se" + "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?" @@ -4359,7 +4458,7 @@ "message": "Importando sua conta..." }, "lastPassMFARequired": { - "message": "Requer autenticação multifatorial do LastPass" + "message": "Autenticação multifatorial do LastPass é necessária" }, "lastPassMFADesc": { "message": "Digite sua senha única do app autenticador" @@ -4380,7 +4479,7 @@ "message": "Aguardando autenticação do SSO" }, "awaitingSSODesc": { - "message": "Continue autenticando-se 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", @@ -4396,7 +4495,7 @@ "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 dela." @@ -4405,10 +4504,10 @@ "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" @@ -4420,7 +4519,7 @@ "message": "Contas disponíveis" }, "accountLimitReached": { - "message": "Limite de contas atingido. Saia de uma conta para adicionar outra." + "message": "Limite de contas atingido. Desconecte uma conta para adicionar outra." }, "active": { "message": "ativo" @@ -4438,7 +4537,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" @@ -4472,7 +4571,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": { @@ -4484,7 +4583,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": { @@ -4492,7 +4591,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": { @@ -4500,7 +4599,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": { @@ -4516,11 +4615,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": { @@ -4563,16 +4662,16 @@ "message": "Itens sugeridos" }, "autofillSuggestionsTip": { - "message": "Salvar um item de credencial para este site para o preenchimento automático" + "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": "Limpe os filtros ou tente outro termo de pesquisa" + "message": "Limpe os filtros ou tente outro termo de busca" }, "copyInfoTitle": { "message": "Copiar informações - $ITEMNAME$", @@ -4615,7 +4714,7 @@ } }, "viewItemTitle": { - "message": "Visualizar item - $ITEMNAME$", + "message": "Ver item - $ITEMNAME$", "description": "Title for a link that opens a view for an item.", "placeholders": { "itemname": { @@ -4625,7 +4724,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": { @@ -4680,7 +4779,7 @@ "message": "Não há valores para copiar" }, "assignToCollections": { - "message": "Atribuir à coleções" + "message": "Atribuir a conjuntos" }, "copyEmail": { "message": "Copiar e-mail" @@ -4704,13 +4803,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": { @@ -4813,10 +4912,10 @@ "message": "Baixar o Bitwarden" }, "downloadBitwardenOnAllDevices": { - "message": "Baixe 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 do Bitwarden." @@ -4825,7 +4924,7 @@ "message": "Baixe o aplicativo para computador" }, "getTheDesktopAppDesc": { - "message": "Acesse o seu cofre sem um navegador e, em seguida, configure o desbloqueio com biometria para facilitar o desbloqueio tanto no aplicativo de computador 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" @@ -4842,6 +4941,9 @@ "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" }, @@ -4926,6 +5028,16 @@ } } }, + "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": "Mostrar detecção de correspondência $WEBSITE$", "placeholders": { @@ -4986,17 +5098,17 @@ "description": "A section header for a list of passwords." }, "logInWithPasskeyAriaLabel": { - "message": "Entrar com 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.", @@ -5023,7 +5135,7 @@ "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" @@ -5068,7 +5180,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", @@ -5077,7 +5189,7 @@ } }, "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$ movido para cima, posição $INDEX$ de $LENGTH$", @@ -5097,13 +5209,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 ser dono deste 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": "$PERSONAL_ITEMS_COUNT$ itens serão transferidos permanentemente para a organização selecionada. Você não irá mais ser dono desses 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", @@ -5112,7 +5224,7 @@ } }, "personalItemWithOrgTransferWarningSingular": { - "message": "1 item será transferido permanentemente para $ORG$. Você não irá mais ser dono deste item.", + "message": "1 item será transferido permanentemente para $ORG$. Você não irá mais ser o proprietário deste item.", "placeholders": { "org": { "content": "$1", @@ -5121,7 +5233,7 @@ } }, "personalItemsWithOrgTransferWarningPlural": { - "message": "$PERSONAL_ITEMS_COUNT$ itens serão transferidos permanentemente para $ORG$. Você não irá mais ser dono desses 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", @@ -5134,7 +5246,7 @@ } }, "successfullyAssignedCollections": { - "message": "Coleções atribuídas com sucesso" + "message": "Conjuntos atribuídos com sucesso" }, "nothingSelected": { "message": "Você não selecionou nada." @@ -5187,7 +5299,7 @@ "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": "Acesso à conta solicitado" @@ -5202,7 +5314,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" @@ -5277,10 +5389,10 @@ "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 computador não está aberto." @@ -5301,23 +5413,23 @@ "message": "Desbloqueie seu cofre em segundos" }, "unlockVaultDesc": { - "message": "Você pode personalizar suas configurações de desbloqueio e tempo limite para acessar mais rapidamente seu cofre." + "message": "Você pode personalizar suas configurações de desbloqueio e tempo limite para acessar seu cofre mais rapidamente." }, "unlockPinSet": { - "message": "Desbloqueio com PIN definido" + "message": "PIN de desbloqueio configurado" }, "unlockWithBiometricSet": { - "message": "Desbloqueio com biometria definido" + "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": { @@ -5361,11 +5473,11 @@ "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": { @@ -5493,7 +5605,7 @@ "message": "Digite a senha da chave SSH." }, "enterSshKeyPassword": { - "message": "Digite a senha" + "message": "Digitar senha" }, "invalidSshKey": { "message": "A chave SSH é inválida" @@ -5508,7 +5620,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", @@ -5520,7 +5632,7 @@ "message": "Atualize seu aplicativo de computador" }, "updateDesktopAppOrDisableFingerprintDialogMessage": { - "message": "Para usar o desbloqueio biométrica, atualize seu aplicativo de computador ou desative o desbloqueio biométrico nas configurações do desktop." + "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 em risco" @@ -5550,7 +5662,7 @@ "message": "Autenticação rápida e fácil" }, "quickLoginBody": { - "message": "Configure 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": "Dê um up nas suas credenciais" @@ -5596,7 +5708,7 @@ "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 para roubar informações pessoais e sensíveis.", + "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": { @@ -5612,16 +5724,16 @@ } }, "hasItemsVaultNudgeBodyOne": { - "message": "Preencher itens automaticamente para a página atual" + "message": "Preenche automaticamente itens para a página atual" }, "hasItemsVaultNudgeBodyTwo": { "message": "Favorite itens para acesso rápido" }, "hasItemsVaultNudgeBodyThree": { - "message": "Pesquise seu cofre por outra coisa" + "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": "Inclua um", @@ -5690,10 +5802,10 @@ "message": "Sobre esta configuração" }, "permitCipherDetailsDescription": { - "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." + "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": "Você não tem permissão para visualizar esta página. Tente entrar com uma conta diferente." + "message": "Você não tem permissão para ver esta página. Tente se conectar com uma conta diferente." }, "wasmNotSupported": { "message": "O WebAssembly não é suportado no seu navegador ou não está ativado. O WebAssembly é necessário para utilizar o aplicativo do Bitwarden.", @@ -5713,13 +5825,61 @@ "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": "Confirmar domínio do Conector de Chave" + "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 db2eb776d7f..c03f3038f98 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" }, @@ -582,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" }, @@ -788,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" @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Saber mais" }, + "migrationsFailed": { + "message": "Ocorreu um erro ao atualizar as definições de encriptação." + }, + "updateEncryptionSettingsTitle": { + "message": "Atualize as suas definições de encriptação" + }, + "updateEncryptionSettingsDesc": { + "message": "As novas definições de encriptação recomendadas irão melhorar a segurança da sua conta. Introduza a sua palavra-passe mestra para atualizar agora." + }, + "confirmIdentityToContinue": { + "message": "Confirme a sua identidade para continuar" + }, + "enterYourMasterPassword": { + "message": "Introduza a sua palavra-passe mestra" + }, + "updateSettings": { + "message": "Atualizar definições" + }, + "later": { + "message": "Mais tarde" + }, "authenticatorKeyTotp": { "message": "Chave de autenticação (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB de armazenamento encriptado para anexos de ficheiros." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ de armazenamento encriptado para anexos de ficheiros.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Acesso de emergência." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Ano de validade" }, + "monthly": { + "message": "mês" + }, "expiration": { "message": "Prazo de validade" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "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." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,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" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 4b2913ce55b..85db8d8a4f3 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "La blocarea sistemului" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "La repornirea browserului" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Aflați mai multe" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Cheie de autentificare (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB spațiu de stocare criptat pentru atașamente de fișiere." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Anul expirării" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expirare" }, @@ -2391,6 +2475,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ă" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 8661d78552e..58f2b372459 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": "С возвращением" }, @@ -582,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Архивированные элементы исключены из общих результатов поиска и предложений автозаполнения. Вы уверены, что хотите архивировать этот элемент?" }, + "upgradeToUseArchive": { + "message": "Для использования архива требуется премиум-статус." + }, "edit": { "message": "Изменить" }, "view": { "message": "Просмотр" }, + "viewAll": { + "message": "Посмотреть все" + }, + "showAll": { + "message": "Показать все" + }, + "viewLess": { + "message": "Свернуть" + }, "viewLogin": { "message": "Просмотр логина" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "Вместе с компьютером" }, + "onIdle": { + "message": "При бездействии" + }, + "onSleep": { + "message": "В режиме сна" + }, "onRestart": { "message": "При перезапуске браузера" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "Элемент сохранен" }, + "savedWebsite": { + "message": "Сохраненный сайт" + }, + "savedWebsites": { + "message": "Сохраненные сайты ( $COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Вы действительно хотите отправить в корзину?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Подробнее" }, + "migrationsFailed": { + "message": "Произошла ошибка при обновлении настроек шифрования." + }, + "updateEncryptionSettingsTitle": { + "message": "Обновите настройки шифрования" + }, + "updateEncryptionSettingsDesc": { + "message": "Новые рекомендуемые настройки шифрования повысят безопасность вашего аккаунта. Введите мастер-пароль, чтобы обновить сейчас." + }, + "confirmIdentityToContinue": { + "message": "Подтвердите вашу личность, чтобы продолжить" + }, + "enterYourMasterPassword": { + "message": "Введите ваш мастер-пароль" + }, + "updateSettings": { + "message": "Обновить настройки" + }, + "later": { + "message": "Позже" + }, "authenticatorKeyTotp": { "message": "Ключ аутентификатора (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 ГБ зашифрованного хранилища для вложенных файлов." }, + "premiumSignUpStorageV2": { + "message": "Зашифрованного хранилища для вложенных файлов: $SIZE$", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Экстренный доступ" }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "Запрашивать биометрию при запуске" }, - "premiumRequired": { - "message": "Требуется Премиум" - }, - "premiumRequiredDesc": { - "message": "Для использования этой функции необходим Премиум." - }, "authenticationTimeout": { "message": "Таймаут аутентификации" }, @@ -1623,6 +1680,9 @@ "selfHostedEnvFormInvalid": { "message": "Вы должны добавить либо базовый URL сервера, либо хотя бы одно пользовательское окружение." }, + "selfHostedEnvMustUseHttps": { + "message": "URL должны использовать HTTPS." + }, "customEnvironment": { "message": "Пользовательское окружение" }, @@ -1676,9 +1736,30 @@ "turnOffAutofill": { "message": "Отключить автозаполнение" }, + "confirmAutofill": { + "message": "Подтвердите автозаполнение" + }, + "confirmAutofillDesc": { + "message": "Этот сайт не соответствует вашим сохраненным логинам. Прежде чем заполнять логин, убедитесь, что это надежный сайт." + }, "showInlineMenuLabel": { "message": "Показывать предположения автозаполнения в полях формы" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Как Bitwarden защищает ваши данные от фишинга?" + }, + "currentWebsite": { + "message": "Текущий сайт" + }, + "autofillAndAddWebsite": { + "message": "Заполнить и добавить этот сайт" + }, + "autofillWithoutAdding": { + "message": "Заполнить без добавления" + }, + "doNotAutofill": { + "message": "Не заполнять" + }, "showInlineMenuIdentitiesLabel": { "message": "Показывать Личную информацию как предложения" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Год" }, + "monthly": { + "message": "месяц" + }, "expiration": { "message": "Срок действия" }, @@ -2391,6 +2475,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Эта страница мешает работе Bitwarden. Встроенное меню Bitwarden было временно отключено в целях безопасности." + }, "setMasterPassword": { "message": "Задать мастер-пароль" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Ошибка расшифровки" }, + "errorGettingAutoFillData": { + "message": "Ошибка получения данных автозаполнения" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden не удалось расшифровать элемент(ы) хранилища, перечисленные ниже." }, @@ -4011,6 +4101,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": "Переключить боковую навигацию" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Премиум" }, + "unlockFeaturesWithPremium": { + "message": "Разблокируйте отчеты, экстренный доступ и другие функции безопасности с помощью Премиум." + }, "freeOrgsCannotUseAttachments": { "message": "Бесплатные организации не могут использовать вложения" }, @@ -4926,6 +5028,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": { @@ -5526,10 +5638,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": "Настройки хранилища" @@ -5709,17 +5821,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": "Great job securing your at-risk logins!" + "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 649556ca64b..ead646d0ac0 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "පද්ධතිය ලොක් මත" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "බ්රව්සරය නැවත ආරම්භ" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "සංස්කරණය කරන ලද අයිතමය" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "ඔබට ඇත්තටම කුණු කූඩයට යැවීමට අවශ්යද?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "වැඩිදුර ඉගෙන ගන්න" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "සත්යාපන යතුර (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "ගොනු ඇමුණුම් සඳහා 1 GB සංකේතාත්මක ගබඩා." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "වාරික අවශ්ය" - }, - "premiumRequiredDesc": { - "message": "මෙම අංගය භාවිතා කිරීම සඳහා වාරික සාමාජිකත්වයක් අවශ්ය වේ." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1623,6 +1680,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": "අභිරුචි පරිසරය" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "කල් ඉකුත්වන වසර" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "කල් ඉකුත්" }, @@ -2391,6 +2475,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": "මාස්ටර් මුරපදය සකසන්න" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 fe86ad298c9..70620e6c5e5 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" @@ -582,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" }, @@ -676,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" @@ -790,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" }, @@ -877,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" @@ -967,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." @@ -979,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" @@ -1028,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" @@ -1260,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." @@ -1296,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." @@ -1373,8 +1406,29 @@ "learnMore": { "message": "Zistiť viac" }, + "migrationsFailed": { + "message": "Pri aktualizácii nastavení šifrovania došlo k chybe." + }, + "updateEncryptionSettingsTitle": { + "message": "Aktualizujte nastavenie šifrovania" + }, + "updateEncryptionSettingsDesc": { + "message": "Nové odporúčané nastavenia šifrovania zlepšia bezpečnosť vášho účtu. Ak ich chcete aktualizovať teraz, zadajte hlavné heslo." + }, + "confirmIdentityToContinue": { + "message": "Ak chcete pokračovať, potvrďte svoju identitu" + }, + "enterYourMasterPassword": { + "message": "Zadajte hlavné heslo" + }, + "updateSettings": { + "message": "Aktualizovať nastavenia" + }, + "later": { + "message": "Neskôr" + }, "authenticatorKeyTotp": { - "message": "Kľúč overovateľa (TOTP)" + "message": "Overovací kľúč (TOTP)" }, "verificationCodeTotp": { "message": "Overovací kód (TOTP)" @@ -1392,7 +1446,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" @@ -1401,7 +1455,7 @@ "message": "Žiadne prílohy." }, "attachmentSaved": { - "message": "Príloha bola uložená." + "message": "Príloha bola uložená" }, "file": { "message": "Súbor" @@ -1437,13 +1491,22 @@ "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." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ šifrovaného úložiska na prílohy.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "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." @@ -1473,7 +1536,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!", @@ -1494,23 +1557,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" }, @@ -1576,7 +1633,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" @@ -1596,14 +1653,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" @@ -1623,6 +1680,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" }, @@ -1643,13 +1703,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", @@ -1676,9 +1736,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" }, @@ -1706,32 +1787,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" @@ -1808,7 +1889,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" @@ -1817,10 +1898,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" @@ -1892,7 +1976,7 @@ "message": "Krstné meno" }, "middleName": { - "message": "Druhé meno" + "message": "Stredné meno" }, "lastName": { "message": "Priezvisko" @@ -1907,13 +1991,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" @@ -2150,10 +2234,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": { @@ -2344,13 +2428,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?" @@ -2362,13 +2446,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é" @@ -2380,7 +2464,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.", @@ -2391,6 +2475,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" }, @@ -2503,10 +2590,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" @@ -2557,7 +2644,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é" @@ -2833,10 +2920,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": { @@ -2895,7 +2982,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": { @@ -2910,7 +2997,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": { @@ -2950,7 +3037,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": { @@ -3016,7 +3103,7 @@ "message": "Hlavné heslo bolo úspešne nastavené" }, "updatedMasterPassword": { - "message": "Hlavné heslo aktualizované" + "message": "Hlavné heslo bolo aktualizované" }, "updateMasterPassword": { "message": "Aktualizovať hlavné heslo" @@ -3074,7 +3161,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", @@ -3142,10 +3229,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." @@ -3169,7 +3256,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?" @@ -3240,6 +3327,9 @@ "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." }, @@ -3331,7 +3421,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": { @@ -3473,10 +3563,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$", @@ -3506,7 +3596,7 @@ } }, "lastSeenOn": { - "message": "naposledy videné $DATE$", + "message": "naposledy videný: $DATE$", "placeholders": { "date": { "content": "$1", @@ -3524,10 +3614,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í." @@ -3591,16 +3681,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" @@ -3612,7 +3702,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", @@ -3633,7 +3723,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" @@ -4008,9 +4098,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" }, @@ -4070,7 +4169,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": { @@ -4275,7 +4374,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?" @@ -4305,7 +4404,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ľúč" @@ -4435,10 +4534,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" @@ -4512,7 +4611,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": { @@ -4816,7 +4915,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." @@ -4842,6 +4941,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" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 cd7eda9a4fa..4867f4b89f2 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "Ob zaklepu sistema" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "Ob ponovnem zagonu brskalnika" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Več o tem" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Ključ avtentikatorja (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB šifriranega prostora za shrambo podatkov." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Leto poteka" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Veljavna do" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 3421dc1fae1..c04c113caca 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": "Добродошли назад" }, @@ -582,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": "Преглед пријаве" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "На закључавање система" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "На покретање прегледача" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "Ставка уређена" }, + "savedWebsite": { + "message": "Сачувана веб локација" + }, + "savedWebsites": { + "message": "Сачувана веб локација ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Сигурно послати ову ставку у отпад?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Сазнај више" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Једнократни код" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1ГБ шифровано складиште за прилоге." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Хитан приступ." }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "Захтевај биометрију при покретању" }, - "premiumRequired": { - "message": "Потребан Премијум" - }, - "premiumRequiredDesc": { - "message": "Премијум чланство је неопходно за употребу ове опције." - }, "authenticationTimeout": { "message": "Истекло је време аутентификације" }, @@ -1623,6 +1680,9 @@ "selfHostedEnvFormInvalid": { "message": "Морате додати или основни УРЛ сервера или бар једно прилагођено окружење." }, + "selfHostedEnvMustUseHttps": { + "message": "Везе морају да користе HTTPS." + }, "customEnvironment": { "message": "Прилагођено окружење" }, @@ -1676,9 +1736,30 @@ "turnOffAutofill": { "message": "Угасити ауто-пуњење" }, + "confirmAutofill": { + "message": "Потврди аутопуњење" + }, + "confirmAutofillDesc": { + "message": "Овај сајт се не подудара са вашим сачуваним подацима за пријаву. Пре него што унесете своје акредитиве за пријаву, уверите се да је то поуздан сајт." + }, "showInlineMenuLabel": { "message": "Прикажи предлоге за ауто-попуњавање у пољима обрасца" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Како Bitwarden штити ваше податке од фишинга?" + }, + "currentWebsite": { + "message": "Тренутни сајт" + }, + "autofillAndAddWebsite": { + "message": "Ауто-попуни и додај овај сајт" + }, + "autofillWithoutAdding": { + "message": "Ауто-попуни без додавања" + }, + "doNotAutofill": { + "message": "Не попуни" + }, "showInlineMenuIdentitiesLabel": { "message": "Приказати идентитете као предлоге" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Година истека" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Истек" }, @@ -2391,6 +2475,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": "Постави Главну Лозинку" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Грешка при декрипцији" }, + "errorGettingAutoFillData": { + "message": "Грешка при преузимању података за ауто-попуњавање" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden није могао да декриптује ставке из трезора наведене испод." }, @@ -4011,6 +4101,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": "Укључите бочну навигацију" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Премијум" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Бесплатне организације не могу да користе прилоге" }, @@ -4926,6 +5028,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": { @@ -5716,10 +5828,58 @@ "message": "Потврдите домен конектора кључа" }, "atRiskLoginsSecured": { - "message": "Great job securing your at-risk logins!" + "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": "This setting is disabled by your organization's policy.", + "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 07ed7a491f1..a6cf12541fd 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" @@ -582,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "Arkiverade objekt är exkluderade från allmänna sökresultat och förslag för autofyll. Är du säker på att du vill arkivera detta objekt?" }, + "upgradeToUseArchive": { + "message": "Ett premium-medlemskap krävs för att använda Arkiv." + }, "edit": { "message": "Redigera" }, "view": { "message": "Visa" }, + "viewAll": { + "message": "Visa alla" + }, + "showAll": { + "message": "Visa alla" + }, + "viewLess": { + "message": "Visa mindre" + }, "viewLogin": { "message": "Visa inloggning" }, @@ -790,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" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Läs mer" }, + "migrationsFailed": { + "message": "Ett fel inträffade när krypteringsinställningarna skulle uppdateras." + }, + "updateEncryptionSettingsTitle": { + "message": "Uppdatera dina krypteringsinställningar" + }, + "updateEncryptionSettingsDesc": { + "message": "De nya rekommenderade krypteringsinställningarna kommer att förbättra säkerheten för ditt konto. Ange ditt huvudlösenord för att uppdatera nu." + }, + "confirmIdentityToContinue": { + "message": "Bekräfta din identitet för att fortsätta" + }, + "enterYourMasterPassword": { + "message": "Ange ditt huvudlösenord" + }, + "updateSettings": { + "message": "Uppdatera inställningar" + }, + "later": { + "message": "Senare" + }, "authenticatorKeyTotp": { "message": "Autentiseringsnyckel (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB lagring av krypterade filer." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ krypterad lagring för filbilagor.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Nödåtkomst." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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ö" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Utgångsår" }, + "monthly": { + "message": "månad" + }, "expiration": { "message": "Utgång" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "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." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,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" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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": "Lås upp avancerade säkerhetsfunktioner" + }, + "unlockAdvancedSecurityDesc": { + "message": "En Premium-prenumeration ger dig fler verktyg för att hålla dig säker och ha kontroll" + }, + "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 c4f0fffd143..934cb8e1a01 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": "மீண்டும் வருக" }, @@ -582,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": "உள்நுழைவைக் காண்க" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "சிஸ்டம் பூட்டப்பட்டவுடன்" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "உலாவி மறுதொடக்கம் செய்யப்பட்டவுடன்" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "உருப்படி சேமிக்கப்பட்டது" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "நீங்கள் உண்மையிலேயே குப்பைக்கு அனுப்ப விரும்புகிறீர்களா?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "மேலும் அறிக" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "அங்கீகரிப்பு விசை (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "கோப்பு இணைப்புகளுக்கு 1 GB என்க்ரிப்ட் செய்யப்பட்ட ஸ்டோரேஜ்." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "அவசர அணுகல்." }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "தொடங்கும் போது பயோமெட்ரிக்ஸைக் கேட்கவும்" }, - "premiumRequired": { - "message": "பிரீமியம் தேவை" - }, - "premiumRequiredDesc": { - "message": "இந்த அம்சத்தைப் பயன்படுத்த ஒரு பிரீமியம் மெம்பர்ஷிப் தேவை." - }, "authenticationTimeout": { "message": "அங்கீகரிப்பு டைம் அவுட்" }, @@ -1623,6 +1680,9 @@ "selfHostedEnvFormInvalid": { "message": "நீங்கள் பேஸ் சர்வர் URL-ஐ அல்லது குறைந்தது ஒரு தனிப்பயன் சூழலைச் சேர்க்க வேண்டும்." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "தனிப்பயன் சூழல்" }, @@ -1676,9 +1736,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": "பரிந்துரைகளாக அடையாளங்களைக் காட்டு" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "காலாவதி ஆண்டு" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "காலாவதி" }, @@ -2391,6 +2475,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": "முதன்மை கடவுச்சொல்லை அமை" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "குறியாக்கம் நீக்கப் பிழை" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden கீழே பட்டியலிடப்பட்ட பெட்டக பொருளை குறியாக்கம் நீக்க முடியவில்லை." }, @@ -4011,6 +4101,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": "பக்க வழிசெலுத்தலை மாற்று" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "பிரீமியம்" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "இலவச நிறுவனங்கள் இணைப்புகளைப் பயன்படுத்த முடியாது" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 e3e6953b0df..684a04d9175 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" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Learn more" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Expiration year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Expiration" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 7487dea84bd..bade59ad99b 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": "ยินดีต้อนรับกลับมา" }, @@ -582,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" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "On Locked" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On Restart" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "แก้ไขรายการแล้ว" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "คุณต้องการส่งไปยังถังขยะใช่หรือไม่?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "เรียนรู้เพิ่มเติม" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Authenticator Key (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB of encrypted file storage." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Expiration Year" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "วันหมดอายุ" }, @@ -2391,6 +2475,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": "ตั้งรหัสผ่านหลัก" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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 e33addd805c..0ba7e2ffec3 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" }, @@ -582,12 +585,24 @@ "archiveItemConfirmDesc": { "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" }, "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" }, @@ -790,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" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Daha fazla bilgi al" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Kimlik doğrulama anahtarı (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "Dosya ekleri için 1 GB şifrelenmiş depolama." }, + "premiumSignUpStorageV2": { + "message": "Dosya ekleri için $SIZE$ şifrelenmiş depolama.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Acil durum erişimi." }, @@ -1505,12 +1568,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ı" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Son kullanma yılı" }, + "monthly": { + "message": "ay" + }, "expiration": { "message": "Son kullanma tarihi" }, @@ -2391,6 +2475,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" }, @@ -3240,6 +3327,9 @@ "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." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Premium" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Ücretsiz kuruluşlar dosya eklerini kullanamaz" }, @@ -4926,6 +5028,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": { @@ -5580,16 +5692,16 @@ "message": "Kasanıza hoş geldiniz!" }, "phishingPageTitleV2": { - "message": "Phishing attempt detected" + "message": "Dolandırıcılık girişimi tespit edildi" }, "phishingPageSummary": { - "message": "The site you are attempting to visit is a known malicious site and a security risk." + "message": "Girmeye çalıştığınız site kötü amaçlı ve güvenlik riski taşıyan bir sitedir." }, "phishingPageCloseTabV2": { "message": "Bu sekmeyi kapat" }, "phishingPageContinueV2": { - "message": "Continue to this site (not recommended)" + "message": "Siteye devam et (önerilmez)" }, "phishingPageExplanation1": { "message": "This site was found in ", @@ -5718,8 +5830,56 @@ "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 dba38faaec6..147e63cce02 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": "З поверненням" }, @@ -551,15 +554,15 @@ "message": "Скинути пошук" }, "archiveNoun": { - "message": "Archive", + "message": "Архів", "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "Архівувати", "description": "Verb" }, "unArchive": { - "message": "Unarchive" + "message": "Видобути" }, "itemsInArchive": { "message": "Записи в архіві" @@ -571,10 +574,10 @@ "message": "Архівовані записи з'являтимуться тут і будуть виключені з результатів звичайного пошуку та пропозицій автозаповнення." }, "itemWasSentToArchive": { - "message": "Item was sent to archive" + "message": "Запис архівовано" }, "itemUnarchived": { - "message": "Item was unarchived" + "message": "Запис розархівовано" }, "archiveItem": { "message": "Архівувати запис" @@ -582,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": "Переглянути запис" }, @@ -734,7 +749,7 @@ "message": "Неправильний головний пароль" }, "invalidMasterPasswordConfirmEmailAndHost": { - "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "message": "Неправильний головний пароль. Перевірте правильність адреси електронної пошти та розміщення облікового запису на $HOST$.", "placeholders": { "host": { "content": "$1", @@ -790,6 +805,12 @@ "onLocked": { "message": "З блокуванням системи" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "З перезапуском браузера" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "Запис збережено" }, + "savedWebsite": { + "message": "Збережений вебсайт" + }, + "savedWebsites": { + "message": "Збережені вебсайти ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Ви дійсно хочете перенести до смітника?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Докладніше" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Ключ автентифікації (TOTP)" }, @@ -1422,26 +1476,35 @@ "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 ГБ зашифрованого сховища для файлів." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Екстрений доступ." }, @@ -1458,25 +1521,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", @@ -1485,7 +1548,7 @@ } }, "premiumPriceV2": { - "message": "Усе лише за $PRICE$ на рік!", + "message": "Лише за $PRICE$ на рік за все!", "placeholders": { "price": { "content": "$1", @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "Запитувати біометрію під час запуску" }, - "premiumRequired": { - "message": "Необхідна передплата преміум" - }, - "premiumRequiredDesc": { - "message": "Для використання цієї функції необхідна передплата преміум." - }, "authenticationTimeout": { "message": "Час очікування автентифікації" }, @@ -1549,13 +1606,13 @@ "message": "Зчитати ключ безпеки" }, "readingPasskeyLoading": { - "message": "Reading passkey..." + "message": "Читання ключа доступу..." }, "passkeyAuthenticationFailed": { - "message": "Passkey authentication failed" + "message": "Збій автентифікації ключа доступу" }, "useADifferentLogInMethod": { - "message": "Use a different log in method" + "message": "Використати інший спосіб входу" }, "awaitingSecurityKeyInteraction": { "message": "Очікується взаємодія з ключем безпеки..." @@ -1623,6 +1680,9 @@ "selfHostedEnvFormInvalid": { "message": "Необхідно додати URL-адресу основного сервера, або принаймні одне користувацьке середовище." }, + "selfHostedEnvMustUseHttps": { + "message": "URL-адреси повинні бути HTTPS." + }, "customEnvironment": { "message": "Власне середовище" }, @@ -1676,9 +1736,30 @@ "turnOffAutofill": { "message": "Вимкніть автозаповнення" }, + "confirmAutofill": { + "message": "Підтвердити автозаповнення" + }, + "confirmAutofillDesc": { + "message": "Адреса цього вебсайту відрізняється від збережених даних вашого запису. Перш ніж заповнити облікові дані, переконайтеся, що це надійний сайт." + }, "showInlineMenuLabel": { "message": "Пропозиції автозаповнення на полях форм" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Як Bitwarden захищає ваші дані від шахрайства?" + }, + "currentWebsite": { + "message": "Поточний вебсайт" + }, + "autofillAndAddWebsite": { + "message": "Автоматично заповнити й додати цей сайт" + }, + "autofillWithoutAdding": { + "message": "Автоматично заповнити без додавання" + }, + "doNotAutofill": { + "message": "Не заповнювати автоматично" + }, "showInlineMenuIdentitiesLabel": { "message": "Показувати посвідчення як пропозиції" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Рік завершення" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "Термін дії" }, @@ -2391,6 +2475,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": "Встановити головний пароль" }, @@ -3217,7 +3304,7 @@ } }, "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "message": "Буде експортовано лише сховище організації, пов'язане з $ORGANIZATION$.", "placeholders": { "organization": { "content": "$1", @@ -3226,7 +3313,7 @@ } }, "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "message": "Буде експортовано лише сховище організації, пов'язане з $ORGANIZATION$. Записи моїх збірок не будуть включені.", "placeholders": { "organization": { "content": "$1", @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "Помилка розшифрування" }, + "errorGettingAutoFillData": { + "message": "Помилка отримання даних автозаповнення" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden не зміг розшифрувати вказані нижче елементи сховища." }, @@ -3470,7 +3560,7 @@ "message": "Помилка Key Connector: переконайтеся, що Key Connector доступний та працює правильно." }, "premiumSubcriptionRequired": { - "message": "Необхідна передплата преміум" + "message": "Необхідна передплата Premium" }, "organizationIsDisabled": { "message": "Організацію вимкнено." @@ -4011,6 +4101,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": "Перемкнути бічну навігацію" }, @@ -4840,7 +4939,10 @@ "message": "Ви дійсно хочете остаточно видалити це вкладення?" }, "premium": { - "message": "Преміум" + "message": "Premium" + }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." }, "freeOrgsCannotUseAttachments": { "message": "Організації без передплати не можуть використовувати вкладення" @@ -4926,6 +5028,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": { @@ -5580,30 +5692,30 @@ "message": "Вітаємо у вашому сховищі!" }, "phishingPageTitleV2": { - "message": "Phishing attempt detected" + "message": "Виявлено спробу шахрайства" }, "phishingPageSummary": { - "message": "The site you are attempting to visit is a known malicious site and a security risk." + "message": "Ви намагаєтеся відвідати відомий зловмисний вебсайт, який має ризики безпеки." }, "phishingPageCloseTabV2": { - "message": "Close this tab" + "message": "Закрити цю вкладку" }, "phishingPageContinueV2": { - "message": "Continue to this site (not recommended)" + "message": "Перейти на цей сайт (не рекомендується)" }, "phishingPageExplanation1": { - "message": "This site was found in ", + "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": ", an open-source list of known phishing sites used for stealing personal and sensitive information.", + "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": "Learn more about phishing detection" + "message": "Докладніше про виявлення шахрайства" }, "protectedBy": { - "message": "Protected by $PRODUCT$", + "message": "Захищено $PRODUCT$", "placeholders": { "product": { "content": "$1", @@ -5716,10 +5828,58 @@ "message": "Підтвердити домен Key Connector" }, "atRiskLoginsSecured": { - "message": "Great job securing your at-risk logins!" + "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": "This setting is disabled by your organization's policy.", + "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 055e5155955..a94a38ffb63 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" }, @@ -582,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": "Cần là thành viên premium để sử dụng Archive." + }, "edit": { "message": "Sửa" }, "view": { "message": "Xem" }, + "viewAll": { + "message": "Xem tất cả" + }, + "showAll": { + "message": "Hiện tất cả" + }, + "viewLess": { + "message": "Ẩn bớt" + }, "viewLogin": { "message": "Xem đăng nhập" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "Mỗi khi khóa máy" }, + "onIdle": { + "message": "Khi hệ thống rãnh rỗi" + }, + "onSleep": { + "message": "Khi hệ thống ngủ" + }, "onRestart": { "message": "Mỗi khi khởi động lại trình duyệt" }, @@ -1028,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?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "Tìm hiểu thêm" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "Khóa xác thực (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1GB bộ nhớ lưu trữ được mã hóa cho các tệp đính kèm." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ bộ nhớ lưu trữ được mã hóa cho các tệp đính kèm.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Truy cập khẩn cấp." }, @@ -1505,12 +1568,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" }, @@ -1623,6 +1680,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" }, @@ -1676,9 +1736,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 ý" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "Năm hết hạn" }, + "monthly": { + "message": "tháng" + }, "expiration": { "message": "Hết hạn" }, @@ -2391,6 +2475,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Trang này đang cản trở trải nghiệm Bitwarden. Menu nội tuyến Bitwarden đã tạm thời bị vô hiệu hóa như một biện pháp an toàn." + }, "setMasterPassword": { "message": "Đặt mật khẩu chính" }, @@ -3240,6 +3327,9 @@ "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." }, @@ -4011,6 +4101,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" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "Cao cấp" }, + "unlockFeaturesWithPremium": { + "message": "Mở khóa tính năng báo cáo, quyền truy cập khẩn cấp và nhiều tính năng bảo mật khác với Premium." + }, "freeOrgsCannotUseAttachments": { "message": "Các tổ chức miễn phí không thể sử dụng tệp đính kèm" }, @@ -4926,6 +5028,16 @@ } } }, + "defaultLabelWithValue": { + "message": "Mặc định ( $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": { @@ -5580,30 +5692,30 @@ "message": "Chào mừng đến với kho lưu trữ của bạn!" }, "phishingPageTitleV2": { - "message": "Phishing attempt detected" + "message": "Đã phát hiện nỗ lực lừa đảo" }, "phishingPageSummary": { - "message": "The site you are attempting to visit is a known malicious site and a security risk." + "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." }, "phishingPageCloseTabV2": { - "message": "Close this tab" + "message": "Đóng thẻ này" }, "phishingPageContinueV2": { - "message": "Continue to this site (not recommended)" + "message": "Tiếp tục truy cập trang web này (không khuyến khích)" }, "phishingPageExplanation1": { - "message": "This site was found in ", + "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": ", an open-source list of known phishing sites used for stealing personal and sensitive information.", + "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": "Learn more about phishing detection" + "message": "Tìm hiểu thêm về phát hiện lừa đảo" }, "protectedBy": { - "message": "Protected by $PRODUCT$", + "message": "Được bảo vệ bởi $PRODUCT$", "placeholders": { "product": { "content": "$1", @@ -5716,10 +5828,58 @@ "message": "Xác nhận tên miền Key Connector" }, "atRiskLoginsSecured": { - "message": "Great job securing your at-risk logins!" + "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": "Mở khóa các tính năng bảo mật nâng cao" + }, + "unlockAdvancedSecurityDesc": { + "message": "Đăng ký Premium cung cấp cho bạn nhiều công cụ hơn để luôn an toàn và kiểm soát" + }, + "explorePremium": { + "message": "Khám phá Premium" + }, + "loadingVault": { + "message": "Đang tải kho" + }, + "vaultLoaded": { + "message": "Đã tải kho" }, "settingDisabledByPolicy": { - "message": "This setting is disabled by your organization's policy.", + "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": "Hành động sau khi đóng kho" } } diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 1d1a6674e18..fd4b6380b1b 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": "删除文件夹" @@ -582,12 +585,24 @@ "archiveItemConfirmDesc": { "message": "已归档的项目将被排除在一般搜索结果和自动填充建议之外。确定要归档此项目吗?" }, + "upgradeToUseArchive": { + "message": "需要高级会员才能使用归档。" + }, "edit": { "message": "编辑" }, "view": { "message": "查看" }, + "viewAll": { + "message": "查看全部" + }, + "showAll": { + "message": "显示全部" + }, + "viewLess": { + "message": "查看更少" + }, "viewLogin": { "message": "查看登录" }, @@ -682,7 +697,7 @@ "message": "会话超时" }, "vaultTimeoutHeader": { - "message": "密码库超时时间" + "message": "密码库超时" }, "otherOptions": { "message": "其他选项" @@ -743,10 +758,10 @@ } }, "vaultTimeout": { - "message": "密码库超时时间" + "message": "密码库超时" }, "vaultTimeout1": { - "message": "超时" + "message": "超时时间" }, "lockNow": { "message": "立即锁定" @@ -790,6 +805,12 @@ "onLocked": { "message": "系统锁定时" }, + "onIdle": { + "message": "系统空闲时" + }, + "onSleep": { + "message": "系统睡眠时" + }, "onRestart": { "message": "浏览器重启时" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "项目已保存" }, + "savedWebsite": { + "message": "保存的网站" + }, + "savedWebsites": { + "message": "保存的网站 ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "您确定要将其发送到回收站吗?" }, @@ -1221,7 +1254,7 @@ "description": "Detailed error message shown when saving login details fails." }, "changePasswordWarning": { - "message": "更改密码后,您需要使用新密码登录。 在其他设备上的活动会话将在一小时内注销。" + "message": "更改密码后,您需要使用新密码登录。在其他设备上的活动会话将在一小时内注销。" }, "accountRecoveryUpdateMasterPasswordSubtitle": { "message": "更改您的主密码以完成账户恢复。" @@ -1373,6 +1406,27 @@ "learnMore": { "message": "进一步了解" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "验证器密钥 (TOTP)" }, @@ -1440,7 +1494,16 @@ "message": "注册高级会员将获得:" }, "ppremiumSignUpStorage": { - "message": "1 GB 文件附件加密存储。" + "message": "1 GB 文件附件加密存储空间。" + }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ 文件附件加密存储空间。", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } }, "premiumSignUpEmergency": { "message": "紧急访问。" @@ -1458,7 +1521,7 @@ "message": "优先客户支持。" }, "ppremiumSignUpFuture": { - "message": "未来的更多高级功能。敬请期待!" + "message": "未来的更多高级版功能。敬请期待!" }, "premiumPurchase": { "message": "购买高级版" @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "启动时提示生物识别" }, - "premiumRequired": { - "message": "需要高级会员" - }, - "premiumRequiredDesc": { - "message": "使用此功能需要高级会员资格。" - }, "authenticationTimeout": { "message": "身份验证超时" }, @@ -1623,6 +1680,9 @@ "selfHostedEnvFormInvalid": { "message": "您必须添加基础服务器 URL 或至少添加一个自定义环境。" }, + "selfHostedEnvMustUseHttps": { + "message": "URL 必须使用 HTTPS。" + }, "customEnvironment": { "message": "自定义环境" }, @@ -1676,9 +1736,30 @@ "turnOffAutofill": { "message": "停用自动填充" }, + "confirmAutofill": { + "message": "确认自动填充" + }, + "confirmAutofillDesc": { + "message": "此网站与您保存的登录信息不匹配。在填写您的登录凭据之前,请确保它是一个可信的网站。" + }, "showInlineMenuLabel": { "message": "在表单字段中显示自动填充建议" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Bitwarden 如何保护您的数据免遭网络钓鱼?" + }, + "currentWebsite": { + "message": "当前网站" + }, + "autofillAndAddWebsite": { + "message": "自动填充并添加此网站" + }, + "autofillWithoutAdding": { + "message": "自动填充但不添加" + }, + "doNotAutofill": { + "message": "不自动填充" + }, "showInlineMenuIdentitiesLabel": { "message": "将身份显示为建议" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "过期年份" }, + "monthly": { + "message": "月" + }, "expiration": { "message": "有效期" }, @@ -2356,7 +2440,7 @@ "message": "已经有账户了吗?" }, "vaultTimeoutLogOutConfirmation": { - "message": "超时后注销账户将解除对密码库的所有访问权限,并需要进行在线身份验证。确定使用此设置吗?" + "message": "超时后注销账户将解除对密码库的所有访问权限,并需要进行在线身份验证。确定要使用此设置吗?" }, "vaultTimeoutLogOutConfirmationTitle": { "message": "超时动作确认" @@ -2391,6 +2475,9 @@ } } }, + "topLayerHijackWarning": { + "message": "此页面正在干扰 Bitwarden 的使用体验。出于安全考虑,Bitwarden 内嵌菜单已被暂时禁用。" + }, "setMasterPassword": { "message": "设置主密码" }, @@ -2858,7 +2945,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": { @@ -2954,7 +3041,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": { @@ -3074,7 +3161,7 @@ "message": "企业策略要求已应用到您的超时选项中" }, "vaultTimeoutPolicyInEffect": { - "message": "您的组织策略已将您最大允许的密码库超时时间设置为 $HOURS$ 小时 $MINUTES$ 分钟。", + "message": "您的组织策略已将您最大允许的密码库超时设置为 $HOURS$ 小时 $MINUTES$ 分钟。", "placeholders": { "hours": { "content": "$1", @@ -3100,7 +3187,7 @@ } }, "vaultTimeoutPolicyMaximumError": { - "message": "超时时间超出了您组织设置的限制:最多 $HOURS$ 小时 $MINUTES$ 分钟", + "message": "超时超出了您组织设置的限制:最多 $HOURS$ 小时 $MINUTES$ 分钟", "placeholders": { "hours": { "content": "$1", @@ -3113,7 +3200,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "您的组织策略正在影响您的密码库超时时间。最大允许的密码库超时时间是 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", + "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", "placeholders": { "hours": { "content": "$1", @@ -3130,7 +3217,7 @@ } }, "vaultTimeoutActionPolicyInEffect": { - "message": "您的组织策略已将您的密码库超时动作设置为 $ACTION$。", + "message": "您的组织策略已将您的密码库超时动作设置为「$ACTION$」。", "placeholders": { "action": { "content": "$1", @@ -3139,7 +3226,7 @@ } }, "vaultTimeoutTooLarge": { - "message": "您的密码库超时时间超出了组织设置的限制。" + "message": "您的密码库超时超出了您组织设置的限制。" }, "vaultExportDisabled": { "message": "密码库导出已禁用" @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "解密错误" }, + "errorGettingAutoFillData": { + "message": "获取自动填充数据时出错" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden 无法解密下列密码库项目。" }, @@ -3687,7 +3777,7 @@ "message": "当前会话" }, "mobile": { - "message": "移动", + "message": "移动端", "description": "Mobile app" }, "extension": { @@ -4011,6 +4101,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": "切换侧边导航" }, @@ -4825,7 +4924,7 @@ "message": "获取桌面 App" }, "getTheDesktopAppDesc": { - "message": "无需使用浏览器访问您的密码库,然后在桌面 App 和浏览器扩展中同时设置生物识别解锁,即可实现快速解锁。" + "message": "无需使用浏览器访问您的密码库。在桌面 App 和浏览器扩展中同时设置生物识别解锁,即可实现快速解锁。" }, "downloadFromBitwardenNow": { "message": "立即从 bitwarden.com 下载" @@ -4837,10 +4936,13 @@ "message": "从 App Store 下载" }, "permanentlyDeleteAttachmentConfirmation": { - "message": "您确定要永久删除此附件吗?" + "message": "确定要永久删除此附件吗?" }, "premium": { - "message": "高级会员" + "message": "高级版" + }, + "unlockFeaturesWithPremium": { + "message": "使用高级版解锁报告、紧急访问以及更多安全功能。" }, "freeOrgsCannotUseAttachments": { "message": "免费组织无法使用附件" @@ -4917,7 +5019,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": { @@ -5238,7 +5350,7 @@ "message": "重试" }, "vaultCustomTimeoutMinimum": { - "message": "自定义超时时间最小为 1 分钟。" + "message": "自定义超时最少为 1 分钟。" }, "fileSavedToDevice": { "message": "文件已保存到设备。可以在设备下载中进行管理。" @@ -5541,7 +5653,7 @@ "message": "欢迎使用 Bitwarden" }, "securityPrioritized": { - "message": "安全优先" + "message": "以安全为首要" }, "securityPrioritizedBody": { "message": "将登录、支付卡和身份保存到您的安全密码库。Bitwarden 使用零知识、端到端的加密来保护您的重要信息。" @@ -5690,7 +5802,7 @@ "message": "关于此设置" }, "permitCipherDetailsDescription": { - "message": "Bitwarden 将使用已保存的登录 URI 来识别应使用哪个图标或更改密码的 URL 来改善您的体验。当您使用此服务时,不会收集或保存任何信息。" + "message": "Bitwarden 将使用已保存的登录 URI 来确定应使用的图标或更改密码的 URL,以提升您的使用体验。使用此服务时不会收集或保存任何信息。" }, "noPermissionsViewPage": { "message": "您没有查看此页面的权限。请尝试使用其他账户登录。" @@ -5716,10 +5828,58 @@ "message": "确认 Key Connector 域名" }, "atRiskLoginsSecured": { - "message": "Great job securing your at-risk logins!" + "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 e2d9ff2068f..de78e415fa6 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "使用單一登入" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "您的組織需要單一登入。" + }, "welcomeBack": { "message": "歡迎回來" }, @@ -582,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": "顯示較少" + }, "viewLogin": { "message": "檢視登入" }, @@ -790,6 +805,12 @@ "onLocked": { "message": "於系統鎖定時" }, + "onIdle": { + "message": "系統閒置時" + }, + "onSleep": { + "message": "系統睡眠時" + }, "onRestart": { "message": "於瀏覽器重新啟動時" }, @@ -1028,6 +1049,18 @@ "editedItem": { "message": "項目已儲存" }, + "savedWebsite": { + "message": "已儲存的網站" + }, + "savedWebsites": { + "message": "已儲存的網站($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "確定要刪除此項目嗎?" }, @@ -1373,6 +1406,27 @@ "learnMore": { "message": "深入了解" }, + "migrationsFailed": { + "message": "An error occurred updating the encryption settings." + }, + "updateEncryptionSettingsTitle": { + "message": "Update your encryption settings" + }, + "updateEncryptionSettingsDesc": { + "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + }, + "confirmIdentityToContinue": { + "message": "Confirm your identity to continue" + }, + "enterYourMasterPassword": { + "message": "Enter your master password" + }, + "updateSettings": { + "message": "Update settings" + }, + "later": { + "message": "Later" + }, "authenticatorKeyTotp": { "message": "驗證器金鑰 (TOTP)" }, @@ -1442,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "用於檔案附件的 1 GB 加密儲存空間。" }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "緊急存取" }, @@ -1505,12 +1568,6 @@ "enableAutoBiometricsPrompt": { "message": "啟動時要求生物特徵辨識" }, - "premiumRequired": { - "message": "需要進階會員資格" - }, - "premiumRequiredDesc": { - "message": "進階會員才可使用此功能。" - }, "authenticationTimeout": { "message": "驗證逾時" }, @@ -1623,6 +1680,9 @@ "selfHostedEnvFormInvalid": { "message": "您必須新增伺服器網域 URL 或至少一個自訂環境。" }, + "selfHostedEnvMustUseHttps": { + "message": "URL 必須使用 HTTPS。" + }, "customEnvironment": { "message": "自訂環境" }, @@ -1676,9 +1736,30 @@ "turnOffAutofill": { "message": "停用自動填入" }, + "confirmAutofill": { + "message": "確認自動填入" + }, + "confirmAutofillDesc": { + "message": "此網站與您儲存的登入資料不相符。在填入登入憑證前,請確認這是受信任的網站。" + }, "showInlineMenuLabel": { "message": "在表單欄位上顯示自動填入選單" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Bitwarden 如何保護您的資料免於網路釣魚攻擊?" + }, + "currentWebsite": { + "message": "目網站" + }, + "autofillAndAddWebsite": { + "message": "自動填充並新增此網站" + }, + "autofillWithoutAdding": { + "message": "自動填入但不新增" + }, + "doNotAutofill": { + "message": "不要自動填入" + }, "showInlineMenuIdentitiesLabel": { "message": "顯示身分建議" }, @@ -1822,6 +1903,9 @@ "expirationYear": { "message": "逾期年份" }, + "monthly": { + "message": "month" + }, "expiration": { "message": "逾期" }, @@ -2391,6 +2475,9 @@ } } }, + "topLayerHijackWarning": { + "message": "此頁面正在干擾 Bitwarden 的使用體驗。為了安全起見,已暫時停用 Bitwarden 的內嵌選單。" + }, "setMasterPassword": { "message": "設定主密碼" }, @@ -3240,6 +3327,9 @@ "decryptionError": { "message": "解密發生錯誤" }, + "errorGettingAutoFillData": { + "message": "取得自動填入資料時發生錯誤" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden 無法解密您密碼庫中下面的項目。" }, @@ -4011,6 +4101,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": "切換側邊欄" }, @@ -4842,6 +4941,9 @@ "premium": { "message": "進階版" }, + "unlockFeaturesWithPremium": { + "message": "Unlock reporting, emergency access, and more security features with Premium." + }, "freeOrgsCannotUseAttachments": { "message": "免費組織無法使用附檔" }, @@ -4926,6 +5028,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": { @@ -5718,8 +5830,56 @@ "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.ts b/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts index 9e9a1ecf570..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 @@ -122,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/current-account.component.html b/apps/browser/src/auth/popup/account-switching/current-account.component.html index 2e2440f6258..7ab55f36753 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 @@ -2,7 +2,7 @@ -
-
- - - - - - - + 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 fcf91ca2e91..333f8d5e534 100644 --- a/apps/browser/src/autofill/notification/bar.ts +++ b/apps/browser/src/autofill/notification/bar.ts @@ -24,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), @@ -187,8 +194,6 @@ async function initNotificationBar(message: NotificationBarWindowMessage) { const notificationTestId = getNotificationTestId(notificationType); appendHeaderMessageToTitle(headerMessage); - document.body.innerHTML = ""; - if (isVaultLocked) { const notificationConfig = { ...notificationBarIframeInitData, @@ -397,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; @@ -433,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 b550ae203d5..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"); @@ -240,8 +282,6 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte this.buttonElement = globalThis.document.createElement(customElementName); this.buttonElement.setAttribute("popover", "manual"); - - this.createInternalStyleNode(this.buttonElement); } /** @@ -249,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"); @@ -270,30 +314,6 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte this.listElement = globalThis.document.createElement(customElementName); this.listElement.setAttribute("popover", "manual"); - - this.createInternalStyleNode(this.listElement); - } - - /** - * 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. - */ - private createInternalStyleNode(parent: HTMLElement) { - const css = document.createTextNode(` - ${parent.tagName}::backdrop { - background: none !important; - pointer-events: none !important; - } - ${parent.tagName}::before, ${parent.tagName}::after { - content:"" !important; - } - `); - const style = globalThis.document.createElement("style"); - style.setAttribute("type", "text/css"); - style.appendChild(css); - parent.prepend(style); } /** @@ -407,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); } }; @@ -428,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; } @@ -458,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(); @@ -509,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 @@ -523,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(); @@ -532,6 +604,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte listInDocument.hidePopover(); listInDocument.showPopover(); } + + if (buttonInDocument || listInDocument) { + this.checkAndUpdateRefreshCount("topLayer"); + } }; /** @@ -541,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..f1ed6875f90 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 @@ -1,7 +1,7 @@ import { mock } from "jest-mock-extended"; import { EVENTS } from "@bitwarden/common/autofill/constants"; -import { ThemeType } from "@bitwarden/common/platform/enums"; +import { ThemeTypes } from "@bitwarden/common/platform/enums"; import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum"; import { createPortSpyMock } from "../../../spec/autofill-mocks"; @@ -66,17 +66,38 @@ describe("AutofillInlineMenuIframeService", () => { ); }); - // TODO CG - This test is brittle and failing due to how we are calling the private method. This needs to be reworked - it.skip("creates an aria alert element if the ariaAlert param is passed", () => { - const ariaAlert = "aria alert"; + it("creates an aria alert element if the ariaAlert param is passed to AutofillInlineMenuIframeService", () => { jest.spyOn(autofillInlineMenuIframeService as any, "createAriaAlertElement"); autofillInlineMenuIframeService.initMenuIframe(); - expect(autofillInlineMenuIframeService["createAriaAlertElement"]).toHaveBeenCalledWith( - ariaAlert, + expect(autofillInlineMenuIframeService["createAriaAlertElement"]).toHaveBeenCalled(); + expect(autofillInlineMenuIframeService["ariaAlertElement"]).toBeDefined(); + expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("role")).toBe( + "alert", ); - expect(autofillInlineMenuIframeService["ariaAlertElement"]).toMatchSnapshot(); + expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("aria-live")).toBe( + "polite", + ); + expect(autofillInlineMenuIframeService["ariaAlertElement"].getAttribute("aria-atomic")).toBe( + "true", + ); + }); + + it("does not create an aria alert element if the ariaAlert param is not passed to AutofillInlineMenuIframeService", () => { + const shadowWithoutAlert = document.createElement("div").attachShadow({ mode: "open" }); + const serviceWithoutAlert = new AutofillInlineMenuIframeService( + shadowWithoutAlert, + AutofillOverlayPort.Button, + { height: "0px" }, + "title", + ); + jest.spyOn(serviceWithoutAlert as any, "createAriaAlertElement"); + + serviceWithoutAlert.initMenuIframe(); + + expect(serviceWithoutAlert["createAriaAlertElement"]).not.toHaveBeenCalled(); + expect(serviceWithoutAlert["ariaAlertElement"]).toBeUndefined(); }); describe("on load of the iframe source", () => { @@ -191,7 +212,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", () => { @@ -200,7 +221,7 @@ describe("AutofillInlineMenuIframeService", () => { sendPortMessage(portSpy, { command: "updateAutofillInlineMenuPosition" }); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, ).not.toHaveBeenCalled(); }); @@ -216,8 +237,8 @@ describe("AutofillInlineMenuIframeService", () => { expect(autofillInlineMenuIframeService["portKey"]).toBe(portKey); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, - ).toHaveBeenCalledWith(message, "*"); + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, + ).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]); }); }); @@ -234,35 +255,35 @@ describe("AutofillInlineMenuIframeService", () => { it("passes the message on to the iframe element", () => { const message = { command: "initAutofillInlineMenuList", - theme: ThemeType.Light, + theme: ThemeTypes.Light, }; sendPortMessage(portSpy, message); expect(updateElementStylesSpy).not.toHaveBeenCalled(); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, - ).toHaveBeenCalledWith(message, "*"); + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, + ).toHaveBeenCalledWith(message, autofillInlineMenuIframeService["extensionOrigin"]); }); it("sets a light theme based on the user's system preferences", () => { window.matchMedia = jest.fn(() => mock({ matches: false })); const message = { command: "initAutofillInlineMenuList", - theme: ThemeType.System, + theme: ThemeTypes.System, }; sendPortMessage(portSpy, message); expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)"); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, ).toHaveBeenCalledWith( { command: "initAutofillInlineMenuList", - theme: ThemeType.Light, + theme: ThemeTypes.Light, }, - "*", + autofillInlineMenuIframeService["extensionOrigin"], ); }); @@ -270,27 +291,27 @@ describe("AutofillInlineMenuIframeService", () => { window.matchMedia = jest.fn(() => mock({ matches: true })); const message = { command: "initAutofillInlineMenuList", - theme: ThemeType.System, + theme: ThemeTypes.System, }; sendPortMessage(portSpy, message); expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)"); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, ).toHaveBeenCalledWith( { command: "initAutofillInlineMenuList", - theme: ThemeType.Dark, + theme: ThemeTypes.Dark, }, - "*", + autofillInlineMenuIframeService["extensionOrigin"], ); }); it("updates the border to match the `dark` theme", () => { const message = { command: "initAutofillInlineMenuList", - theme: ThemeType.Dark, + theme: ThemeTypes.Dark, }; sendPortMessage(portSpy, message); @@ -364,6 +385,219 @@ describe("AutofillInlineMenuIframeService", () => { autofillInlineMenuIframeService["handleFadeInInlineMenuIframe"], ).toHaveBeenCalled(); }); + + it("closes the inline menu when iframe is outside the viewport (bottom)", () => { + const viewportHeight = 800; + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: 0, + left: 0, + right: 100, + bottom: viewportHeight + 1, + height: 98, + width: 262, + } as DOMRect); + Object.defineProperty(globalThis.window, "innerHeight", { + value: viewportHeight, + writable: true, + configurable: true, + }); + Object.defineProperty(globalThis.window, "innerWidth", { + value: 1200, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); + + it("closes the inline menu when iframe is outside the viewport (right)", () => { + const viewportWidth = 1200; + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: 0, + left: 0, + right: viewportWidth + 1, + bottom: 100, + height: 98, + width: 262, + } as DOMRect); + Object.defineProperty(globalThis.window, "innerHeight", { + value: 800, + writable: true, + configurable: true, + }); + Object.defineProperty(globalThis.window, "innerWidth", { + value: viewportWidth, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); + + it("closes the inline menu when iframe is outside the viewport (left)", () => { + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: 0, + left: -1, + right: 0, + bottom: 100, + height: 98, + width: 262, + } as DOMRect); + Object.defineProperty(globalThis.window, "innerHeight", { + value: 800, + writable: true, + configurable: true, + }); + Object.defineProperty(globalThis.window, "innerWidth", { + value: 1200, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); + + it("closes the inline menu when iframe is outside the viewport (top)", () => { + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: -1, + left: 0, + right: 100, + bottom: 0, + height: 98, + width: 262, + } as DOMRect); + Object.defineProperty(globalThis.window, "innerHeight", { + value: 800, + writable: true, + configurable: true, + }); + Object.defineProperty(globalThis.window, "innerWidth", { + value: 1200, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); + + it("allows iframe (do not close) when it has no dimensions", () => { + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: 0, + left: 0, + right: 0, + bottom: 0, + height: 0, + width: 0, + } as DOMRect); + + Object.defineProperty(globalThis.window, "innerHeight", { + value: 800, + writable: true, + configurable: true, + }); + + Object.defineProperty(globalThis.window, "innerWidth", { + value: 1200, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).not.toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); + + it("uses visualViewport when available", () => { + jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); + jest + .spyOn(autofillInlineMenuIframeService["iframe"], "getBoundingClientRect") + .mockReturnValue({ + top: 0, + left: 0, + right: 100, + bottom: 700, + height: 98, + width: 262, + } as DOMRect); + + Object.defineProperty(globalThis.window, "visualViewport", { + value: { + height: 600, + width: 1200, + } as VisualViewport, + writable: true, + configurable: true, + }); + + Object.defineProperty(globalThis.window, "innerHeight", { + value: 800, + writable: true, + configurable: true, + }); + + Object.defineProperty(globalThis.window, "innerWidth", { + value: 1200, + writable: true, + configurable: true, + }); + + sendPortMessage(portSpy, { + command: "updateAutofillInlineMenuPosition", + styles: {}, + }); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { + forceCloseInlineMenu: true, + }); + }); }); it("updates the visibility of the iframe", () => { @@ -381,13 +615,13 @@ describe("AutofillInlineMenuIframeService", () => { }); expect( - autofillInlineMenuIframeService["iframe"].contentWindow.postMessage, + autofillInlineMenuIframeService["iframe"].contentWindow?.postMessage, ).toHaveBeenCalledWith( { 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..64ef7d180ed 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, + ); } /** @@ -276,6 +282,15 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe const styles = this.fadeInTimeout ? Object.assign(position, { opacity: "0" }) : position; this.updateElementStyles(this.iframe, styles); + const elementHeightCompletelyInViewport = this.isElementCompletelyWithinViewport( + this.iframe.getBoundingClientRect(), + ); + + if (!elementHeightCompletelyInViewport) { + this.forceCloseInlineMenu(); + return; + } + if (this.fadeInTimeout) { this.handleFadeInInlineMenuIframe(); } @@ -283,6 +298,42 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe this.announceAriaAlert(this.ariaAlert, 2000); } + /** + * Check if element is completely within the browser viewport. + */ + private isElementCompletelyWithinViewport(elementPosition: DOMRect) { + // An element that lacks size should be considered within the viewport + if (!elementPosition.height || !elementPosition.width) { + return true; + } + + const [viewportHeight, viewportWidth] = this.getViewportSize(); + + const rightSideIsWithinViewport = (elementPosition.right || 0) <= viewportWidth; + const leftSideIsWithinViewport = (elementPosition.left || 0) >= 0; + const topSideIsWithinViewport = (elementPosition.top || 0) >= 0; + const bottomSideIsWithinViewport = (elementPosition.bottom || 0) <= viewportHeight; + + return ( + rightSideIsWithinViewport && + leftSideIsWithinViewport && + topSideIsWithinViewport && + bottomSideIsWithinViewport + ); + } + + /** Use Visual Viewport API if available (better for mobile/zoom) */ + private getViewportSize(): [ + VisualViewport["height"] | Window["innerHeight"], + VisualViewport["width"] | Window["innerWidth"], + ] { + if ("visualViewport" in globalThis.window && globalThis.window.visualViewport) { + return [globalThis.window.visualViewport.height, globalThis.window.visualViewport.width]; + } + + return [globalThis.window.innerHeight, globalThis.window.innerWidth]; + } + /** * Gets the page color scheme meta tag and sends a message to the iframe * to update its color scheme. Will default to "normal" if the meta tag 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/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..84a15fd1067 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,36 @@ 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 extensionProtocols = new Set([ + "chrome-extension:", + "moz-extension:", + "safari-web-extension:", + ]); + const isExtensionProtocol = extensionProtocols.has(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 +154,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 +164,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 +191,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 +207,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 +252,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 +269,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 +280,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 39ca68d912c..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 @@ -7,7 +7,7 @@ exports[`OverlayNotificationsContentService opening the notification bar creates > `; 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 +2401,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 +2434,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 77e8c661d08..13e97766594 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -44,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, @@ -102,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, @@ -140,12 +151,6 @@ describe("AutofillService", () => { userNotificationsSettings, messageListener, ); - domainSettingsService = new DefaultDomainSettingsService( - fakeStateProvider, - policyService, - accountService, - ); - domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); jest.spyOn(BrowserApi, "tabSendMessage"); }); @@ -369,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(); @@ -680,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); + } } }); @@ -691,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); + } } }); @@ -702,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); + } } }); @@ -713,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); + } } }); @@ -727,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); + } } }); }); @@ -766,7 +779,6 @@ describe("AutofillService", () => { { command: "fillForm", fillScript: { - metadata: {}, properties: { delay_between_operations: 20, }, @@ -863,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); + } } }); @@ -898,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); + } } }); @@ -1370,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."); + } } }); @@ -1610,7 +1630,6 @@ describe("AutofillService", () => { expect(autofillService["generateLoginFillScript"]).toHaveBeenCalledWith( { - metadata: {}, properties: {}, script: [ ["click_on_opid", "username-field"], @@ -1648,7 +1667,6 @@ describe("AutofillService", () => { expect(autofillService["generateCardFillScript"]).toHaveBeenCalledWith( { - metadata: {}, properties: {}, script: [ ["click_on_opid", "username-field"], @@ -1686,7 +1704,6 @@ describe("AutofillService", () => { expect(autofillService["generateIdentityFillScript"]).toHaveBeenCalledWith( { - metadata: {}, properties: {}, script: [ ["click_on_opid", "username-field"], @@ -2064,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; @@ -2279,7 +2483,7 @@ describe("AutofillService", () => { ); expect(value).toStrictEqual({ autosubmit: null, - metadata: {}, + itemType: "", properties: { delay_between_operations: 20 }, savedUrls: ["https://www.example.com"], script: [ @@ -2294,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, + ); + }); + }); }); }); @@ -2364,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, }; @@ -2555,7 +2898,6 @@ describe("AutofillService", () => { expect(value).toStrictEqual({ autosubmit: null, itemType: "", - metadata: {}, properties: { delay_between_operations: 20, }, @@ -2989,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( diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index f5df17083ce..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"; @@ -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; @@ -871,6 +874,70 @@ export default class AutofillService implements AutofillServiceInterface { 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)) { @@ -878,20 +945,25 @@ export default class AutofillService implements AutofillServiceInterface { } 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); } @@ -900,24 +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. - pf = prioritizedPasswordFields[0]; - passwords.push(pf); + // 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; - if (login.username && pf.elementNumber > 0) { - username = this.findUsernameField(pageDetails, pf, false, false, true); + // 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 (username) { - usernames.push(username); + // 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 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); + } } } } @@ -951,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; @@ -960,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; } @@ -2330,12 +2436,14 @@ export default class AutofillService implements AutofillServiceInterface { const includesUsernameFieldName = this.findMatchingFieldIndex(f, AutoFillConstants.UsernameFieldNames) > -1; - const isInSameForm = f.form === passwordField.form; + // 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 = - f.form === passwordField.form && (f.type === "email" || f.type === "tel"); + const isQualifiedUsernameField = isInSameForm && (f.type === "email" || f.type === "tel"); if ( !f.disabled && 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 47b1c9ea6df..367599f7ad0 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -997,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++) { @@ -1026,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. @@ -1060,7 +1053,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ (this.isAutofillElementNodeMutated(mutation.removedNodes, true) || this.isAutofillElementNodeMutated(mutation.addedNodes)) ) { - this.requirePageDetailsUpdate(); + this.flagPageDetailsUpdateIsRequired(); return; } @@ -1407,7 +1400,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this.intersectionObserver = new IntersectionObserver(this.handleFormElementIntersection, { root: null, rootMargin: "0px", - threshold: 1.0, + threshold: 0.9999, // Safari doesn't seem to function properly with a threshold of 1, }); } 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..f6afaae202f 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,10 +940,13 @@ 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) + this.fieldHasDisqualifyingAttributeValue(field) || + this.isTotpField(field) ) { return false; } @@ -1026,7 +1050,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 +1131,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 +1148,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 +1202,7 @@ export class InlineMenuFieldQualificationService this.submitButtonKeywordsMap.set(element, Array.from(keywordsSet).join(",")); } - return this.submitButtonKeywordsMap.get(element); + return this.submitButtonKeywordsMap.get(element) || ""; } /** @@ -1222,8 +1256,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 +1288,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 63cd4b534fb..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"; @@ -94,14 +94,13 @@ 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: [], savedUrls: ["https://bitwarden.com"], untrustedIframe: false, @@ -221,17 +220,14 @@ describe("InsertAutofillContentService", () => { expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith( 1, fillScript.script[0], - 0, ); expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith( 2, fillScript.script[1], - 1, ); expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith( 3, fillScript.script[2], - 2, ); }); }); @@ -376,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, }); }); }); 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 6c951afc1a0..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, @@ -50,7 +52,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf } for (let index = 0; index < fillScript.script.length; index++) { - await this.runFillScriptAction(fillScript.script[index], index); + await this.runFillScriptAction(fillScript.script[index]); } } @@ -116,25 +118,26 @@ 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), ); @@ -158,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); + } } /** @@ -171,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(); } @@ -187,6 +197,10 @@ 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); @@ -195,8 +209,6 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf const elementAlreadyHasTheValue = !!(elementValue?.length && elementValue === value); if ( - !element || - !value || elementAlreadyHasTheValue || (elementCanBeReadonly && element.readOnly) || (elementCanBeFilled && element.disabled) @@ -298,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; } @@ -313,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; } 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 0f89aa4792a..66a5604a8ba 100644 --- a/apps/browser/src/background/idle.background.ts +++ b/apps/browser/src/background/idle.background.ts @@ -1,6 +1,6 @@ import { firstValueFrom } from "rxjs"; -import { LogoutService } from "@bitwarden/auth/common"; +import { LockService, LogoutService } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { VaultTimeoutAction, @@ -23,6 +23,7 @@ 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); @@ -66,7 +67,7 @@ export default class IdleBackground { if (action === VaultTimeoutAction.LogOut) { 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 8170c2a65a0..2540571abb0 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -20,9 +20,9 @@ import { AuthRequestService, AuthRequestServiceAbstraction, DefaultAuthRequestApiService, - DefaultLockService, DefaultLogoutService, InternalUserDecryptionOptionsServiceAbstraction, + LockService, LoginEmailServiceAbstraction, LogoutReason, UserDecryptionOptionsService, @@ -131,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"; @@ -270,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"; @@ -293,8 +294,10 @@ 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 { BrowserSessionTimeoutTypeService } from "../key-management/session-timeout/services/browser-session-timeout-type.service"; import VaultTimeoutService from "../key-management/vault-timeout/vault-timeout.service"; import { BrowserActionsService } from "../platform/actions/browser-actions.service"; import { DefaultBadgeBrowserApi } from "../platform/badge/badge-browser-api"; @@ -362,6 +365,7 @@ export default class MainBackground { folderService: InternalFolderServiceAbstraction; userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction; collectionService: CollectionService; + lockService: LockService; vaultTimeoutService?: VaultTimeoutService; vaultTimeoutSettingsService: VaultTimeoutSettingsService; passwordGenerationService: PasswordGenerationServiceAbstraction; @@ -491,17 +495,10 @@ export default class MainBackground { private popupViewCacheBackgroundService: PopupViewCacheBackgroundService; private popupRouterCacheBackgroundService: PopupRouterCacheBackgroundService; - 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); - } - }; + // DIRT + private phishingDataService: PhishingDataService; + constructor() { const logoutCallback = async (logoutReason: LogoutReason, userId?: UserId) => await this.logout(logoutReason, userId); @@ -552,7 +549,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; } @@ -730,22 +727,21 @@ export default class MainBackground { 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.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.policyService = new DefaultPolicyService( + this.stateProvider, + this.organizationService, + this.accountService, + ); + + const sessionTimeoutTypeService = new BrowserSessionTimeoutTypeService( + this.platformUtilsService, + ); this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService( this.accountService, @@ -758,16 +754,7 @@ export default class MainBackground { this.stateProvider, this.logService, VaultTimeoutStringType.OnRestart, // default vault timeout - ); - - this.biometricsService = new BackgroundBrowserBiometricsService( - runtimeNativeMessagingBackground, - this.logService, - this.keyService, - this.biometricStateService, - this.messagingService, - this.vaultTimeoutSettingsService, - this.pinService, + sessionTimeoutTypeService, ); this.apiService = new ApiService( @@ -853,6 +840,24 @@ export default class MainBackground { this.configService, ); + this.pinService = new PinService( + this.encryptService, + 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( @@ -863,8 +868,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, @@ -880,6 +883,7 @@ export default class MainBackground { this.userDecryptionOptionsService, this.logService, this.configService, + this.accountService, ); this.devicesService = new DevicesServiceImplementation( @@ -983,27 +987,6 @@ export default class MainBackground { this.restrictedItemTypesService, ); - const logoutService = new DefaultLogoutService(this.messagingService); - 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, - logoutService, - ); this.containerService = new ContainerService(this.keyService, this.encryptService); this.sendStateProvider = new SendStateProvider(this.stateProvider); @@ -1126,7 +1109,7 @@ export default class MainBackground { this.collectionService, this.keyService, this.encryptService, - this.pinService, + this.keyGenerationService, this.accountService, this.restrictedItemTypesService, ); @@ -1134,7 +1117,7 @@ export default class MainBackground { this.individualVaultExportService = new IndividualVaultExportService( this.folderService, this.cipherService, - this.pinService, + this.keyGenerationService, this.keyService, this.encryptService, this.cryptoFunctionService, @@ -1148,7 +1131,7 @@ export default class MainBackground { this.organizationVaultExportService = new OrganizationVaultExportService( this.cipherService, this.exportApiService, - this.pinService, + this.keyGenerationService, this.keyService, this.encryptService, this.cryptoFunctionService, @@ -1215,11 +1198,12 @@ 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, this.configService, + this.policyService, ); this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService); @@ -1267,6 +1251,7 @@ export default class MainBackground { this.biometricStateService, this.accountService, this.logService, + this.authService, ); // Background @@ -1280,7 +1265,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, @@ -1294,7 +1308,7 @@ export default class MainBackground { this.configService, messageListener, this.accountService, - lockService, + this.lockService, this.billingAccountProfileStateService, this.browserInitialInstallService, ); @@ -1314,9 +1328,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( @@ -1401,6 +1416,7 @@ export default class MainBackground { this.serverNotificationsService, this.accountService, this.vaultTimeoutSettingsService, + this.lockService, logoutService, ); @@ -1451,19 +1467,30 @@ export default class MainBackground { this.inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); + this.phishingDataService = new PhishingDataService( + this.apiService, + this.taskSchedulerService, + this.globalStateProvider, + this.logService, + this.platformUtilsService, + ); + PhishingDetectionService.initialize( this.accountService, - this.auditService, this.billingAccountProfileStateService, this.configService, - this.eventCollectionService, this.logService, - this.storageService, - this.taskSchedulerService, + 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, @@ -1743,7 +1770,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 { diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 9dc2bff65e5..eba6b01fe90 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -256,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": { @@ -265,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; @@ -282,14 +293,16 @@ export default class RuntimeBackground { case "openPopup": await this.openPopup(); break; - case VaultMessages.OpenAtRiskPasswords: + case VaultMessages.OpenAtRiskPasswords: { await this.main.openAtRisksPasswordsPage(); this.announcePopupOpen(); break; - case VaultMessages.OpenBrowserExtensionToUrl: + } + case VaultMessages.OpenBrowserExtensionToUrl: { await this.main.openTheExtensionToPage(msg.url); this.announcePopupOpen(); break; + } case "bgUpdateContextMenu": case "editedCipher": case "addedCipher": @@ -301,10 +314,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; } @@ -323,10 +333,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; } @@ -361,6 +368,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..fea3e558057 100644 --- a/apps/browser/src/billing/popup/settings/premium-v2.component.html +++ b/apps/browser/src/billing/popup/settings/premium-v2.component.html @@ -6,13 +6,13 @@
-

{{ "premiumFeatures" | i18n }}

+

{{ "premiumFeatures" | i18n }}

  • - {{ "ppremiumSignUpStorage" | i18n }} + {{ "premiumSignUpStorageV2" | i18n: `${storageProvidedGb} GB` }}
  • {{ "premiumSignUpTwoStepOptions" | 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 b858b74242d..0c246d734e5 100644 --- a/apps/browser/src/billing/popup/settings/premium-v2.component.ts +++ b/apps/browser/src/billing/popup/settings/premium-v2.component.ts @@ -1,13 +1,14 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule, CurrencyPipe, Location } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; import { RouterModule } from "@angular/router"; import { PremiumComponent as BasePremiumComponent } from "@bitwarden/angular/billing/components/premium.component"; 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 { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; 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"; @@ -44,7 +45,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co SectionComponent, ], }) -export class PremiumV2Component extends BasePremiumComponent { +export class PremiumV2Component extends BasePremiumComponent implements OnInit { priceString: string; constructor( @@ -59,6 +60,7 @@ export class PremiumV2Component extends BasePremiumComponent { billingAccountProfileStateService: BillingAccountProfileStateService, toastService: ToastService, accountService: AccountService, + billingApiService: BillingApiServiceAbstraction, ) { super( i18nService, @@ -70,15 +72,18 @@ export class PremiumV2Component extends BasePremiumComponent { billingAccountProfileStateService, toastService, accountService, + billingApiService, ); - + } + async ngOnInit() { + await super.ngOnInit(); // Support old price string. Can be removed in future once all translations are properly updated. const thePrice = this.currencyPipe.transform(this.price, "$"); // Safari extension crashes due to $1 appearing in the price string ($10.00). Escape the $ to fix. const formattedPrice = this.platformUtilsService.isSafari() ? thePrice.replace("$", "$$$") : thePrice; - this.priceString = i18nService.t("premiumPriceV2", formattedPrice); + this.priceString = this.i18nService.t("premiumPriceV2", formattedPrice); if (this.priceString.indexOf("%price%") > -1) { this.priceString = this.priceString.replace("%price%", thePrice); } 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 6087042629a..00000000000 --- a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts +++ /dev/null @@ -1,57 +0,0 @@ -// eslint-disable-next-line no-restricted-imports -import { CommonModule } from "@angular/common"; -// eslint-disable-next-line no-restricted-imports -import { Component, inject } from "@angular/core"; -// eslint-disable-next-line no-restricted-imports -import { ActivatedRoute, RouterModule } from "@angular/router"; -import { map } from "rxjs"; - -import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { - AsyncActionsModule, - ButtonModule, - CheckboxModule, - FormFieldModule, - IconModule, - IconTileComponent, - LinkModule, - CalloutComponent, - TypographyModule, -} from "@bitwarden/components"; - -import { PhishingDetectionService } 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, - ], -}) -export class PhishingWarning { - private activatedRoute = inject(ActivatedRoute); - protected phishingHost$ = this.activatedRoute.queryParamMap.pipe( - map((params) => params.get("phishingHost") || ""), - ); - - async closeTab() { - await PhishingDetectionService.requestClosePhishingWarningPage(); - } - async continueAnyway() { - await PhishingDetectionService.requestContinueToDangerousUrl(); - } -} diff --git a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.html b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.html similarity index 94% rename from apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.html rename to apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.html index 5cac567c5c3..7675add73d7 100644 --- a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.html +++ b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.html @@ -9,7 +9,7 @@

    {{ "phishingPageSummary" | i18n }}

    - {{ phishingHost$ | async }} + {{ phishingHostname$ | async }} 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/pages/phishing-warning.stories.ts b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.stories.ts similarity index 83% rename from apps/browser/src/dirt/phishing-detection/pages/phishing-warning.stories.ts rename to apps/browser/src/dirt/phishing-detection/popup/phishing-warning.stories.ts index b29d97451b8..32b3c102c36 100644 --- a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.stories.ts +++ b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.stories.ts @@ -1,5 +1,3 @@ -// TODO: This needs to be dealt with by moving this folder or updating the lint rule. -/* eslint-disable no-restricted-imports */ import { ActivatedRoute, RouterModule } from "@angular/router"; import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { BehaviorSubject, of } from "rxjs"; @@ -10,6 +8,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { AnonLayoutComponent, I18nMockService } from "@bitwarden/components"; +import { MessageSender } from "@bitwarden/messaging"; import { PhishingWarning } from "./phishing-warning.component"; import { ProtectedByComponent } from "./protected-by-component"; @@ -49,6 +48,13 @@ export default { 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: () => @@ -79,7 +85,7 @@ export default { }).asObservable(), }, }, - mockActivatedRoute({ phishingHost: "malicious-example.com" }), + mockActivatedRoute({ phishingUrl: "http://malicious-example.com" }), ], }), ], @@ -95,14 +101,7 @@ export default { `, }), - argTypes: { - phishingHost: { - control: "text", - description: "The suspicious host that was blocked", - }, - }, args: { - phishingHost: "malicious-example.com", pageIcon: DeactivatedOrg, }, } satisfies Meta; @@ -110,26 +109,20 @@ export default { type Story = StoryObj; export const Default: Story = { - args: { - phishingHost: "malicious-example.com", - }, decorators: [ moduleMetadata({ - providers: [mockActivatedRoute({ phishingHost: "malicious-example.com" })], + providers: [mockActivatedRoute({ phishingUrl: "http://malicious-example.com" })], }), ], }; export const LongHostname: Story = { - args: { - phishingHost: "very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com", - }, decorators: [ moduleMetadata({ providers: [ mockActivatedRoute({ - phishingHost: - "very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com", + phishingUrl: + "http://verylongsuspiciousphishingdomainnamethatmightwrapmaliciousexample.com", }), ], }), diff --git a/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.html b/apps/browser/src/dirt/phishing-detection/popup/protected-by-component.html similarity index 69% rename from apps/browser/src/dirt/phishing-detection/pages/protected-by-component.html rename to apps/browser/src/dirt/phishing-detection/popup/protected-by-component.html index d9f26bc9c90..6c55097ade3 100644 --- a/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.html +++ b/apps/browser/src/dirt/phishing-detection/popup/protected-by-component.html @@ -1 +1 @@ -{{ "protectedBy" | i18n: "Bitwarden Phishing Blocker" }} +{{ "protectedBy" | i18n: "Bitwarden phishing blocker" }} diff --git a/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.ts b/apps/browser/src/dirt/phishing-detection/popup/protected-by-component.ts similarity index 86% rename from apps/browser/src/dirt/phishing-detection/pages/protected-by-component.ts rename to apps/browser/src/dirt/phishing-detection/popup/protected-by-component.ts index 71cdac89aa2..8da916af5e6 100644 --- a/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.ts +++ b/apps/browser/src/dirt/phishing-detection/popup/protected-by-component.ts @@ -1,6 +1,4 @@ -// 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"; 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 d6aca6abea0..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,127 +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 accountService: AccountService; - let auditService: AuditService; let billingAccountProfileStateService: BillingAccountProfileStateService; let configService: ConfigService; - let eventCollectionService: EventCollectionService; let logService: LogService; - let storageService: AbstractStorageService; - let taskSchedulerService: TaskSchedulerService; + let phishingDataService: MockProxy; + let messageListener: MockProxy; beforeEach(() => { accountService = { getAccount$: jest.fn(() => of(null)) } as any; - auditService = { getKnownPhishingDomains: jest.fn() } 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; - storageService = { get: jest.fn(), save: jest.fn() } as any; - taskSchedulerService = { registerTaskHandler: jest.fn(), setInterval: jest.fn() } as any; + phishingDataService = mock(); + messageListener = mock({ + messages$(_commandDefinition) { + return new Observable(); + }, + }); }); it("should initialize without errors", () => { expect(() => { PhishingDetectionService.initialize( accountService, - auditService, billingAccountProfileStateService, configService, - eventCollectionService, logService, - storageService, - taskSchedulerService, + phishingDataService, + messageListener, ); }).not.toThrow(); }); - 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; + // 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; - // Patch _setup to call done - const setupSpy = jest - .spyOn(PhishingDetectionService as any, "_setup") - .mockImplementation(async () => { - expect(setupSpy).toHaveBeenCalled(); - done(); - }); + // // Run the initialization + // PhishingDetectionService.initialize( + // accountService, + // billingAccountProfileStateService, + // configService, + // logService, + // phishingDataService, + // messageListener, + // ); + // }); - // Run the initialization - PhishingDetectionService.initialize( - accountService, - auditService, - billingAccountProfileStateService, - configService, - eventCollectionService, - logService, - storageService, - taskSchedulerService, - ); - }); + // 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; - 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; - - // Patch _setup to fail if called - // [FIXME] This test needs to check if the setupSpy fails or is called - // Refactor initialize in PhishingDetectionService to return a Promise or Observable that resolves/completes when initialization is done - // So that spy setups can be properly verified after initialization - // const setupSpy = jest - // .spyOn(PhishingDetectionService as any, "_setup") - // .mockImplementation(async () => { - // throw new Error("Should not call _setup"); - // }); - - // Patch _cleanup to call done - const cleanupSpy = jest - .spyOn(PhishingDetectionService as any, "_cleanup") - .mockImplementation(() => { - expect(cleanupSpy).toHaveBeenCalled(); - done(); - }); - - // Run the initialization - PhishingDetectionService.initialize( - accountService, - auditService, - billingAccountProfileStateService, - configService, - eventCollectionService, - logService, - storageService, - taskSchedulerService, - ); - }); - - 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); - }); - - // Add more tests for other methods as needed + // // 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 179431b155c..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,709 +1,193 @@ import { combineLatest, concatMap, - delay, + distinctUntilChanged, EMPTY, + filter, map, + merge, + of, Subject, - Subscription, 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, - auditService: AuditService, billingAccountProfileStateService: BillingAccountProfileStateService, configService: ConfigService, - eventCollectionService: EventCollectionService, logService: LogService, - storageService: AbstractStorageService, - taskSchedulerService: TaskSchedulerService, - ): 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. Checking prerequisites..."); + logService.debug("[PhishingDetectionService] Initialize called. Checking prerequisites..."); - combineLatest([ + 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( - switchMap(([account, featureEnabled]) => { - if (!account) { - logService.info("[PhishingDetectionService] No active account."); - this._cleanup(); - return EMPTY; - } - return billingAccountProfileStateService - .hasPremiumFromAnySource$(account.id) - .pipe(map((hasPremium) => ({ hasPremium, featureEnabled }))); - }), - concatMap(async ({ hasPremium, featureEnabled }) => { - if (!hasPremium || !featureEnabled) { - logService.info( + distinctUntilChanged(), + switchMap((activeUserHasAccess) => { + if (!activeUserHasAccess) { + logService.debug( "[PhishingDetectionService] User does not have access to phishing detection service.", ); - this._cleanup(); + return EMPTY; } else { - 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 async requestClosePhishingWarningPage() { - await chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Close }); - } - - /** - * Sends a message to the phishing detection service to continue to the caught url - */ - static async requestContinueToDangerousUrl() { - await 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/foreground-browser-biometrics.ts b/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts index b6e84fee31a..d803a457a81 100644 --- a/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts +++ b/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts @@ -48,7 +48,11 @@ export class ForegroundBrowserBiometricsService extends BiometricsService { result: BiometricsStatus; error: string; }>(BiometricsCommands.GetBiometricsStatusForUser, { userId: id }); - return response.result; + if (response != null) { + return response.result; + } else { + return BiometricsStatus.DesktopDisconnected; + } } async getShouldAutopromptNow(): Promise { diff --git a/apps/browser/src/key-management/key-connector/remove-password.component.html b/apps/browser/src/key-management/key-connector/remove-password.component.html deleted file mode 100644 index 427065e83f3..00000000000 --- a/apps/browser/src/key-management/key-connector/remove-password.component.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - @if (loading) { -
    - - {{ "loading" | i18n }} -
    - } @else { -

    {{ "removeMasterPasswordForOrganizationUserKeyConnector" | i18n }}

    -

    {{ "organizationName" | i18n }}:

    -

    {{ organization.name }}

    -

    {{ "keyConnectorDomain" | i18n }}:

    -

    {{ organization.keyConnectorUrl }}

    - - - - } -
    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 deleted file mode 100644 index c4077a1eca9..00000000000 --- a/apps/browser/src/key-management/key-connector/remove-password.component.ts +++ /dev/null @@ -1,14 +0,0 @@ -// FIXME (PM-22628): angular imports are forbidden in background -// eslint-disable-next-line no-restricted-imports -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", - standalone: false, -}) -export class RemovePasswordComponent extends BaseRemovePasswordComponent {} diff --git a/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.spec.ts b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.spec.ts new file mode 100644 index 00000000000..cf5d556a553 --- /dev/null +++ b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.spec.ts @@ -0,0 +1,57 @@ +import { mock } from "jest-mock-extended"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; +import { + VaultTimeoutNumberType, + 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 { BrowserSessionTimeoutSettingsComponentService } from "./browser-session-timeout-settings-component.service"; + +describe("BrowserSessionTimeoutSettingsComponentService", () => { + let service: BrowserSessionTimeoutSettingsComponentService; + let mockI18nService: jest.Mocked; + let mockSessionTimeoutTypeService: jest.Mocked; + let mockPolicyService: jest.Mocked; + let mockMessagingService: jest.Mocked; + + beforeEach(() => { + mockI18nService = mock(); + mockSessionTimeoutTypeService = mock(); + mockPolicyService = mock(); + mockMessagingService = mock(); + + service = new BrowserSessionTimeoutSettingsComponentService( + mockI18nService, + mockSessionTimeoutTypeService, + mockPolicyService, + mockMessagingService, + ); + }); + + describe("onTimeoutSave", () => { + it("should call messagingService.send with 'bgReseedStorage' when timeout is Never", () => { + service.onTimeoutSave(VaultTimeoutStringType.Never); + + expect(mockMessagingService.send).toHaveBeenCalledWith("bgReseedStorage"); + }); + + it.each([ + VaultTimeoutNumberType.Immediately, + VaultTimeoutNumberType.OnMinute, + VaultTimeoutNumberType.EightHours, + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.Custom, + ])("should not call messagingService.send when timeout is %s", (timeoutValue) => { + service.onTimeoutSave(timeoutValue); + + expect(mockMessagingService.send).not.toHaveBeenCalled(); + }); + }); +}); 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..24925e25e24 --- /dev/null +++ b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts @@ -0,0 +1,26 @@ +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; +import { + VaultTimeout, + 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 { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui"; + +export class BrowserSessionTimeoutSettingsComponentService extends SessionTimeoutSettingsComponentService { + constructor( + i18nService: I18nService, + sessionTimeoutTypeService: SessionTimeoutTypeService, + policyService: PolicyService, + private readonly messagingService: MessagingService, + ) { + super(i18nService, sessionTimeoutTypeService, policyService); + } + + override onTimeoutSave(timeout: VaultTimeout): void { + if (timeout === VaultTimeoutStringType.Never) { + this.messagingService.send("bgReseedStorage"); + } + } +} diff --git a/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.spec.ts b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.spec.ts new file mode 100644 index 00000000000..83de5c51a4a --- /dev/null +++ b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.spec.ts @@ -0,0 +1,139 @@ +import { mock } from "jest-mock-extended"; + +import { + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { BrowserSessionTimeoutTypeService } from "./browser-session-timeout-type.service"; + +describe("BrowserSessionTimeoutTypeService", () => { + let service: BrowserSessionTimeoutTypeService; + let mockPlatformUtilsService: jest.Mocked; + + beforeEach(() => { + mockPlatformUtilsService = mock(); + service = new BrowserSessionTimeoutTypeService(mockPlatformUtilsService); + }); + + describe("isAvailable", () => { + it.each([ + VaultTimeoutNumberType.Immediately, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.Custom, + ])("should return true for always available type: %s", async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(true); + }); + + it.each([VaultTimeoutNumberType.OnMinute, VaultTimeoutNumberType.EightHours])( + "should return true for numeric timeout type: %s", + async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(true); + }, + ); + + describe("OnLocked availability", () => { + const mockNavigatorPlatform = (platform: string) => { + Object.defineProperty(navigator, "platform", { + value: platform, + writable: true, + configurable: true, + }); + }; + + beforeEach(() => { + mockNavigatorPlatform("Linux x86_64"); + mockPlatformUtilsService.isFirefox.mockReturnValue(false); + mockPlatformUtilsService.isSafari.mockReturnValue(false); + mockPlatformUtilsService.isOpera.mockReturnValue(false); + }); + + it("should return true when not Firefox, Safari, or Opera on Mac", async () => { + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(true); + }); + + it("should return true when Opera on non-Mac platform", async () => { + mockNavigatorPlatform("Win32"); + mockPlatformUtilsService.isOpera.mockReturnValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(true); + }); + + it("should return false when Opera on Mac", async () => { + mockNavigatorPlatform("MacIntel"); + mockPlatformUtilsService.isOpera.mockReturnValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(false); + }); + + it("should return false when Firefox", async () => { + mockPlatformUtilsService.isFirefox.mockReturnValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(false); + }); + + it("should return false when Safari", async () => { + mockPlatformUtilsService.isSafari.mockReturnValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(false); + }); + }); + + it.each([VaultTimeoutStringType.OnIdle, VaultTimeoutStringType.OnSleep])( + "should return false for unavailable timeout type: %s", + async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(false); + }, + ); + }); + + describe("getOrPromoteToAvailable", () => { + it.each([ + VaultTimeoutNumberType.Immediately, + VaultTimeoutNumberType.OnMinute, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.Custom, + ])("should return the original type when it is available: %s", async (timeoutType) => { + jest.spyOn(service, "isAvailable").mockResolvedValue(true); + + const result = await service.getOrPromoteToAvailable(timeoutType); + + expect(result).toBe(timeoutType); + expect(service.isAvailable).toHaveBeenCalledWith(timeoutType); + }); + + it.each([ + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnLocked, + 5, + ])("should return OnRestart when type is not available: %s", async (timeoutType) => { + jest.spyOn(service, "isAvailable").mockResolvedValue(false); + + const result = await service.getOrPromoteToAvailable(timeoutType); + + expect(result).toBe(VaultTimeoutStringType.OnRestart); + expect(service.isAvailable).toHaveBeenCalledWith(timeoutType); + }); + }); +}); diff --git a/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.ts b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.ts new file mode 100644 index 00000000000..33ac3e356d4 --- /dev/null +++ b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.ts @@ -0,0 +1,43 @@ +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; +import { + isVaultTimeoutTypeNumeric, + VaultTimeout, + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +export class BrowserSessionTimeoutTypeService implements SessionTimeoutTypeService { + constructor(private readonly platformUtilsService: PlatformUtilsService) {} + + async isAvailable(type: VaultTimeout): Promise { + switch (type) { + case VaultTimeoutNumberType.Immediately: + case VaultTimeoutStringType.OnRestart: + case VaultTimeoutStringType.Never: + case VaultTimeoutStringType.Custom: + return true; + case VaultTimeoutStringType.OnLocked: + return ( + !this.platformUtilsService.isFirefox() && + !this.platformUtilsService.isSafari() && + !(this.platformUtilsService.isOpera() && navigator.platform === "MacIntel") + ); + default: + if (isVaultTimeoutTypeNumeric(type)) { + return true; + } + break; + } + + return false; + } + + async getOrPromoteToAvailable(type: VaultTimeout): Promise { + const available = await this.isAvailable(type); + if (!available) { + return VaultTimeoutStringType.OnRestart; + } + return type; + } +} 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 4081ab03359..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,15 +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 }); - } } diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index e218abd2d10..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.10.1", + "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 6f4fc905f44..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.10.1", + "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/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 e4165348c6e..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", () => { diff --git a/apps/browser/src/platform/browser/browser-popup-utils.ts b/apps/browser/src/platform/browser/browser-popup-utils.ts index cd55f6361a0..8343799d0eb 100644 --- a/apps/browser/src/platform/browser/browser-popup-utils.ts +++ b/apps/browser/src/platform/browser/browser-popup-utils.ts @@ -168,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; } /** 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/layout/popup-layout.mdx b/apps/browser/src/platform/popup/layout/popup-layout.mdx index a2725350a8f..0a1c80b13f6 100644 --- a/apps/browser/src/platform/popup/layout/popup-layout.mdx +++ b/apps/browser/src/platform/popup/layout/popup-layout.mdx @@ -1,4 +1,4 @@ -import { Meta, Story, Canvas } from "@storybook/addon-docs"; +import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks"; import * as stories from "./popup-layout.stories"; 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 ca79a6d9d14..c6ffe1a6414 100644 --- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts +++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts @@ -29,11 +29,9 @@ import { SearchModule, SectionComponent, ScrollLayoutDirective, - SkeletonComponent, - SkeletonTextComponent, - SkeletonGroupComponent, } 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"; @@ -366,9 +364,7 @@ export default { SectionComponent, IconButtonModule, BadgeModule, - SkeletonComponent, - SkeletonTextComponent, - SkeletonGroupComponent, + VaultLoadingSkeletonComponent, ], providers: [ { @@ -634,21 +630,9 @@ export const SkeletonLoading: Story = { template: /* HTML */ ` - + -
    -
    Loading...
    -
    - - @for (num of data; track $index) { - - - - - - } -
    -
    +
    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 db5ea641691..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,11 +1,16 @@ 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"; -// 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", templateUrl: "popup-page.component.html", @@ -13,28 +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); - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() loading = false; + readonly loading = input(false); - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input({ transform: booleanAttribute }) - disablePadding = false; + readonly disablePadding = input(false, { transform: booleanAttribute }); - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - 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" */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @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 @@
} @else { -
- + +
+
+ +
+
- } diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index b85da665fa0..8f00569b720 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -67,6 +67,8 @@ import { initPopupClosedListener } from "../platform/services/popup-view-cache-b import { routerTransition } from "./app-routing.animations"; import { DesktopSyncVerificationDialogComponent } from "./components/desktop-sync-verification-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-root", styles: [], diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index ec35fc7c554..d178cee2fc3 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -28,7 +28,6 @@ import { CurrentAccountComponent } from "../auth/popup/account-switching/current import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; import { NotificationsSettingsComponent } from "../autofill/popup/settings/notifications.component"; -import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; 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"; @@ -85,14 +84,8 @@ import "../platform/popup/locales"; CalloutModule, LinkModule, ], - declarations: [ - AppComponent, - ColorPasswordPipe, - ColorPasswordCountPipe, - TabsV2Component, - RemovePasswordComponent, - ], - exports: [], + declarations: [AppComponent, ColorPasswordPipe, ColorPasswordCountPipe, TabsV2Component], + exports: [CalloutModule], providers: [CurrencyPipe, DatePipe], bootstrap: [AppComponent], }) diff --git a/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts b/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts index 2ca24da6c75..e1774dbbddd 100644 --- a/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts +++ b/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts @@ -9,12 +9,15 @@ import { ButtonModule, DialogModule, DialogService, + CenterPositionStrategy, } from "@bitwarden/components"; export type DesktopSyncVerificationDialogParams = { 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: "desktop-sync-verification-dialog.component.html", imports: [JslibModule, ButtonModule, DialogModule], @@ -47,6 +50,7 @@ export class DesktopSyncVerificationDialogComponent implements OnDestroy, OnInit static open(dialogService: DialogService, data: DesktopSyncVerificationDialogParams) { return dialogService.open(DesktopSyncVerificationDialogComponent, { data, + positionStrategy: new CenterPositionStrategy(), }); } } diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html index dcd0496ed30..7a1815b86ed 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html @@ -1,31 +1,21 @@ - -
- -
- - - - - -
- +
+ + @if (showAcctSwitcher && hasLoggedInAccount) { + + } +
diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts index 7a30e15582c..57ef285bdf5 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts @@ -238,6 +238,11 @@ export const DefaultContentExample: Story = { }, ], }), + parameters: { + chromatic: { + viewports: [380, 1280], + }, + }, }; // Dynamic Content Example 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 deleted file mode 100644 index 01b9d3f05d5..00000000000 --- a/apps/browser/src/popup/scss/base.scss +++ /dev/null @@ -1,453 +0,0 @@ -@import "variables.scss"; - -* { - box-sizing: border-box; - padding: 0; - margin: 0; -} - -html { - overflow: hidden; - min-height: 600px; - height: 100%; - - &.body-sm { - min-height: 500px; - } - - &.body-xs { - min-height: 400px; - } - - &.body-xxs { - min-height: 300px; - } - - &.body-3xs { - min-height: 240px; - } - - &.body-full { - min-height: unset; - width: 100%; - height: 100%; - - & body { - width: 100%; - } - } -} - -html, -body { - font-family: $font-family-sans-serif; - font-size: $font-size-base; - line-height: $line-height-base; - -webkit-font-smoothing: antialiased; -} - -body { - width: 380px; - height: 100%; - position: relative; - min-height: inherit; - overflow: hidden; - color: $text-color; - background-color: $background-color; - - @include themify($themes) { - color: themed("textColor"); - background-color: themed("backgroundColor"); - } -} - -h1, -h2, -h3, -h4, -h5, -h6 { - font-family: $font-family-sans-serif; - font-size: $font-size-base; - font-weight: normal; -} - -p { - margin-bottom: 10px; -} - -ul, -ol { - margin-bottom: 10px; -} - -img { - border: none; -} - -a:not(popup-page a, popup-tab-navigation a) { - text-decoration: none; - - @include themify($themes) { - color: themed("primaryColor"); - } - - &:hover, - &:focus { - @include themify($themes) { - color: darken(themed("primaryColor"), 6%); - } - } -} - -input:not(bit-form-field input, bit-search input, input[bitcheckbox]), -select:not(bit-form-field select), -textarea:not(bit-form-field textarea) { - @include themify($themes) { - color: themed("textColor"); - background-color: themed("inputBackgroundColor"); - } -} - -input:not(input[bitcheckbox]), -select, -textarea, -button:not(bit-chip-select button) { - font-size: $font-size-base; - font-family: $font-family-sans-serif; -} - -input[type*="date"] { - @include themify($themes) { - color-scheme: themed("dateInputColorScheme"); - } -} - -::-webkit-calendar-picker-indicator { - @include themify($themes) { - filter: themed("webkitCalendarPickerFilter"); - } -} - -::-webkit-calendar-picker-indicator:hover { - @include themify($themes) { - filter: themed("webkitCalendarPickerHoverFilter"); - } - cursor: pointer; -} - -select { - width: 100%; - padding: 0.35rem; -} - -button { - cursor: pointer; -} - -textarea { - resize: vertical; -} - -app-root > div { - height: 100%; - width: 100%; -} - -main::-webkit-scrollbar, -cdk-virtual-scroll-viewport::-webkit-scrollbar, -.vault-select::-webkit-scrollbar { - width: 10px; - height: 10px; -} - -main::-webkit-scrollbar-track, -.vault-select::-webkit-scrollbar-track { - background-color: transparent; -} - -cdk-virtual-scroll-viewport::-webkit-scrollbar-track { - @include themify($themes) { - background-color: themed("backgroundColor"); - } -} - -main::-webkit-scrollbar-thumb, -cdk-virtual-scroll-viewport::-webkit-scrollbar-thumb, -.vault-select::-webkit-scrollbar-thumb { - border-radius: 10px; - margin-right: 1px; - - @include themify($themes) { - background-color: themed("scrollbarColor"); - } - - &:hover { - @include themify($themes) { - background-color: themed("scrollbarHoverColor"); - } - } -} - -header:not(bit-callout header, bit-dialog header, popup-page header) { - height: 44px; - display: flex; - - &:not(.no-theme) { - border-bottom: 1px solid #000000; - - @include themify($themes) { - color: themed("headerColor"); - background-color: themed("headerBackgroundColor"); - border-bottom-color: themed("headerBorderColor"); - } - } - - .header-content { - display: flex; - flex: 1 1 auto; - } - - .header-content > .right, - .header-content > .right > .right { - height: 100%; - } - - .left, - .right { - flex: 1; - display: flex; - min-width: -webkit-min-content; /* Workaround to Chrome bug */ - .header-icon { - margin-right: 5px; - } - } - - .right { - justify-content: flex-end; - align-items: center; - app-avatar { - max-height: 30px; - margin-right: 5px; - } - } - - .center { - display: flex; - align-items: center; - text-align: center; - min-width: 0; - } - - .login-center { - margin: auto; - } - - app-pop-out > button, - div > button:not(app-current-account button):not(.home-acc-switcher-btn), - div > a { - border: none; - padding: 0 10px; - text-decoration: none; - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - height: 100%; - white-space: pre; - - &:not(.home-acc-switcher-btn):hover, - &:not(.home-acc-switcher-btn):focus { - @include themify($themes) { - background-color: themed("headerBackgroundHoverColor"); - color: themed("headerColor"); - } - } - - &[disabled] { - opacity: 0.65; - cursor: default !important; - background-color: inherit !important; - } - - i + span { - margin-left: 5px; - } - } - - app-pop-out { - display: flex; - padding-right: 0.5em; - } - - .title { - font-weight: bold; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .search { - padding: 7px 10px; - width: 100%; - text-align: left; - position: relative; - display: flex; - - .bwi { - position: absolute; - top: 15px; - left: 20px; - - @include themify($themes) { - color: themed("headerInputPlaceholderColor"); - } - } - - input:not(bit-form-field input) { - width: 100%; - margin: 0; - border: none; - padding: 5px 10px 5px 30px; - border-radius: $border-radius; - - @include themify($themes) { - background-color: themed("headerInputBackgroundColor"); - color: themed("headerInputColor"); - } - - &::selection { - @include themify($themes) { - // explicitly set text selection to invert foreground/background - background-color: themed("headerInputColor"); - color: themed("headerInputBackgroundColor"); - } - } - - &:focus { - border-radius: $border-radius; - outline: none; - - @include themify($themes) { - background-color: themed("headerInputBackgroundFocusColor"); - } - } - - &::-webkit-input-placeholder { - @include themify($themes) { - color: themed("headerInputPlaceholderColor"); - } - } - /** make the cancel button visible in both dark/light themes **/ - &[type="search"]::-webkit-search-cancel-button { - -webkit-appearance: none; - appearance: none; - height: 15px; - width: 15px; - background-repeat: no-repeat; - mask-image: url("../images/close-button-white.svg"); - -webkit-mask-image: url("../images/close-button-white.svg"); - @include themify($themes) { - background-color: themed("headerInputColor"); - } - } - } - } - - .left + .search, - .left + .sr-only + .search { - padding-left: 0; - - .bwi { - left: 10px; - } - } - - .search + .right { - margin-left: -10px; - } -} - -.content { - padding: 15px 5px; -} - -app-root { - width: 100%; - height: 100vh; - display: flex; - - @include themify($themes) { - background-color: themed("backgroundColor"); - } -} - -main:not(popup-page main):not(auth-anon-layout main) { - position: absolute; - top: 44px; - bottom: 0; - left: 0; - right: 0; - overflow-y: auto; - overflow-x: hidden; - - @include themify($themes) { - background-color: themed("backgroundColor"); - } - - &.no-header { - top: 0; - } - - &.flex { - display: flex; - flex-flow: column; - height: calc(100% - 44px); - } -} - -.center-content, -.no-items, -.full-loading-spinner { - display: flex; - justify-content: center; - align-items: center; - height: 100%; - flex-direction: column; - flex-grow: 1; -} - -.no-items, -.full-loading-spinner { - text-align: center; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - - .no-items-image { - @include themify($themes) { - content: url("../images/search-desktop" + themed("svgSuffix")); - } - } - - .bwi { - margin-bottom: 10px; - - @include themify($themes) { - color: themed("disabledIconColor"); - } - } -} - -// cdk-virtual-scroll -.cdk-virtual-scroll-viewport { - width: 100%; - height: 100%; - overflow-y: auto; - overflow-x: hidden; -} - -.cdk-virtual-scroll-content-wrapper { - width: 100%; -} diff --git a/apps/browser/src/popup/scss/box.scss b/apps/browser/src/popup/scss/box.scss deleted file mode 100644 index 763f73a15cb..00000000000 --- a/apps/browser/src/popup/scss/box.scss +++ /dev/null @@ -1,620 +0,0 @@ -@import "variables.scss"; - -.box { - position: relative; - width: 100%; - - &.first { - margin-top: 0; - } - - .box-header { - margin: 0 10px 5px 10px; - text-transform: uppercase; - display: flex; - - @include themify($themes) { - color: themed("headingColor"); - } - } - - .box-content { - @include themify($themes) { - background-color: themed("backgroundColor"); - border-color: themed("borderColor"); - } - - &.box-content-padded { - padding: 10px 15px; - } - - &.condensed .box-content-row, - .box-content-row.condensed { - padding-top: 5px; - padding-bottom: 5px; - } - - &.no-hover .box-content-row, - .box-content-row.no-hover { - &:hover, - &:focus { - @include themify($themes) { - background-color: themed("boxBackgroundColor") !important; - } - } - } - - &.single-line .box-content-row, - .box-content-row.single-line { - padding-top: 10px; - padding-bottom: 10px; - margin: 5px; - } - - &.row-top-padding { - padding-top: 10px; - } - } - - .box-footer { - margin: 0 5px 5px 5px; - padding: 0 10px 5px 10px; - font-size: $font-size-small; - - button.btn { - font-size: $font-size-small; - padding: 0; - } - - button.btn.primary { - font-size: $font-size-base; - padding: 7px 15px; - width: 100%; - - &:hover { - @include themify($themes) { - border-color: themed("borderHoverColor") !important; - } - } - } - - @include themify($themes) { - color: themed("mutedColor"); - } - } - - &.list { - margin: 10px 0 20px 0; - .box-content { - .virtual-scroll-item { - display: inline-block; - width: 100%; - } - - .box-content-row { - text-decoration: none; - border-radius: $border-radius; - // background-color: $background-color; - - @include themify($themes) { - color: themed("textColor"); - background-color: themed("boxBackgroundColor"); - } - - &.padded { - padding-top: 10px; - padding-bottom: 10px; - } - - &.no-hover { - &:hover { - @include themify($themes) { - background-color: themed("boxBackgroundColor") !important; - } - } - } - - &:hover, - &:focus, - &.active { - @include themify($themes) { - background-color: themed("listItemBackgroundHoverColor"); - } - } - - &:focus { - border-left: 5px solid #000000; - padding-left: 5px; - - @include themify($themes) { - border-left-color: themed("mutedColor"); - } - } - - .action-buttons { - .row-btn { - padding-left: 5px; - padding-right: 5px; - } - } - - .text:not(.no-ellipsis), - .detail { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .row-main { - display: flex; - min-width: 0; - align-items: normal; - - .row-main-content { - min-width: 0; - } - } - } - - &.single-line { - .box-content-row { - display: flex; - padding-top: 10px; - padding-bottom: 10px; - margin: 5px; - border-radius: $border-radius; - } - } - } - } -} - -.box-content-row { - display: block; - padding: 5px 10px; - position: relative; - z-index: 1; - border-radius: $border-radius; - margin: 3px 5px; - - @include themify($themes) { - background-color: themed("boxBackgroundColor"); - } - - &:last-child { - &:before { - border: none; - height: 0; - } - } - - &.override-last:last-child:before { - border-bottom: 1px solid #000000; - @include themify($themes) { - border-bottom-color: themed("boxBorderColor"); - } - } - - &.last:last-child:before { - border-bottom: 1px solid #000000; - @include themify($themes) { - border-bottom-color: themed("boxBorderColor"); - } - } - - &:after { - content: ""; - display: table; - clear: both; - } - - &:hover, - &:focus, - &.active { - @include themify($themes) { - background-color: themed("boxBackgroundHoverColor"); - } - } - - &.pre { - white-space: pre; - overflow-x: auto; - } - - &.pre-wrap { - white-space: pre-wrap; - overflow-x: auto; - } - - .row-label, - label { - font-size: $font-size-small; - display: block; - width: 100%; - margin-bottom: 5px; - - @include themify($themes) { - color: themed("mutedColor"); - } - - .sub-label { - margin-left: 10px; - } - } - - .flex-label { - font-size: $font-size-small; - display: flex; - flex-grow: 1; - margin-bottom: 5px; - - @include themify($themes) { - color: themed("mutedColor"); - } - - > a { - flex-grow: 0; - } - } - - .text, - .detail { - display: block; - text-align: left; - - @include themify($themes) { - color: themed("textColor"); - } - } - - .detail { - font-size: $font-size-small; - - @include themify($themes) { - color: themed("mutedColor"); - } - } - - .img-right, - .txt-right { - float: right; - margin-left: 10px; - } - - .row-main { - flex-grow: 1; - min-width: 0; - } - - &.box-content-row-flex, - .box-content-row-flex, - &.box-content-row-checkbox, - &.box-content-row-link, - &.box-content-row-input, - &.box-content-row-slider, - &.box-content-row-multi { - display: flex; - align-items: center; - word-break: break-all; - - &.box-content-row-word-break { - word-break: normal; - } - } - - &.box-content-row-multi { - input:not([type="checkbox"]) { - width: 100%; - } - - input + label.sr-only + select { - margin-top: 5px; - } - - > a, - > button { - padding: 8px 8px 8px 4px; - margin: 0; - - @include themify($themes) { - color: themed("dangerColor"); - } - } - } - - &.box-content-row-multi, - &.box-content-row-newmulti { - padding-left: 10px; - } - - &.box-content-row-newmulti { - @include themify($themes) { - color: themed("primaryColor"); - } - } - - &.box-content-row-checkbox, - &.box-content-row-link, - &.box-content-row-input, - &.box-content-row-slider { - padding-top: 10px; - padding-bottom: 10px; - margin: 5px; - - label, - .row-label { - font-size: $font-size-base; - display: block; - width: initial; - margin-bottom: 0; - - @include themify($themes) { - color: themed("textColor"); - } - } - - > span { - @include themify($themes) { - color: themed("mutedColor"); - } - } - - > input { - margin: 0 0 0 auto; - padding: 0; - } - - > * { - margin-right: 15px; - - &:last-child { - margin-right: 0; - } - } - } - - &.box-content-row-checkbox-left { - justify-content: flex-start; - - > input { - margin: 0 15px 0 0; - } - } - - &.box-content-row-input { - label { - white-space: nowrap; - } - - input { - text-align: right; - - &[type="number"] { - max-width: 50px; - } - } - } - - &.box-content-row-slider { - input[type="range"] { - height: 10px; - } - - input[type="number"] { - width: 45px; - } - - label { - white-space: nowrap; - } - } - - input:not([type="checkbox"]):not([type="radio"]), - textarea { - border: none; - width: 100%; - background-color: transparent !important; - - &::-webkit-input-placeholder { - @include themify($themes) { - color: themed("inputPlaceholderColor"); - } - } - - &:not([type="file"]):focus { - outline: none; - } - } - - select { - width: 100%; - border: 1px solid #000000; - border-radius: $border-radius; - padding: 7px 4px; - - @include themify($themes) { - border-color: themed("inputBorderColor"); - } - } - - .action-buttons { - display: flex; - margin-left: 5px; - - &.action-buttons-fixed { - align-self: start; - margin-top: 2px; - } - - .row-btn { - cursor: pointer; - padding: 10px 8px; - background: none; - border: none; - - @include themify($themes) { - color: themed("boxRowButtonColor"); - } - - &:hover, - &:focus { - @include themify($themes) { - color: themed("boxRowButtonHoverColor"); - } - } - - &.disabled, - &[disabled] { - @include themify($themes) { - color: themed("disabledIconColor"); - opacity: themed("disabledBoxOpacity"); - } - - &:hover { - @include themify($themes) { - color: themed("disabledIconColor"); - opacity: themed("disabledBoxOpacity"); - } - } - cursor: default !important; - } - } - - &.no-pad .row-btn { - padding-top: 0; - padding-bottom: 0; - } - } - - &:not(.box-draggable-row) { - .action-buttons .row-btn:last-child { - margin-right: -3px; - } - } - - &.box-draggable-row { - &.box-content-row-checkbox { - input[type="checkbox"] + .drag-handle { - margin-left: 10px; - } - } - } - - .drag-handle { - cursor: move; - padding: 10px 2px 10px 8px; - user-select: none; - - @include themify($themes) { - color: themed("mutedColor"); - } - } - - &.cdk-drag-preview { - position: relative; - display: flex; - align-items: center; - opacity: 0.8; - - @include themify($themes) { - background-color: themed("boxBackgroundColor"); - } - } - - select.field-type { - margin: 5px 0 0 25px; - width: calc(100% - 25px); - } - - .icon { - display: flex; - justify-content: center; - align-items: center; - min-width: 34px; - margin-left: -5px; - - @include themify($themes) { - color: themed("mutedColor"); - } - - &.icon-small { - min-width: 25px; - } - - img { - border-radius: $border-radius; - max-height: 20px; - max-width: 20px; - } - } - - .progress { - display: flex; - height: 5px; - overflow: hidden; - margin: 5px -15px -10px; - - .progress-bar { - display: flex; - flex-direction: column; - justify-content: center; - white-space: nowrap; - background-color: $brand-primary; - } - } - - .radio-group { - display: flex; - justify-content: flex-start; - align-items: center; - margin-bottom: 5px; - - input { - flex-grow: 0; - } - - label { - margin: 0 0 0 5px; - flex-grow: 1; - font-size: $font-size-base; - display: block; - width: 100%; - - @include themify($themes) { - color: themed("textColor"); - } - } - - &.align-start { - align-items: start; - margin-top: 10px; - - label { - margin-top: -4px; - } - } - } -} - -.truncate { - display: inline-block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -form { - .box { - .box-content { - .box-content-row { - &.no-hover { - &:hover { - @include themify($themes) { - background-color: themed("transparentColor") !important; - } - } - } - } - } - } -} diff --git a/apps/browser/src/popup/scss/buttons.scss b/apps/browser/src/popup/scss/buttons.scss deleted file mode 100644 index e9af536dc3d..00000000000 --- a/apps/browser/src/popup/scss/buttons.scss +++ /dev/null @@ -1,118 +0,0 @@ -@import "variables.scss"; - -.btn { - border-radius: $border-radius; - padding: 7px 15px; - border: 1px solid #000000; - font-size: $font-size-base; - text-align: center; - cursor: pointer; - - @include themify($themes) { - background-color: themed("buttonBackgroundColor"); - border-color: themed("buttonBorderColor"); - color: themed("buttonColor"); - } - - &.primary { - @include themify($themes) { - color: themed("buttonPrimaryColor"); - } - } - - &.danger { - @include themify($themes) { - color: themed("buttonDangerColor"); - } - } - - &.callout-half { - font-weight: bold; - max-width: 50%; - } - - &:hover:not([disabled]) { - cursor: pointer; - - @include themify($themes) { - background-color: darken(themed("buttonBackgroundColor"), 1.5%); - border-color: darken(themed("buttonBorderColor"), 17%); - color: darken(themed("buttonColor"), 10%); - } - - &.primary { - @include themify($themes) { - color: darken(themed("buttonPrimaryColor"), 6%); - } - } - - &.danger { - @include themify($themes) { - color: darken(themed("buttonDangerColor"), 6%); - } - } - } - - &:focus:not([disabled]) { - cursor: pointer; - outline: 0; - - @include themify($themes) { - background-color: darken(themed("buttonBackgroundColor"), 6%); - border-color: darken(themed("buttonBorderColor"), 25%); - } - } - - &[disabled] { - opacity: 0.65; - cursor: default !important; - } - - &.block { - display: block; - width: calc(100% - 10px); - margin: 0 auto; - } - - &.link, - &.neutral { - border: none !important; - background: none !important; - - &:focus { - text-decoration: underline; - } - } -} - -.action-buttons { - .btn { - &:focus { - outline: auto; - } - } -} - -button.box-content-row { - display: block; - width: calc(100% - 10px); - text-align: left; - border-color: none; - - @include themify($themes) { - background-color: themed("boxBackgroundColor"); - } -} - -button { - border: none; - background: transparent; - color: inherit; -} - -.login-buttons { - .btn.block { - width: 100%; - margin-bottom: 10px; - } -} diff --git a/apps/browser/src/popup/scss/environment.scss b/apps/browser/src/popup/scss/environment.scss deleted file mode 100644 index cd8f6379e2c..00000000000 --- a/apps/browser/src/popup/scss/environment.scss +++ /dev/null @@ -1,43 +0,0 @@ -@import "variables.scss"; - -html.browser_safari { - &.safari_height_fix { - body { - height: 360px !important; - - &.body-xs { - height: 300px !important; - } - - &.body-full { - height: 100% !important; - } - } - } - - header { - .search .bwi { - left: 20px; - } - - .left + .search .bwi { - left: 10px; - } - } - - .content { - &.login-page { - padding-top: 100px; - } - } - - app-root { - border-width: 1px; - border-style: solid; - border-color: #000000; - } - - &.theme_light app-root { - border-color: #777777; - } -} diff --git a/apps/browser/src/popup/scss/grid.scss b/apps/browser/src/popup/scss/grid.scss deleted file mode 100644 index 8cdb29bb52c..00000000000 --- a/apps/browser/src/popup/scss/grid.scss +++ /dev/null @@ -1,11 +0,0 @@ -.row { - display: flex; - margin: 0 -15px; - width: 100%; -} - -.col { - flex-basis: 0; - flex-grow: 1; - padding: 0 15px; -} diff --git a/apps/browser/src/popup/scss/misc.scss b/apps/browser/src/popup/scss/misc.scss deleted file mode 100644 index 006e1d35f6a..00000000000 --- a/apps/browser/src/popup/scss/misc.scss +++ /dev/null @@ -1,348 +0,0 @@ -@import "variables.scss"; - -small, -.small { - font-size: $font-size-small; -} - -.bg-primary { - @include themify($themes) { - background-color: themed("primaryColor") !important; - } -} - -.bg-success { - @include themify($themes) { - background-color: themed("successColor") !important; - } -} - -.bg-danger { - @include themify($themes) { - background-color: themed("dangerColor") !important; - } -} - -.bg-info { - @include themify($themes) { - background-color: themed("infoColor") !important; - } -} - -.bg-warning { - @include themify($themes) { - background-color: themed("warningColor") !important; - } -} - -.text-primary { - @include themify($themes) { - color: themed("primaryColor") !important; - } -} - -.text-success { - @include themify($themes) { - color: themed("successColor") !important; - } -} - -.text-muted { - @include themify($themes) { - color: themed("mutedColor") !important; - } -} - -.text-default { - @include themify($themes) { - color: themed("textColor") !important; - } -} - -.text-danger { - @include themify($themes) { - color: themed("dangerColor") !important; - } -} - -.text-info { - @include themify($themes) { - color: themed("infoColor") !important; - } -} - -.text-warning { - @include themify($themes) { - color: themed("warningColor") !important; - } -} - -.text-center { - text-align: center; -} - -.font-weight-semibold { - font-weight: 600; -} - -p.lead { - font-size: $font-size-large; - margin-bottom: 20px; - font-weight: normal; -} - -.flex-right { - margin-left: auto; -} - -.flex-bottom { - margin-top: auto; -} - -.no-margin { - margin: 0 !important; -} - -.display-block { - display: block !important; -} - -.monospaced { - font-family: $font-family-monospace; -} - -.show-whitespace { - white-space: pre-wrap; -} - -.img-responsive { - display: block; - max-width: 100%; - height: auto; -} - -.img-rounded { - border-radius: $border-radius; -} - -.select-index-top { - position: relative; - z-index: 100; -} - -.sr-only { - position: absolute !important; - width: 1px !important; - height: 1px !important; - padding: 0 !important; - margin: -1px !important; - overflow: hidden !important; - clip: rect(0, 0, 0, 0) !important; - border: 0 !important; -} - -:not(:focus) > .exists-only-on-parent-focus { - display: none; -} - -.password-wrapper { - overflow-wrap: break-word; - white-space: pre-wrap; - min-width: 0; -} - -.password-number { - @include themify($themes) { - color: themed("passwordNumberColor"); - } -} - -.password-special { - @include themify($themes) { - color: themed("passwordSpecialColor"); - } -} - -.password-character { - display: inline-flex; - flex-direction: column; - align-items: center; - width: 30px; - height: 36px; - font-weight: 600; - - &:nth-child(odd) { - @include themify($themes) { - background-color: themed("backgroundColor"); - } - } -} - -.password-count { - white-space: nowrap; - font-size: 8px; - - @include themify($themes) { - color: themed("passwordCountText") !important; - } -} - -#duo-frame { - background: url("../images/loading.svg") 0 0 no-repeat; - width: 100%; - height: 470px; - margin-bottom: -10px; - - iframe { - width: 100%; - height: 100%; - border: none; - } -} - -#web-authn-frame { - width: 100%; - height: 40px; - - iframe { - border: none; - height: 100%; - width: 100%; - } -} - -body.linux-webauthn { - width: 485px !important; - #web-authn-frame { - iframe { - width: 375px; - margin: 0 55px; - } - } -} - -app-root > #loading { - display: flex; - text-align: center; - justify-content: center; - align-items: center; - height: 100%; - width: 100%; - color: $text-muted; - - @include themify($themes) { - color: themed("mutedColor"); - } -} - -app-vault-icon, -.app-vault-icon { - display: flex; -} - -.logo-image { - margin: 0 auto; - width: 142px; - height: 21px; - background-size: 142px 21px; - background-repeat: no-repeat; - @include themify($themes) { - background-image: url("../images/logo-" + themed("logoSuffix") + "@2x.png"); - } - @media (min-width: 219px) { - width: 189px; - height: 28px; - background-size: 189px 28px; - } - @media (min-width: 314px) { - width: 284px; - height: 43px; - background-size: 284px 43px; - } -} - -[hidden] { - display: none !important; -} - -.draggable { - cursor: move; -} - -input[type="password"]::-ms-reveal { - display: none; -} - -.flex { - display: flex; - - &.flex-grow { - > * { - flex: 1; - } - } -} - -// Text selection styles -// Set explicit selection styles (assumes primary accent color has sufficient -// contrast against the background, so its inversion is also still readable) -// and suppress user selection for most elements (to make it more app-like) - -:not(bit-form-field input)::selection { - @include themify($themes) { - color: themed("backgroundColor"); - background-color: themed("primaryAccentColor"); - } -} - -h1, -h2, -h3, -label, -a, -button, -p, -img, -.box-header, -.box-footer, -.callout, -.row-label, -.modal-title, -.overlay-container { - user-select: none; - - &.user-select { - user-select: auto; - } -} - -/* tweak for inconsistent line heights in cipher view */ -.box-footer button, -.box-footer a { - line-height: 1; -} - -// Workaround for slow performance on external monitors on Chrome + MacOS -// See: https://bugs.chromium.org/p/chromium/issues/detail?id=971701#c64 -@keyframes redraw { - 0% { - opacity: 0.99; - } - 100% { - opacity: 1; - } -} -html.force_redraw { - animation: redraw 1s linear infinite; -} - -/* override for vault icon in browser (pre extension refresh) */ -app-vault-icon:not(app-vault-list-items-container app-vault-icon) > div { - display: flex; - justify-content: center; - align-items: center; - float: left; - height: 36px; - width: 34px; - margin-left: -5px; -} diff --git a/apps/browser/src/popup/scss/pages.scss b/apps/browser/src/popup/scss/pages.scss deleted file mode 100644 index 56c5f80c86c..00000000000 --- a/apps/browser/src/popup/scss/pages.scss +++ /dev/null @@ -1,144 +0,0 @@ -@import "variables.scss"; - -app-home { - position: fixed; - height: 100%; - width: 100%; - - .center-content { - margin-top: -50px; - height: calc(100% + 50px); - } - - img { - width: 284px; - margin: 0 auto; - } - - p.lead { - margin: 30px 0; - } - - .btn + .btn { - margin-top: 10px; - } - - button.settings-icon { - position: absolute; - top: 10px; - left: 10px; - - @include themify($themes) { - color: themed("mutedColor"); - } - - &:not(:hover):not(:focus) { - span { - clip: rect(0 0 0 0); - clip-path: inset(50%); - height: 1px; - overflow: hidden; - position: absolute; - white-space: nowrap; - width: 1px; - } - } - - &:hover, - &:focus { - text-decoration: none; - - @include themify($themes) { - color: themed("primaryColor"); - } - } - } -} - -body.body-sm, -body.body-xs { - app-home { - .center-content { - margin-top: 0; - height: 100%; - } - - p.lead { - margin: 15px 0; - } - } -} - -body.body-full { - app-home { - .center-content { - margin-top: -80px; - height: calc(100% + 80px); - } - } -} - -.createAccountLink { - padding: 30px 10px 0 10px; -} - -.remember-email-check { - padding-top: 18px; - padding-left: 10px; - padding-bottom: 18px; -} - -.login-buttons > button { - margin: 15px 0 15px 0; -} - -.useBrowserlink { - margin-left: 5px; - margin-top: 20px; - - span { - font-weight: 700; - font-size: $font-size-small; - } -} - -.fido2-browser-selector-dropdown { - @include themify($themes) { - background-color: themed("boxBackgroundColor"); - } - padding: 8px; - width: 100%; - box-shadow: - 0 2px 2px 0 rgba(0, 0, 0, 0.14), - 0 3px 1px -2px rgba(0, 0, 0, 0.12), - 0 1px 5px 0 rgba(0, 0, 0, 0.2); - border-radius: $border-radius; -} - -.fido2-browser-selector-dropdown-item { - @include themify($themes) { - color: themed("textColor") !important; - } - width: 100%; - text-align: left; - padding: 0px 15px 0px 5px; - margin-bottom: 5px; - border-radius: 3px; - border: 1px solid transparent; - transition: all 0.2s ease-in-out; - - &:hover { - @include themify($themes) { - background-color: themed("listItemBackgroundHoverColor") !important; - } - } - - &:last-child { - 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/plugins.scss b/apps/browser/src/popup/scss/plugins.scss deleted file mode 100644 index 591e8a1bd0c..00000000000 --- a/apps/browser/src/popup/scss/plugins.scss +++ /dev/null @@ -1,23 +0,0 @@ -@import "variables.scss"; - -@each $mfaType in $mfaTypes { - .mfaType#{$mfaType} { - content: url("../images/two-factor/" + $mfaType + ".png"); - max-width: 100px; - } -} - -.mfaType1 { - @include themify($themes) { - content: url("../images/two-factor/1" + themed("mfaLogoSuffix")); - max-width: 100px; - max-height: 45px; - } -} - -.mfaType7 { - @include themify($themes) { - content: url("../images/two-factor/7" + themed("mfaLogoSuffix")); - max-width: 100px; - } -} diff --git a/apps/browser/src/popup/scss/popup.scss b/apps/browser/src/popup/scss/popup.scss index b150de2c75d..59b4d472f23 100644 --- a/apps/browser/src/popup/scss/popup.scss +++ b/apps/browser/src/popup/scss/popup.scss @@ -1,13 +1,50 @@ @import "../../../../../libs/angular/src/scss/bwicons/styles/style.scss"; @import "variables.scss"; @import "../../../../../libs/angular/src/scss/icons.scss"; -@import "base.scss"; -@import "grid.scss"; -@import "box.scss"; -@import "buttons.scss"; -@import "misc.scss"; -@import "environment.scss"; -@import "pages.scss"; -@import "plugins.scss"; @import "@angular/cdk/overlay-prebuilt.css"; @import "../../../../../libs/components/src/multi-select/scss/bw.theme"; + +.cdk-virtual-scroll-content-wrapper { + width: 100%; +} + +// MFA Types for logo styling with no dark theme alternative +$mfaTypes: 0, 2, 3, 4, 6; + +@each $mfaType in $mfaTypes { + .mfaType#{$mfaType} { + content: url("../images/two-factor/" + $mfaType + ".png"); + max-width: 100px; + } +} + +.mfaType0 { + content: url("../images/two-factor/0.png"); + max-width: 100px; + max-height: 45px; +} + +.mfaType1 { + max-width: 100px; + max-height: 45px; + + &:is(.theme_light *) { + content: url("../images/two-factor/1.png"); + } + + &:is(.theme_dark *) { + content: url("../images/two-factor/1-w.png"); + } +} + +.mfaType7 { + max-width: 100px; + + &:is(.theme_light *) { + content: url("../images/two-factor/7.png"); + } + + &:is(.theme_dark *) { + content: url("../images/two-factor/7-w.png"); + } +} diff --git a/apps/browser/src/popup/scss/tailwind.css b/apps/browser/src/popup/scss/tailwind.css index 54139990356..f58950cc86a 100644 --- a/apps/browser/src/popup/scss/tailwind.css +++ b/apps/browser/src/popup/scss/tailwind.css @@ -1,4 +1,104 @@ -@import "../../../../../libs/components/src/tw-theme.css"; +@import "../../../../../libs/components/src/tw-theme-preflight.css"; + +@layer base { + html { + overflow: hidden; + min-height: 600px; + height: 100%; + + &.body-sm { + min-height: 500px; + } + + &.body-xs { + min-height: 400px; + } + + &.body-xxs { + min-height: 300px; + } + + &.body-3xs { + min-height: 240px; + } + + &.body-full { + min-height: unset; + width: 100%; + height: 100%; + + & body { + width: 100%; + } + } + } + + html.browser_safari { + &.safari_height_fix { + body { + height: 360px !important; + + &.body-xs { + height: 300px !important; + } + + &.body-full { + height: 100% !important; + } + } + } + + app-root { + border-width: 1px; + border-style: solid; + border-color: #000000; + } + + &.theme_light app-root { + border-color: #777777; + } + } + + body { + width: 380px; + height: 100%; + position: relative; + min-height: inherit; + overflow: hidden; + @apply tw-bg-background-alt; + } + + /** + * Workaround for slow performance on external monitors on Chrome + MacOS + * See: https://bugs.chromium.org/p/chromium/issues/detail?id=971701#c64 + */ + @keyframes redraw { + 0% { + opacity: 0.99; + } + 100% { + opacity: 1; + } + } + html.force_redraw { + animation: redraw 1s linear infinite; + } + + /** + * Text selection style: + * suppress user selection for most elements (to make it more app-like) + */ + h1, + h2, + h3, + label, + a, + button, + p, + img { + user-select: none; + } +} @layer components { /** Safari Support */ @@ -19,4 +119,59 @@ html:not(.browser_safari) .tw-styled-scrollbar { scrollbar-color: rgb(var(--color-secondary-500)) rgb(var(--color-background-alt)); } + + #duo-frame { + background: url("../images/loading.svg") 0 0 no-repeat; + width: 100%; + height: 470px; + margin-bottom: -10px; + + iframe { + width: 100%; + height: 100%; + border: none; + } + } + + #web-authn-frame { + width: 100%; + height: 40px; + + iframe { + border: none; + height: 100%; + width: 100%; + } + } + + body.linux-webauthn { + width: 485px !important; + #web-authn-frame { + iframe { + width: 375px; + margin: 0 55px; + } + } + } + + app-root > #loading { + display: flex; + text-align: center; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + + @apply tw-text-muted; + } + + /** + * Text selection style: + * Set explicit selection styles (assumes primary accent color has sufficient + * contrast against the background, so its inversion is also still readable) + */ + :not(bit-form-field input)::selection { + @apply tw-text-contrast; + @apply tw-bg-primary-700; + } } diff --git a/apps/browser/src/popup/scss/variables.scss b/apps/browser/src/popup/scss/variables.scss index aea69e26436..02a10521bca 100644 --- a/apps/browser/src/popup/scss/variables.scss +++ b/apps/browser/src/popup/scss/variables.scss @@ -1,178 +1,42 @@ -$dark-icon-themes: "theme_dark"; +/** + * DEPRECATED: DO NOT MODIFY OR USE! + */ -$font-family-sans-serif: Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif; +$dark-icon-themes: "theme_dark"; + +$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; -$font-size-xlarge: 22px; -$font-size-xxlarge: 28px; -$font-size-small: 12px; $text-color: #000000; -$border-color: #f0f0f0; $border-color-dark: #ddd; -$list-item-hover: #fbfbfb; -$list-icon-color: #767679; -$disabled-box-opacity: 1; -$border-radius: 6px; -$line-height-base: 1.42857143; -$icon-hover-color: lighten($text-color, 50%); - -$mfaTypes: 0, 2, 3, 4, 6; - -$gray: #555; -$gray-light: #777; -$text-muted: $gray-light; - $brand-primary: #175ddc; -$brand-danger: #c83522; $brand-success: #017e45; -$brand-info: #555555; -$brand-warning: #8b6609; -$brand-primary-accent: #1252a3; - $background-color: #f0f0f0; - -$box-background-color: white; -$box-background-hover-color: $list-item-hover; -$box-border-color: $border-color; -$border-color-alt: #c3c5c7; - -$button-border-color: darken($border-color-dark, 12%); -$button-background-color: white; -$button-color: lighten($text-color, 40%); $button-color-primary: darken($brand-primary, 8%); -$button-color-danger: darken($brand-danger, 10%); - -$code-color: #c01176; -$code-color-dark: #f08dc7; $themes: ( light: ( textColor: $text-color, - hoverColorTransparent: rgba($text-color, 0.15), borderColor: $border-color-dark, backgroundColor: $background-color, - borderColorAlt: $border-color-alt, - backgroundColorAlt: #ffffff, - scrollbarColor: rgba(100, 100, 100, 0.2), - scrollbarHoverColor: rgba(100, 100, 100, 0.4), - boxBackgroundColor: $box-background-color, - boxBackgroundHoverColor: $box-background-hover-color, - boxBorderColor: $box-border-color, - tabBackgroundColor: #ffffff, - tabBackgroundHoverColor: $list-item-hover, - headerColor: #ffffff, - headerBackgroundColor: $brand-primary, - headerBackgroundHoverColor: rgba(255, 255, 255, 0.1), - headerBorderColor: $brand-primary, - headerInputBackgroundColor: darken($brand-primary, 8%), - headerInputBackgroundFocusColor: darken($brand-primary, 10%), - headerInputColor: #ffffff, - headerInputPlaceholderColor: lighten($brand-primary, 35%), - listItemBackgroundHoverColor: $list-item-hover, - disabledIconColor: $list-icon-color, - disabledBoxOpacity: $disabled-box-opacity, - headingColor: $gray-light, - labelColor: $gray-light, - mutedColor: $text-muted, - totpStrokeColor: $brand-primary, - boxRowButtonColor: $brand-primary, - boxRowButtonHoverColor: darken($brand-primary, 10%), inputBorderColor: darken($border-color-dark, 7%), inputBackgroundColor: #ffffff, - inputPlaceholderColor: lighten($gray-light, 35%), - buttonBackgroundColor: $button-background-color, - buttonBorderColor: $button-border-color, - buttonColor: $button-color, buttonPrimaryColor: $button-color-primary, - buttonDangerColor: $button-color-danger, primaryColor: $brand-primary, - primaryAccentColor: $brand-primary-accent, - dangerColor: $brand-danger, successColor: $brand-success, - infoColor: $brand-info, - warningColor: $brand-warning, - logoSuffix: "dark", - mfaLogoSuffix: ".png", passwordNumberColor: #007fde, passwordSpecialColor: #c40800, - passwordCountText: #212529, - calloutBorderColor: $border-color-dark, - calloutBackgroundColor: $box-background-color, - toastTextColor: #ffffff, - svgSuffix: "-light.svg", - transparentColor: rgba(0, 0, 0, 0), - dateInputColorScheme: light, - // https://stackoverflow.com/a/53336754 - webkitCalendarPickerFilter: invert(46%) sepia(69%) saturate(6397%) hue-rotate(211deg) - brightness(85%) contrast(103%), - // light has no hover so use same color - webkitCalendarPickerHoverFilter: invert(46%) sepia(69%) saturate(6397%) hue-rotate(211deg) - brightness(85%) contrast(103%), - codeColor: $code-color, ), dark: ( textColor: #ffffff, - hoverColorTransparent: rgba($text-color, 0.15), borderColor: #161c26, backgroundColor: #161c26, - borderColorAlt: #6e788a, - backgroundColorAlt: #2f343d, - scrollbarColor: #6e788a, - scrollbarHoverColor: #8d94a5, - boxBackgroundColor: #2f343d, - boxBackgroundHoverColor: #3c424e, - boxBorderColor: #4c525f, - tabBackgroundColor: #2f343d, - tabBackgroundHoverColor: #3c424e, - headerColor: #ffffff, - headerBackgroundColor: #2f343d, - headerBackgroundHoverColor: #3c424e, - headerBorderColor: #161c26, - headerInputBackgroundColor: #3c424e, - headerInputBackgroundFocusColor: #4c525f, - headerInputColor: #ffffff, - headerInputPlaceholderColor: #bac0ce, - listItemBackgroundHoverColor: #3c424e, - disabledIconColor: #bac0ce, - disabledBoxOpacity: 0.5, - headingColor: #bac0ce, - labelColor: #bac0ce, - mutedColor: #bac0ce, - totpStrokeColor: #4c525f, - boxRowButtonColor: #bac0ce, - boxRowButtonHoverColor: #ffffff, inputBorderColor: #4c525f, inputBackgroundColor: #2f343d, - inputPlaceholderColor: #bac0ce, - buttonBackgroundColor: #3c424e, - buttonBorderColor: #4c525f, - buttonColor: #bac0ce, buttonPrimaryColor: #6f9df1, - buttonDangerColor: #ff8d85, primaryColor: #6f9df1, - primaryAccentColor: #6f9df1, - dangerColor: #ff8d85, successColor: #52e07c, - infoColor: #a4b0c6, - warningColor: #ffeb66, - logoSuffix: "white", - mfaLogoSuffix: "-w.png", passwordNumberColor: #6f9df1, passwordSpecialColor: #ff8d85, - passwordCountText: #ffffff, - calloutBorderColor: #4c525f, - calloutBackgroundColor: #3c424e, - toastTextColor: #1f242e, - svgSuffix: "-dark.svg", - transparentColor: rgba(0, 0, 0, 0), - dateInputColorScheme: dark, - // https://stackoverflow.com/a/53336754 - must prepend brightness(0) saturate(100%) to dark themed date inputs - webkitCalendarPickerFilter: brightness(0) saturate(100%) invert(86%) sepia(19%) saturate(152%) - hue-rotate(184deg) brightness(87%) contrast(93%), - webkitCalendarPickerHoverFilter: brightness(0) saturate(100%) invert(100%) sepia(0%) - saturate(0%) hue-rotate(93deg) brightness(103%) contrast(103%), - codeColor: $code-color-dark, ), ); diff --git a/apps/browser/src/popup/services/init.service.ts b/apps/browser/src/popup/services/init.service.ts index 1de1731013d..f16d82d0810 100644 --- a/apps/browser/src/popup/services/init.service.ts +++ b/apps/browser/src/popup/services/init.service.ts @@ -1,8 +1,7 @@ -import { DOCUMENT } from "@angular/common"; -import { inject, Inject, Injectable } from "@angular/core"; +import { inject, Inject, Injectable, DOCUMENT } from "@angular/core"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; -import { TwoFactorService } from "@bitwarden/common/auth/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"; diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index a44ba81c40b..bb89eff1147 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -36,6 +36,7 @@ import { 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"; @@ -75,6 +76,7 @@ import { InternalMasterPasswordServiceAbstraction, MasterPasswordServiceAbstraction, } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { VaultTimeoutService, VaultTimeoutStringType, @@ -134,6 +136,7 @@ import { DialogService, ToastService, } from "@bitwarden/components"; +import { GeneratorServicesModule } from "@bitwarden/generator-components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { BiometricsService, @@ -141,7 +144,10 @@ 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 { @@ -165,6 +171,8 @@ 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 { BrowserSessionTimeoutTypeService } from "../../key-management/session-timeout/services/browser-session-timeout-type.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"; @@ -603,7 +611,12 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: Fido2UserVerificationService, useClass: Fido2UserVerificationService, - deps: [PasswordRepromptService, UserVerificationService, DialogService], + deps: [ + PasswordRepromptService, + UserDecryptionOptionsServiceAbstraction, + DialogService, + AccountServiceAbstraction, + ], }), safeProvider({ provide: AnimationControlService, @@ -713,10 +726,25 @@ const safeProviders: SafeProvider[] = [ useClass: ExtensionNewDeviceVerificationComponentService, deps: [], }), + safeProvider({ + provide: SessionTimeoutTypeService, + useClass: BrowserSessionTimeoutTypeService, + deps: [PlatformUtilsService], + }), + safeProvider({ + provide: SessionTimeoutSettingsComponentService, + useClass: BrowserSessionTimeoutSettingsComponentService, + deps: [ + I18nServiceAbstraction, + SessionTimeoutTypeService, + PolicyService, + MessagingServiceAbstraction, + ], + }), ]; @NgModule({ - imports: [JslibServicesModule], + imports: [JslibServicesModule, GeneratorServicesModule], declarations: [], // Do not register your dependency here! Add it to the typesafeProviders array using the helper function providers: safeProviders, 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/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-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 56b8bcbb9f5..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 @@ -3,7 +3,7 @@ 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"; @@ -33,7 +33,9 @@ export class SendFilePopoutDialogContainerComponent implements OnInit { 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-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 1272a86be17..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,13 +36,18 @@ 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 @@ -64,6 +74,8 @@ export enum SendState { SendListFiltersComponent, SendSearchComponent, TypographyModule, + VaultFadeInOutSkeletonComponent, + VaultLoadingSkeletonComponent, ], }) export class SendV2Component implements OnDestroy { @@ -72,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$, @@ -93,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-page/about-page-v2.component.ts b/apps/browser/src/tools/popup/settings/about-page/about-page-v2.component.ts index 2ef830d9d94..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"; @@ -51,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/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 1c370381f54..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"; @@ -24,8 +34,6 @@ 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({ templateUrl: "settings-v2.component.html", imports: [ @@ -38,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), @@ -79,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-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 f81bccc760c..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"; @@ -52,6 +53,7 @@ export class AtRiskCarouselDialogComponent { static open(dialogService: DialogService) { return dialogService.open(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/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 2125af289a2..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 @@ -30,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/item-copy-action/item-copy-actions.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.spec.ts new file mode 100644 index 00000000000..84ce58913a9 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.spec.ts @@ -0,0 +1,425 @@ +import { CommonModule } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { of } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { IconButtonModule, ItemModule, MenuModule } from "@bitwarden/components"; +import { CipherListView, CopyableCipherFields } from "@bitwarden/sdk-internal"; + +import { VaultPopupCopyButtonsService } from "../../../services/vault-popup-copy-buttons.service"; + +import { ItemCopyActionsComponent } from "./item-copy-actions.component"; + +describe("ItemCopyActionsComponent", () => { + let fixture: ComponentFixture; + let component: ItemCopyActionsComponent; + + let i18nService: jest.Mocked; + + beforeEach(async () => { + i18nService = { + t: jest.fn((key: string) => `translated-${key}`), + } as unknown as jest.Mocked; + + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + JslibModule, + ItemModule, + IconButtonModule, + MenuModule, + ItemCopyActionsComponent, // standalone + ], + providers: [ + { provide: I18nService, useValue: i18nService }, + { + provide: VaultPopupCopyButtonsService, + useValue: { + showQuickCopyActions$: of(true), + } satisfies Partial, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ItemCopyActionsComponent); + component = fixture.componentInstance; + + // Default cipher so tests can override as needed + component.cipher = { + name: "My cipher", + viewPassword: true, + login: { username: null, password: null, totp: null }, + card: { code: null, number: null }, + identity: { + fullAddressForCopy: null, + email: null, + username: null, + phone: null, + }, + sshKey: { + privateKey: null, + publicKey: null, + keyFingerprint: null, + }, + notes: null, + copyableFields: [], + } as unknown as CipherViewLike; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("findSingleCopyableItem", () => { + beforeEach(() => { + jest + .spyOn(CipherViewLikeUtils, "hasCopyableValue") + .mockImplementation( + (cipher: CipherViewLike & { __copyable?: Record }, field) => { + return Boolean(cipher.__copyable?.[field]); + }, + ); + }); + + it("returns the single item with value and translates its key", () => { + const items = [ + { key: "copyUsername", field: "username" as const }, + { key: "copyPassword", field: "password" as const }, + ]; + + (component.cipher as any).__copyable = { + username: true, + password: false, + }; + + const result = component.findSingleCopyableItem(items); + + expect(result).toEqual({ + key: "translated-copyUsername", + field: "username", + }); + expect(i18nService.t).toHaveBeenCalledWith("copyUsername"); + }); + + it("returns null when no items have a value", () => { + const items = [ + { key: "copyUsername", field: "username" as const }, + { key: "copyPassword", field: "password" as const }, + ]; + + (component.cipher as any).__copyable = { + username: false, + password: false, + }; + + const result = component.findSingleCopyableItem(items); + + expect(result).toBeNull(); + }); + + it("returns null when more than one item has a value", () => { + const items = [ + { key: "copyUsername", field: "username" as const }, + { key: "copyPassword", field: "password" as const }, + ]; + + (component.cipher as any).__copyable = { + username: true, + password: true, + }; + + const result = component.findSingleCopyableItem(items); + + expect(result).toBeNull(); + }); + }); + + describe("singleCopyableLogin", () => { + beforeEach(() => { + jest + .spyOn(CipherViewLikeUtils, "hasCopyableValue") + .mockImplementation( + (cipher: CipherViewLike & { __copyable?: Record }, field) => { + return Boolean(cipher.__copyable?.[field]); + }, + ); + }); + + it("returns username with special-case logic when password is hidden and both username/password exist and no totp", () => { + (component.cipher as CipherView).viewPassword = false; + + (component.cipher as any).__copyable = { + username: true, + password: true, + totp: false, + }; + + const result = component.singleCopyableLogin; + + expect(result).toEqual({ + key: "translated-copyUsername", + field: "username", + }); + expect(i18nService.t).toHaveBeenCalledWith("copyUsername"); + }); + + it("returns null when password is hidden but multiple fields exist, ensuring username and totp are shown in the menu UI ", () => { + (component.cipher as CipherView).viewPassword = false; + + (component.cipher as any).__copyable = { + username: true, + password: true, + totp: true, + }; + + const result = component.singleCopyableLogin; + + expect(result).toBeNull(); + }); + + it("falls back to findSingleCopyableItem when password is visible", () => { + const findSingleCopyableItemSpy = jest.spyOn(component, "findSingleCopyableItem"); + (component.cipher as CipherView).viewPassword = true; + + void component.singleCopyableLogin; + expect(findSingleCopyableItemSpy).toHaveBeenCalled(); + }); + }); + + describe("singleCopyableCard", () => { + beforeEach(() => { + jest + .spyOn(CipherViewLikeUtils, "hasCopyableValue") + .mockImplementation( + (cipher: CipherViewLike & { __copyable?: Record }, field) => { + return Boolean(cipher.__copyable?.[field]); + }, + ); + }); + + it("returns security code when it is the only available card value", () => { + (component.cipher as any).__copyable = { + securityCode: true, + cardNumber: false, + }; + + const result = component.singleCopyableCard; + + expect(result).toEqual({ + key: "translated-securityCode", + field: "securityCode", + }); + expect(i18nService.t).toHaveBeenCalledWith("securityCode"); + }); + + it("returns null when both card number and security code are available", () => { + (component.cipher as any).__copyable = { + securityCode: true, + cardNumber: true, + }; + + const result = component.singleCopyableCard; + + expect(result).toBeNull(); + }); + }); + + describe("singleCopyableIdentity", () => { + beforeEach(() => { + jest + .spyOn(CipherViewLikeUtils, "hasCopyableValue") + .mockImplementation( + (cipher: CipherViewLike & { __copyable?: Record }, field) => { + return Boolean(cipher.__copyable?.[field]); + }, + ); + }); + + it("returns the only copyable identity field", () => { + (component.cipher as any).__copyable = { + address: false, + email: true, + username: false, + phone: false, + }; + + const result = component.singleCopyableIdentity; + + expect(result).toEqual({ + key: "translated-email", + field: "email", + }); + expect(i18nService.t).toHaveBeenCalledWith("email"); + }); + + it("returns null when multiple identity fields are available", () => { + (component.cipher as any).__copyable = { + address: true, + email: true, + username: false, + phone: false, + }; + + const result = component.singleCopyableIdentity; + + expect(result).toBeNull(); + }); + }); + + describe("has*Values in non-list view", () => { + beforeEach(() => { + jest.spyOn(CipherViewLikeUtils, "isCipherListView").mockReturnValue(false); + }); + + it("computes hasLoginValues from login fields", () => { + (component.cipher as CipherView).login = { + username: "user", + password: null, + totp: null, + } as any; + + expect(component.hasLoginValues).toBe(true); + + (component.cipher as CipherView).login = { + username: null, + password: null, + totp: null, + } as any; + + expect(component.hasLoginValues).toBe(false); + }); + + it("computes hasCardValues from card fields", () => { + (component.cipher as CipherView).card = { code: "123", number: null } as any; + + expect(component.hasCardValues).toBe(true); + + (component.cipher as CipherView).card = { code: null, number: null } as any; + + expect(component.hasCardValues).toBe(false); + }); + + it("computes hasIdentityValues from identity fields", () => { + (component.cipher as CipherView).identity = { + fullAddressForCopy: null, + email: "test@example.com", + username: null, + phone: null, + } as any; + + expect(component.hasIdentityValues).toBe(true); + + (component.cipher as CipherView).identity = { + fullAddressForCopy: null, + email: null, + username: null, + phone: null, + } as any; + + expect(component.hasIdentityValues).toBe(false); + }); + + it("computes hasSecureNoteValue from notes", () => { + (component.cipher as CipherView).notes = "Some note" as any; + expect(component.hasSecureNoteValue).toBe(true); + + (component.cipher as CipherView).notes = null as any; + expect(component.hasSecureNoteValue).toBe(false); + }); + + it("computes hasSshKeyValues from sshKey fields", () => { + (component.cipher as CipherView).sshKey = { + privateKey: "priv", + publicKey: null, + keyFingerprint: null, + } as any; + + expect(component.hasSshKeyValues).toBe(true); + + (component.cipher as CipherView).sshKey = { + privateKey: null, + publicKey: null, + keyFingerprint: null, + } as any; + + expect(component.hasSshKeyValues).toBe(false); + }); + }); + + describe("has*Values in list view", () => { + beforeEach(() => { + jest.spyOn(CipherViewLikeUtils, "isCipherListView").mockReturnValue(true); + }); + + it("uses copyableFields for login values", () => { + (component.cipher as CipherListView).copyableFields = [ + "LoginUsername", + "CardNumber", + ] as CopyableCipherFields[]; + + expect(component.hasLoginValues).toBe(true); + + (component.cipher as CipherListView).copyableFields = [ + "CardNumber", + ] as CopyableCipherFields[]; + + expect(component.hasLoginValues).toBe(false); + }); + + it("uses copyableFields for card values", () => { + (component.cipher as CipherListView).copyableFields = [ + "CardSecurityCode", + ] as CopyableCipherFields[]; + + expect(component.hasCardValues).toBe(true); + + (component.cipher as CipherListView).copyableFields = [ + "LoginUsername", + ] as CopyableCipherFields[]; + + expect(component.hasCardValues).toBe(false); + }); + + it("uses copyableFields for identity values", () => { + (component.cipher as CipherListView).copyableFields = [ + "IdentityEmail", + ] as CopyableCipherFields[]; + + expect(component.hasIdentityValues).toBe(true); + + (component.cipher as CipherListView).copyableFields = [ + "LoginUsername", + ] as CopyableCipherFields[]; + + expect(component.hasIdentityValues).toBe(false); + }); + + it("uses copyableFields for secure note value", () => { + (component.cipher as CipherListView).copyableFields = [ + "SecureNotes", + ] as CopyableCipherFields[]; + expect(component.hasSecureNoteValue).toBe(true); + + (component.cipher as CipherListView).copyableFields = [ + "LoginUsername", + ] as CopyableCipherFields[]; + expect(component.hasSecureNoteValue).toBe(false); + }); + + it("uses copyableFields for ssh key values", () => { + (component.cipher as CipherListView).copyableFields = ["SshKey"] as CopyableCipherFields[]; + expect(component.hasSshKeyValues).toBe(true); + + (component.cipher as CipherListView).copyableFields = [ + "LoginUsername", + ] as CopyableCipherFields[]; + expect(component.hasSshKeyValues).toBe(false); + }); + }); +}); 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 2e2ee5cd56b..e1398ac9f1f 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,7 +18,7 @@ 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 @@ -48,23 +48,26 @@ 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" }, { key: "copyVerificationCode", field: "totp" }, ]; - // If both the password and username are visible but the password is hidden, return the username + // If both the password and username are visible but the password is hidden and there's no + // totp code to copy return the username if ( !this.cipher.viewPassword && CipherViewLikeUtils.hasCopyableValue(this.cipher, "username") && - CipherViewLikeUtils.hasCopyableValue(this.cipher, "password") + CipherViewLikeUtils.hasCopyableValue(this.cipher, "password") && + !CipherViewLikeUtils.hasCopyableValue(this.cipher, "totp") ) { 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 3a48f7eb449..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)) { + + }
@@ -43,10 +51,26 @@ {{ "assignToCollections" | i18n }} - @if (canArchive$ | async) { - + @if (showArchive$ | async) { + @if (canArchive$ | async) { + + } @else { + + } } @if (canDelete$ | async) { 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 2139b6d9a4f..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 @@ -24,6 +24,8 @@ import { 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 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-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 6850a474af5..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 @@ -428,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-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 c254c290915..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,13 +2,27 @@ 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 @@ -22,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(); @@ -45,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 07d3f042e60..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 { + + } + + + - - - - - + + + + + + + + + @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..4b992d9f1ee --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts @@ -0,0 +1,575 @@ +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), + }; + + 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 + }; + + const configSvc = { + getFeatureFlag$: jest.fn().mockImplementation((_flag: string) => of(false)), + }; + + 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: configSvc, + }, + { + 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); + + configSvc.getFeatureFlag$.mockImplementation((_flag: string) => of(true)); + + nudgesSvc.showNudgeSpotlight$.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(() => { + configSvc.getFeatureFlag$.mockImplementation((_flag: string) => of(false)); + + itemsSvc.emptyVault$.next(true); + + nudgesSvc.showNudgeSpotlight$.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 2dd6c1a0ce1..63d971081df 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 { @@ -88,6 +101,9 @@ type VaultState = UnionOfValues; SpotlightComponent, RouterModule, TypographyModule, + VaultLoadingSkeletonComponent, + VaultFadeInOutSkeletonComponent, + VaultFadeInOutComponent, ], }) export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { @@ -108,19 +124,90 @@ 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, + ); + + protected premiumSpotlightFeatureFlag$ = this.configService.getFeatureFlag$( + FeatureFlag.BrowserPremiumSpotlight, + ); + + private showPremiumNudgeSpotlight$ = this.activeUserId$.pipe( + switchMap((userId) => this.nudgesService.showNudgeSpotlight$(NudgeType.PremiumUpgrade, userId)), + ); + protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$; protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$; protected allFilters$ = this.vaultPopupListFiltersService.allFilters$; + 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.premiumSpotlightFeatureFlag$, + this.showPremiumNudgeSpotlight$, + this.showHasItemsVaultSpotlight$, + this.hasPremium$, + this.cipherCount$, + this.accountAgeInDays$, ]).pipe( - map(([itemsLoading, filters]) => itemsLoading || !filters), + map( + ([featureFlagEnabled, showPremiumNudge, showHasItemsNudge, hasPremium, count, age]) => + featureFlagEnabled && + 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 = @@ -150,14 +237,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 30074777e83..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 @@ -330,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/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 718043b4e85..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 @@ -262,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(); 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 3d5b35cded6..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 @@ -231,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()) ) { @@ -314,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); @@ -350,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; @@ -359,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.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index a1820a975f1..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 @@ -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-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/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/archive.component.html b/apps/browser/src/vault/popup/settings/archive.component.html index faaf0243fc7..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 70ba6842a0d..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, @@ -85,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({ @@ -101,7 +130,7 @@ export class TrashListItemsContainerComponent { } } - async delete(cipher: CipherView) { + async delete(cipher: PopupCipherViewLike) { const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); if (!repromptPassed) { @@ -120,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({ @@ -133,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], }); @@ -147,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 3c1278b4d44..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()) { - - - {{ "archiveNoun" | 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 ff6e9b4065c..e085cb21c2d 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,14 +2,16 @@ 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"; @@ -18,6 +20,7 @@ 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 @@ -32,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 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 readonly 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/popup/utils/vault-popout-window.spec.ts b/apps/browser/src/vault/popup/utils/vault-popout-window.spec.ts index 4597c004290..3389228dda4 100644 --- a/apps/browser/src/vault/popup/utils/vault-popout-window.spec.ts +++ b/apps/browser/src/vault/popup/utils/vault-popout-window.spec.ts @@ -2,6 +2,7 @@ import { mock } from "jest-mock-extended"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { BrowserApi } from "../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; import { @@ -23,6 +24,19 @@ describe("VaultPopoutWindow", () => { .spyOn(BrowserPopupUtils, "closeSingleActionPopout") .mockImplementation(); + beforeEach(() => { + jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([]); + jest.spyOn(BrowserApi, "updateWindowProperties").mockResolvedValue(); + global.chrome = { + ...global.chrome, + runtime: { + ...global.chrome?.runtime, + sendMessage: jest.fn().mockResolvedValue(undefined), + getURL: jest.fn((path) => `chrome-extension://extension-id/${path}`), + }, + }; + }); + afterEach(() => { jest.clearAllMocks(); }); @@ -123,6 +137,32 @@ describe("VaultPopoutWindow", () => { }, ); }); + + it("sends a message to refresh data when the popup is already open", async () => { + const existingPopupTab = { + id: 123, + windowId: 456, + url: `chrome-extension://extension-id/popup/index.html#/edit-cipher?singleActionPopout=${VaultPopoutType.addEditVaultItem}_${CipherType.Login}`, + } as chrome.tabs.Tab; + + jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([existingPopupTab]); + const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage"); + const updateWindowSpy = jest.spyOn(BrowserApi, "updateWindowProperties"); + + await openAddEditVaultItemPopout( + mock({ windowId: 1, url: "https://jest-testing-website.com" }), + { + cipherType: CipherType.Login, + }, + ); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledWith({ + command: "reloadAddEditCipherData", + data: { cipherId: undefined, cipherType: CipherType.Login }, + }); + expect(updateWindowSpy).toHaveBeenCalledWith(456, { focused: true }); + }); }); describe("closeAddEditVaultItemPopout", () => { diff --git a/apps/browser/src/vault/popup/utils/vault-popout-window.ts b/apps/browser/src/vault/popup/utils/vault-popout-window.ts index 3dae96b6cc7..cccf005cd2e 100644 --- a/apps/browser/src/vault/popup/utils/vault-popout-window.ts +++ b/apps/browser/src/vault/popup/utils/vault-popout-window.ts @@ -115,10 +115,26 @@ async function openAddEditVaultItemPopout( addEditCipherUrl += formatQueryString("uri", url); } - await BrowserPopupUtils.openPopout(addEditCipherUrl, { - singleActionKey, - senderWindowId: windowId, - }); + const extensionUrl = chrome.runtime.getURL("popup/index.html"); + const existingPopupTabs = await BrowserApi.tabsQuery({ url: `${extensionUrl}*` }); + const existingPopup = existingPopupTabs.find((tab) => + tab.url?.includes(`singleActionPopout=${singleActionKey}`), + ); + // Check if the an existing popup is already open + try { + await chrome.runtime.sendMessage({ + command: "reloadAddEditCipherData", + data: { cipherId, cipherType }, + }); + await BrowserApi.updateWindowProperties(existingPopup.windowId, { + focused: true, + }); + } catch { + await BrowserPopupUtils.openPopout(addEditCipherUrl, { + singleActionKey, + senderWindowId: windowId, + }); + } } /** 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/pt_BR/copy.resx b/apps/browser/store/locales/pt_BR/copy.resx index 96ae2332f53..09855fd5798 100644 --- a/apps/browser/store/locales/pt_BR/copy.resx +++ b/apps/browser/store/locales/pt_BR/copy.resx @@ -121,63 +121,63 @@ 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..faaa7fa4128 100644 --- a/apps/browser/tailwind.config.js +++ b/apps/browser/tailwind.config.js @@ -10,6 +10,8 @@ config.content = [ "../../libs/vault/src/**/*.{html,ts}", "../../libs/angular/src/**/*.{html,ts}", "../../libs/vault/src/**/*.{html,ts}", + "../../libs/pricing/src/**/*.{html,ts}", ]; +config.corePlugins.preflight = true; 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/cli/package.json b/apps/cli/package.json index 02627f80a27..0ae4bf9ce30 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.10.1", + "version": "2025.12.0", "keywords": [ "bitwarden", "password", @@ -75,20 +75,20 @@ "inquirer": "8.2.6", "jsdom": "26.1.0", "jszip": "3.10.1", - "koa": "2.16.1", + "koa": "3.1.1", "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", - "open": "10.1.2", + "node-forge": "1.3.2", + "open": "11.0.0", "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.19", "zxcvbn": "4.4.2" } } 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 aa43b353f9c..d8859318b52 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -20,7 +20,6 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli 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"; @@ -28,10 +27,11 @@ 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 { TwoFactorApiService } from "@bitwarden/common/auth/two-factor"; +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 { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; @@ -82,6 +82,7 @@ export class LoginCommand { protected ssoUrlService: SsoUrlService, protected i18nService: I18nService, protected masterPasswordService: MasterPasswordServiceAbstraction, + protected encryptedMigrator: EncryptedMigrator, ) {} async run(email: string, password: string, options: OptionValues) { @@ -112,20 +113,14 @@ export class LoginCommand { } else if (options.sso != null && this.canInteract) { // If the optional Org SSO Identifier isn't provided, the option value is `true`. const orgSsoIdentifier = options.sso === true ? null : options.sso; - const passwordOptions: any = { - type: "password", - length: 64, - uppercase: true, - lowercase: true, - numbers: true, - special: false, - }; - const state = await this.passwordGenerationService.generatePassword(passwordOptions); - ssoCodeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); - const codeVerifierHash = await this.cryptoFunctionService.hash(ssoCodeVerifier, "sha256"); - const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); + const ssoPromptData = await this.makeSsoPromptData(); + ssoCodeVerifier = ssoPromptData.ssoCodeVerifier; try { - const ssoParams = await this.openSsoPrompt(codeChallenge, state, orgSsoIdentifier); + const ssoParams = await this.openSsoPrompt( + ssoPromptData.codeChallenge, + ssoPromptData.state, + orgSsoIdentifier, + ); ssoCode = ssoParams.ssoCode; orgIdentifier = ssoParams.orgIdentifier; } catch { @@ -230,9 +225,43 @@ export class LoginCommand { new PasswordLoginCredentials(email, password, twoFactor), ); } + + // Begin Acting on initial AuthResult + if (response.requiresEncryptionKeyMigration) { return Response.error(this.i18nService.t("legacyEncryptionUnsupported")); } + + // Opting for not checking feature flag since the server will not respond with + // SsoOrganizationIdentifier if the feature flag is not enabled. + if (response.requiresSso && this.canInteract) { + const ssoPromptData = await this.makeSsoPromptData(); + ssoCodeVerifier = ssoPromptData.ssoCodeVerifier; + try { + const ssoParams = await this.openSsoPrompt( + ssoPromptData.codeChallenge, + ssoPromptData.state, + response.ssoOrganizationIdentifier, + ); + ssoCode = ssoParams.ssoCode; + orgIdentifier = ssoParams.orgIdentifier; + if (ssoCode != null && ssoCodeVerifier != null) { + response = await this.loginStrategyService.logIn( + new SsoLoginCredentials( + ssoCode, + ssoCodeVerifier, + this.ssoRedirectUri, + orgIdentifier, + undefined, // email to look up 2FA token not required as CLI can't remember 2FA token + twoFactor, + ), + ); + } + } catch { + return Response.badRequest("Something went wrong. Try again."); + } + } + if (response.requiresTwoFactor) { const twoFactorProviders = await this.twoFactorService.getSupportedProviders(null); if (twoFactorProviders.length === 0) { @@ -278,6 +307,10 @@ export class LoginCommand { if (twoFactorToken == null && selectedProvider.type === TwoFactorProviderType.Email) { const emailReq = new TwoFactorEmailRequest(); emailReq.email = await this.loginStrategyService.getEmail(); + // if the user was logging in with SSO, we need to include the SSO session token + if (response.ssoEmail2FaSessionToken != null) { + emailReq.ssoEmail2FaSessionToken = response.ssoEmail2FaSessionToken; + } emailReq.masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash(); await this.twoFactorApiService.postTwoFactorEmail(emailReq); } @@ -323,6 +356,7 @@ export class LoginCommand { response = await this.loginStrategyService.logInNewDeviceVerification(newDeviceToken); } + // We check response two factor again here since MFA could fail based on the logic on ln 226 if (response.requiresTwoFactor) { return Response.error("Login failed."); } @@ -368,6 +402,8 @@ export class LoginCommand { } } + await this.encryptedMigrator.runMigrations(response.userId, password); + return await this.handleSuccessResponse(response); } catch (e) { if ( @@ -689,6 +725,27 @@ export class LoginCommand { }; } + /// Generate SSO prompt data: code verifier, code challenge, and state + private async makeSsoPromptData(): Promise<{ + ssoCodeVerifier: string; + codeChallenge: string; + state: string; + }> { + const passwordOptions: any = { + type: "password", + length: 64, + uppercase: true, + lowercase: true, + numbers: true, + special: false, + }; + const state = await this.passwordGenerationService.generatePassword(passwordOptions); + const ssoCodeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); + const codeVerifierHash = await this.cryptoFunctionService.hash(ssoCodeVerifier, "sha256"); + const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); + return { ssoCodeVerifier, codeChallenge, state }; + } + private async openSsoPrompt( codeChallenge: string, state: string, diff --git a/apps/cli/src/base-program.ts b/apps/cli/src/base-program.ts index 69a5e4e1bde..71c3830b4cc 100644 --- a/apps/cli/src/base-program.ts +++ b/apps/cli/src/base-program.ts @@ -182,6 +182,7 @@ export abstract class BaseProgram { this.serviceContainer.organizationApiService, this.serviceContainer.logout, this.serviceContainer.i18nService, + this.serviceContainer.encryptedMigrator, this.serviceContainer.masterPasswordUnlockService, this.serviceContainer.configService, ); diff --git a/apps/cli/src/commands/edit.command.ts b/apps/cli/src/commands/edit.command.ts index 14a218c7141..d95e8333dca 100644 --- a/apps/cli/src/commands/edit.command.ts +++ b/apps/cli/src/commands/edit.command.ts @@ -186,15 +186,15 @@ export class EditCommand { return Response.notFound(); } - let folderView = await folder.decrypt(); + const userKey = await firstValueFrom(this.keyService.userKey$(activeUserId)); + let folderView = await folder.decrypt(userKey); folderView = FolderExport.toView(req, folderView); - const userKey = await this.keyService.getUserKey(activeUserId); const encFolder = await this.folderService.encrypt(folderView, userKey); try { const folder = await this.folderApiService.save(encFolder, activeUserId); const updatedFolder = new Folder(folder); - const decFolder = await updatedFolder.decrypt(); + const decFolder = await updatedFolder.decrypt(userKey); const res = new FolderResponse(decFolder); return Response.success(res); } catch (e) { diff --git a/apps/cli/src/commands/get.command.ts b/apps/cli/src/commands/get.command.ts index a994ad3117c..35816b56fb2 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"; @@ -417,10 +417,11 @@ export class GetCommand extends DownloadCommand { private async getFolder(id: string) { let decFolder: FolderView = null; const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const userKey = await firstValueFrom(this.keyService.userKey$(activeUserId)); if (Utils.isGuid(id)) { const folder = await this.folderService.getFromState(id, activeUserId); if (folder != null) { - decFolder = await folder.decrypt(); + decFolder = await folder.decrypt(userKey); } } else if (id.trim() !== "") { let folders = await this.folderService.getAllDecryptedFromState(activeUserId); @@ -448,7 +449,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 e5174f67913..ff210cf222d 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -102,7 +102,7 @@ export class ListCommand { 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; } @@ -112,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; } 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 index 928a750dca6..70e9a8fd232 100644 --- a/apps/cli/src/key-management/commands/unlock.command.spec.ts +++ b/apps/cli/src/key-management/commands/unlock.command.spec.ts @@ -7,6 +7,7 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { MasterPasswordVerificationResponse } from "@bitwarden/common/auth/types/verification"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; @@ -40,6 +41,7 @@ describe("UnlockCommand", () => { const organizationApiService = mock(); const logout = jest.fn(); const i18nService = mock(); + const encryptedMigrator = mock(); const masterPasswordUnlockService = mock(); const configService = mock(); @@ -92,6 +94,7 @@ describe("UnlockCommand", () => { organizationApiService, logout, i18nService, + encryptedMigrator, masterPasswordUnlockService, configService, ); diff --git a/apps/cli/src/key-management/commands/unlock.command.ts b/apps/cli/src/key-management/commands/unlock.command.ts index 4ae8ce823a4..c88d9ae1cc4 100644 --- a/apps/cli/src/key-management/commands/unlock.command.ts +++ b/apps/cli/src/key-management/commands/unlock.command.ts @@ -9,6 +9,7 @@ import { VerificationType } from "@bitwarden/common/auth/enums/verification-type import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; @@ -38,6 +39,7 @@ export class UnlockCommand { private organizationApiService: OrganizationApiServiceAbstraction, private logout: () => Promise, private i18nService: I18nService, + private encryptedMigrator: EncryptedMigrator, private masterPasswordUnlockService: MasterPasswordUnlockService, private configService: ConfigService, ) {} @@ -116,6 +118,8 @@ export class UnlockCommand { } } + await this.encryptedMigrator.runMigrations(userId, password); + return this.successResponse(); } diff --git a/apps/cli/src/key-management/session-timeout/services/cli-session-timeout-type.service.ts b/apps/cli/src/key-management/session-timeout/services/cli-session-timeout-type.service.ts new file mode 100644 index 00000000000..8143b37b8a3 --- /dev/null +++ b/apps/cli/src/key-management/session-timeout/services/cli-session-timeout-type.service.ts @@ -0,0 +1,15 @@ +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; +import { + VaultTimeout, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; + +export class CliSessionTimeoutTypeService implements SessionTimeoutTypeService { + async isAvailable(timeout: VaultTimeout): Promise { + return timeout === VaultTimeoutStringType.Never; + } + + async getOrPromoteToAvailable(_: VaultTimeout): Promise { + return VaultTimeoutStringType.Never; + } +} diff --git a/apps/cli/src/oss-serve-configurator.ts b/apps/cli/src/oss-serve-configurator.ts index d318a44c677..dbe17224d07 100644 --- a/apps/cli/src/oss-serve-configurator.ts +++ b/apps/cli/src/oss-serve-configurator.ts @@ -160,7 +160,10 @@ export class OssServeConfigurator { 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, @@ -173,6 +176,7 @@ export class OssServeConfigurator { this.serviceContainer.organizationApiService, async () => await this.serviceContainer.logout(), this.serviceContainer.i18nService, + this.serviceContainer.encryptedMigrator, this.serviceContainer.masterPasswordUnlockService, this.serviceContainer.configService, ); 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-sdk-load.service.ts b/apps/cli/src/platform/services/cli-sdk-load.service.ts index 638e64a8214..13a4c19d51d 100644 --- a/apps/cli/src/platform/services/cli-sdk-load.service.ts +++ b/apps/cli/src/platform/services/cli-sdk-load.service.ts @@ -3,6 +3,8 @@ import * as sdk from "@bitwarden/sdk-internal"; export class CliSdkLoadService extends SdkLoadService { async load(): Promise { + // CLI uses stdout for user interaction / automations so we cannot log info / debug here. + SdkLoadService.logLevel = sdk.LogLevel.Error; const module = await import("@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm"); (sdk as any).init(module); } 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 41368269faf..3e5b5678629 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -195,6 +195,7 @@ export class Program extends BaseProgram { this.serviceContainer.ssoUrlService, this.serviceContainer.i18nService, this.serviceContainer.masterPasswordService, + this.serviceContainer.encryptedMigrator, ); const response = await command.run(email, password, options); this.processResponse(response, true); @@ -250,7 +251,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); }); @@ -274,6 +278,11 @@ export class Program extends BaseProgram { }) .option("--check", "Check lock status.", async () => { await this.exitIfNotAuthed(); + const userId = (await firstValueFrom(this.serviceContainer.accountService.activeAccount$)) + ?.id; + await this.serviceContainer.userAutoUnlockKeyService.setUserKeyInMemoryIfAutoUserKeySet( + userId, + ); const authStatus = await this.serviceContainer.authService.getAuthStatus(); if (authStatus === AuthenticationStatus.Unlocked) { @@ -303,6 +312,7 @@ export class Program extends BaseProgram { this.serviceContainer.organizationApiService, async () => await this.serviceContainer.logout(), this.serviceContainer.i18nService, + this.serviceContainer.encryptedMigrator, this.serviceContainer.masterPasswordUnlockService, this.serviceContainer.configService, ); diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 3c4ee55361f..2d4ea7d00b5 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,10 +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 { TwoFactorApiService, DefaultTwoFactorApiService } from "@bitwarden/common/auth/two-factor"; +import { + DefaultTwoFactorService, + TwoFactorService, + TwoFactorApiService, + DefaultTwoFactorApiService, +} from "@bitwarden/common/auth/two-factor"; import { AutofillSettingsService, AutofillSettingsServiceAbstraction, @@ -69,6 +76,10 @@ import { import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation"; 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 { DefaultEncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/default-encrypted-migrator"; +import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; +import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service"; +import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service"; 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"; @@ -199,9 +210,12 @@ import { } from "@bitwarden/vault-export-core"; import { CliBiometricsService } from "../key-management/cli-biometrics-service"; +import { CliProcessReloadService } from "../key-management/cli-process-reload.service"; +import { CliSessionTimeoutTypeService } from "../key-management/session-timeout/services/cli-session-timeout-type.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"; @@ -315,9 +329,11 @@ export class ServiceContainer { cipherEncryptionService: CipherEncryptionService; restrictedItemTypesService: RestrictedItemTypesService; cliRestrictedItemTypesService: CliRestrictedItemTypesService; + encryptedMigrator: EncryptedMigrator; securityStateService: SecurityStateService; masterPasswordUnlockService: MasterPasswordUnlockService; cipherArchiveService: CipherArchiveService; + lockService: LockService; constructor() { let p = null; @@ -476,10 +492,7 @@ export class ServiceContainer { const pinStateService = new PinStateService(this.stateProvider); this.pinService = new PinService( - this.accountService, this.encryptService, - this.kdfConfigService, - this.keyGenerationService, this.logService, this.keyService, this.sdkService, @@ -489,6 +502,7 @@ export class ServiceContainer { this.masterPasswordUnlockService = new DefaultMasterPasswordUnlockService( this.masterPasswordService, this.keyService, + this.logService, ); this.appIdService = new AppIdService(this.storageService, this.logService); @@ -501,11 +515,19 @@ 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); - this.policyService = new DefaultPolicyService(this.stateProvider, this.organizationService); + this.policyService = new DefaultPolicyService( + this.stateProvider, + this.organizationService, + this.accountService, + ); + + const sessionTimeoutTypeService = new CliSessionTimeoutTypeService(); this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService( this.accountService, @@ -518,6 +540,7 @@ export class ServiceContainer { this.stateProvider, this.logService, VaultTimeoutStringType.Never, // default vault timeout + sessionTimeoutTypeService, ); const refreshAccessTokenErrorCallback = () => { @@ -620,10 +643,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") @@ -690,6 +714,7 @@ export class ServiceContainer { this.userDecryptionOptionsService, this.logService, this.configService, + this.accountService, ); this.loginStrategyService = new LoginStrategyService( @@ -778,9 +803,6 @@ export class ServiceContainer { this.folderApiService = new FolderApiService(this.folderService, this.apiService); - const lockedCallback = async (userId: UserId) => - await this.keyService.clearStoredUserKey(userId); - this.userVerificationApiService = new UserVerificationApiService(this.apiService); this.userVerificationService = new UserVerificationService( @@ -796,25 +818,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, ); @@ -873,7 +905,7 @@ export class ServiceContainer { this.collectionService, this.keyService, this.encryptService, - this.pinService, + this.keyGenerationService, this.accountService, this.restrictedItemTypesService, ); @@ -881,7 +913,7 @@ export class ServiceContainer { this.individualExportService = new IndividualVaultExportService( this.folderService, this.cipherService, - this.pinService, + this.keyGenerationService, this.keyService, this.encryptService, this.cryptoFunctionService, @@ -895,7 +927,7 @@ export class ServiceContainer { this.organizationExportService = new OrganizationVaultExportService( this.cipherService, this.vaultExportApiService, - this.pinService, + this.keyGenerationService, this.keyService, this.encryptService, this.cryptoFunctionService, @@ -949,6 +981,16 @@ export class ServiceContainer { ); this.masterPasswordApiService = new MasterPasswordApiService(this.apiService, this.logService); + const changeKdfApiService = new DefaultChangeKdfApiService(this.apiService); + const changeKdfService = new DefaultChangeKdfService(changeKdfApiService, this.sdkService); + this.encryptedMigrator = new DefaultEncryptedMigrator( + this.kdfConfigService, + changeKdfService, + this.logService, + this.configService, + this.masterPasswordService, + this.syncService, + ); } async logout() { diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index 6c643e04cd0..33bf4518ccd 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -308,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/create.command.ts b/apps/cli/src/vault/create.command.ts index 03a205e9c48..d826766dc65 100644 --- a/apps/cli/src/vault/create.command.ts +++ b/apps/cli/src/vault/create.command.ts @@ -92,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); @@ -181,12 +181,12 @@ export class CreateCommand { private async createFolder(req: FolderExport) { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - const userKey = await this.keyService.getUserKey(activeUserId); + const userKey = await firstValueFrom(this.keyService.userKey$(activeUserId)); const folder = await this.folderService.encrypt(FolderExport.toView(req), userKey); try { const folderData = await this.folderApiService.save(folder, activeUserId); const newFolder = new Folder(folderData); - const decFolder = await newFolder.decrypt(); + const decFolder = await newFolder.decrypt(userKey); const res = new FolderResponse(decFolder); return Response.success(res); } catch (e) { diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index a0cd1b3dcbf..5978659f21e 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" - [[package]] name = "aead" version = "0.5.2" @@ -114,15 +99,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.94" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[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,20 +116,21 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", + "windows-sys 0.60.2", "wl-clipboard-rs", "x11rb", ] [[package]] name = "ashpd" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" +checksum = "da0986d5b4f0802160191ad75f8d33ada000558757db3defb70299ca95d9fcbd" dependencies = [ "enumflags2", "futures-channel", "futures-util", - "rand 0.9.1", + "rand 0.9.2", "serde", "serde_repr", "tokio", @@ -317,9 +303,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", @@ -346,23 +332,8 @@ dependencies = [ "mockall", "serial_test", "tracing", - "windows 0.61.1", - "windows-core 0.61.0", -] - -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", + "windows", + "windows-core", ] [[package]] @@ -440,6 +411,25 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "bitwarden_chromium_import_helper" +version = "0.0.0" +dependencies = [ + "aes-gcm", + "anyhow", + "base64", + "chacha20poly1305", + "chromium_importer", + "clap", + "embed-resource", + "scopeguard", + "sysinfo", + "tokio", + "tracing", + "tracing-subscriber", + "windows", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -481,6 +471,12 @@ dependencies = [ "cipher", ] +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + [[package]] name = "byteorder" version = "1.5.0" @@ -489,9 +485,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "camino" @@ -536,10 +532,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.4" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9157bbaa6b165880c27a4293a474c91cdcf265cc68cc829bf10be0964a391caf" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ + "find-msvc-tools", "shlex", ] @@ -589,19 +586,20 @@ dependencies = [ "async-trait", "base64", "cbc", + "dirs", "hex", - "homedir", "oo7", "pbkdf2", - "rand 0.9.1", + "rand 0.9.2", "rusqlite", "security-framework", "serde", "serde_json", "sha1", "tokio", - "winapi", - "windows 0.61.1", + "tracing", + "verifysign", + "windows", ] [[package]] @@ -617,9 +615,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", @@ -627,9 +625,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", @@ -639,9 +637,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", @@ -664,17 +662,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" @@ -698,9 +685,9 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "convert_case" -version = "0.6.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" dependencies = [ "unicode-segmentation", ] @@ -759,16 +746,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "ctor" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" -dependencies = [ - "quote", - "syn", -] - [[package]] name = "ctor" version = "0.5.0" @@ -821,78 +798,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" @@ -914,27 +819,21 @@ 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", + "rand 0.9.2", "scopeguard", "secmem-proc", "security-framework", @@ -942,17 +841,15 @@ dependencies = [ "serde", "serde_json", "sha2", - "ssh-encoding", "ssh-key", "sysinfo", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", - "tokio-stream", "tokio-util", "tracing", "typenum", "widestring", - "windows 0.61.1", + "windows", "windows-future", "zbus", "zbus_polkit", @@ -965,18 +862,14 @@ version = "0.0.0" dependencies = [ "anyhow", "autotype", - "base64", "chromium_importer", "desktop_core", - "hex", "napi", "napi-build", "napi-derive", "serde", "serde_json", "tokio", - "tokio-stream", - "tokio-util", "tracing", "tracing-subscriber", "windows-registry", @@ -989,9 +882,7 @@ version = "0.0.0" dependencies = [ "anyhow", "cc", - "core-foundation", "glob", - "thiserror 2.0.12", "tokio", "tracing", ] @@ -1000,7 +891,6 @@ dependencies = [ name = "desktop_proxy" version = "0.0.0" dependencies = [ - "anyhow", "desktop_core", "embed_plist", "futures", @@ -1040,7 +930,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1157,6 +1047,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" @@ -1267,6 +1171,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" @@ -1465,17 +1375,11 @@ dependencies = [ "polyval", ] -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "goblin" @@ -1499,12 +1403,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" @@ -1520,7 +1418,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.15.3", + "hashbrown", ] [[package]] @@ -1561,14 +1459,14 @@ dependencies = [ [[package]] name = "homedir" -version = "0.3.4" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bdbbd5bc8c5749697ccaa352fa45aff8730cf21c68029c0eef1ffed7c3d6ba2" +checksum = "68df315d2857b2d8d2898be54a85e1d001bbbe0dbb5f8ef847b48dd3a23c4527" dependencies = [ "cfg-if", - "nix 0.29.0", + "nix", "widestring", - "windows 0.57.0", + "windows", ] [[package]] @@ -1685,7 +1583,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", - "hashbrown 0.15.3", + "hashbrown", ] [[package]] @@ -1726,24 +1624,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] -name = "keytar" -version = "0.1.6" +name = "js-sys" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d361c55fba09829ac620b040f5425bf239b1030c3d6820a84acac8da867dca4d" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" 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", + "once_cell", + "wasm-bindgen", ] [[package]] @@ -1757,9 +1644,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.172" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "libloading" @@ -1768,7 +1655,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a793df0d7afeac54f95b471d3af7f0d4fb975699f972341a4b76988d49cdf0c" dependencies = [ "cfg-if", - "windows-targets 0.53.3", + "windows-targets 0.48.5", ] [[package]] @@ -1798,15 +1685,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "link-cplusplus" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6f6da007f968f9def0d65a05b187e2960183de70c160204ecfccf0ee330212" -dependencies = [ - "cc", -] - [[package]] name = "linux-keyutils" version = "0.2.4" @@ -1857,11 +1735,9 @@ version = "0.0.0" dependencies = [ "desktop_core", "futures", - "oslog", "serde", "serde_json", "tokio", - "tokio-util", "tracing", "tracing-oslog", "tracing-subscriber", @@ -1935,15 +1811,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" -dependencies = [ - "adler2", -] - [[package]] name = "mio" version = "1.0.3" @@ -1957,9 +1824,9 @@ dependencies = [ [[package]] name = "mockall" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b" dependencies = [ "cfg-if", "downcast", @@ -1971,9 +1838,9 @@ dependencies = [ [[package]] name = "mockall_derive" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf" dependencies = [ "cfg-if", "proc-macro2", @@ -1983,32 +1850,33 @@ dependencies = [ [[package]] name = "napi" -version = "2.16.17" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +checksum = "f1b74e3dce5230795bb4d2821b941706dee733c7308752507254b0497f39cad7" dependencies = [ "bitflags", - "ctor 0.2.9", - "napi-derive", + "ctor", + "napi-build", "napi-sys", - "once_cell", + "nohash-hasher", + "rustc-hash", "tokio", ] [[package]] name = "napi-build" -version = "2.2.0" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03acbfa4f156a32188bfa09b86dc11a431b5725253fc1fc6f6df5bed273382c4" +checksum = "dcae8ad5609d14afb3a3b91dee88c757016261b151e9dcecabf1b2a31a6cab14" [[package]] name = "napi-derive" -version = "2.16.13" +version = "3.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +checksum = "7552d5a579b834614bbd496db5109f1b9f1c758f08224b0dee1e408333adf0d0" dependencies = [ - "cfg-if", "convert_case", + "ctor", "napi-derive-backend", "proc-macro2", "quote", @@ -2017,40 +1885,26 @@ dependencies = [ [[package]] name = "napi-derive-backend" -version = "1.0.75" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +checksum = "5f6a81ac7486b70f2532a289603340862c06eea5a1e650c1ffeda2ce1238516a" dependencies = [ "convert_case", - "once_cell", "proc-macro2", "quote", - "regex", "semver", "syn", ] [[package]] name = "napi-sys" -version = "2.4.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +checksum = "3e4e7135a8f97aa0f1509cce21a8a1f9dcec1b50d8dee006b48a5adb69a9d64d" dependencies = [ "libloading", ] -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "nix" version = "0.30.1" @@ -2064,6 +1918,12 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -2267,15 +2127,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -2284,9 +2135,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "oo7" -version = "0.4.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb23d3ec3527d65a83be1c1795cb883c52cfa57147d42acc797127df56fc489" +checksum = "e3299dd401feaf1d45afd8fd1c0586f10fcfb22f244bb9afa942cec73503b89d" dependencies = [ "aes", "ashpd", @@ -2302,7 +2153,7 @@ dependencies = [ "num", "num-bigint-dig", "pbkdf2", - "rand 0.9.1", + "rand 0.9.2", "serde", "sha2", "subtle", @@ -2345,17 +2196,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" @@ -2518,21 +2358,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" @@ -2540,8 +2365,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der", - "pkcs5", - "rand_core 0.6.4", "spki", ] @@ -2670,7 +2493,7 @@ dependencies = [ name = "process_isolation" version = "0.0.0" dependencies = [ - "ctor 0.5.0", + "ctor", "desktop_core", "libc", "tracing", @@ -2713,9 +2536,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -2782,19 +2605,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.12", -] - -[[package]] -name = "regex" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", + "thiserror 2.0.17", ] [[package]] @@ -2870,10 +2681,10 @@ dependencies = [ ] [[package]] -name = "rustc-demangle" -version = "0.1.24" +name = "rustc-hash" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustc_version" @@ -2922,9 +2733,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -2932,15 +2743,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" -[[package]] -name = "salsa20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" -dependencies = [ - "cipher", -] - [[package]] name = "scc" version = "2.4.0" @@ -2956,12 +2758,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" @@ -2982,17 +2778,6 @@ dependencies = [ "syn", ] -[[package]] -name = "scrypt" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" -dependencies = [ - "pbkdf2", - "salsa20", - "sha2", -] - [[package]] name = "sdd" version = "3.0.10" @@ -3024,15 +2809,15 @@ dependencies = [ "libc", "rustix 1.0.7", "rustix-linux-procfs", - "thiserror 2.0.12", - "windows 0.61.1", + "thiserror 2.0.17", + "windows", ] [[package]] name = "security-framework" -version = "3.5.0" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc198e42d9b7510827939c9a15f5062a0c913f3371d765977e586d2fe6c16f4a" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ "bitflags", "core-foundation", @@ -3103,6 +2888,15 @@ 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" @@ -3213,12 +3007,12 @@ checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "socket2" -version = "0.5.9" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3333,16 +3127,16 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.35.0" +version = "0.37.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b897c8ea620e181c7955369a31be5f48d9a9121cb59fd33ecef9ff2a34323422" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" dependencies = [ "libc", "memchr", "ntapi", "objc2-core-foundation", "objc2-io-kit", - "windows 0.61.1", + "windows", ] [[package]] @@ -3358,15 +3152,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "termtree" version = "0.5.1" @@ -3393,11 +3178,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.17", ] [[package]] @@ -3413,9 +3198,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", @@ -3443,11 +3228,10 @@ dependencies = [ [[package]] name = "tokio" -version = "1.45.0" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", "bytes", "libc", "mio", @@ -3457,36 +3241,25 @@ dependencies = [ "socket2", "tokio-macros", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", "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" +version = "0.7.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ "bytes", "futures-core", @@ -3504,12 +3277,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" @@ -3517,10 +3314,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" @@ -3609,9 +3421,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" @@ -3642,12 +3454,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" @@ -3682,7 +3488,7 @@ dependencies = [ "paste", "serde", "textwrap", - "toml", + "toml 0.5.11", "uniffi_meta", "uniffi_udl", ] @@ -3736,7 +3542,7 @@ dependencies = [ "quote", "serde", "syn", - "toml", + "toml 0.5.11", "uniffi_meta", ] @@ -3812,6 +3618,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3824,12 +3641,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" @@ -3845,6 +3694,51 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wayland-backend" version = "0.3.10" @@ -3946,31 +3840,12 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" -dependencies = [ - "windows-core 0.57.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.61.1" @@ -3978,7 +3853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419" dependencies = [ "windows-collections", - "windows-core 0.61.0", + "windows-core", "windows-future", "windows-link 0.1.3", "windows-numerics", @@ -3990,19 +3865,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core 0.61.0", -] - -[[package]] -name = "windows-core" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" -dependencies = [ - "windows-implement 0.57.0", - "windows-interface 0.57.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-core", ] [[package]] @@ -4011,8 +3874,8 @@ version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" dependencies = [ - "windows-implement 0.60.0", - "windows-interface 0.59.1", + "windows-implement", + "windows-interface", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -4024,21 +3887,10 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32" dependencies = [ - "windows-core 0.61.0", + "windows-core", "windows-link 0.1.3", ] -[[package]] -name = "windows-implement" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-implement" version = "0.60.0" @@ -4050,17 +3902,6 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-interface" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-interface" version = "0.59.1" @@ -4090,7 +3931,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core 0.61.0", + "windows-core", "windows-link 0.1.3", ] @@ -4105,15 +3946,6 @@ dependencies = [ "windows-strings 0.5.1", ] -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -4186,6 +4018,15 @@ 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" @@ -4362,8 +4203,8 @@ name = "windows_plugin_authenticator" version = "0.0.0" dependencies = [ "hex", - "windows 0.61.1", - "windows-core 0.61.0", + "windows", + "windows-core", ] [[package]] @@ -4447,6 +4288,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" @@ -4524,9 +4375,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" dependencies = [ "async-broadcast", "async-executor", @@ -4542,14 +4393,15 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix 0.30.1", + "nix", "ordered-stream", "serde", "serde_repr", "tokio", "tracing", "uds_windows", - "windows-sys 0.60.2", + "uuid", + "windows-sys 0.61.2", "winnow", "zbus_macros", "zbus_names", @@ -4558,9 +4410,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" dependencies = [ "proc-macro-crate", "proc-macro2", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 6a366316328..26f791fd660 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "autotype", + "bitwarden_chromium_import_helper", "chromium_importer", "core", "macos_provider", @@ -19,13 +20,14 @@ publish = false [workspace.dependencies] aes = "=0.8.4" -anyhow = "=1.0.94" -arboard = { version = "=3.6.0", default-features = false } -ashpd = "=0.11.0" +aes-gcm = "=0.10.3" +anyhow = "=1.0.100" +arboard = { version = "=3.6.1", default-features = false } +ashpd = "=0.12.0" base64 = "=0.22.1" bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "a641316227227f8777fdf56ac9fa2d6b5f7fe662" } byteorder = "=1.5.0" -bytes = "=1.10.1" +bytes = "=1.11.0" cbc = "=0.1.2" chacha20poly1305 = "=0.10.1" core-foundation = "=0.10.1" @@ -35,56 +37,58 @@ ed25519 = "=2.2.3" embed_plist = "=1.2.2" futures = "=0.3.31" hex = "=0.4.3" -homedir = "=0.3.4" +homedir = "=0.3.6" interprocess = "=2.2.1" -keytar = "=0.1.6" -libc = "=0.2.172" +libc = "=0.2.178" linux-keyutils = "=0.2.4" -log = "=0.4.25" 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" +napi = "=3.3.0" +napi-build = "=2.2.3" +napi-derive = "=3.2.5" +oo7 = "=0.5.0" pin-project = "=1.1.10" pkcs8 = "=0.10.2" -rand = "=0.9.1" +rand = "=0.9.2" rsa = "=0.9.6" russh-cryptovec = "=0.7.3" scopeguard = "=1.2.0" secmem-proc = "=0.3.7" -security-framework = "=3.5.0" +security-framework = "=3.5.1" 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" +sysinfo = "=0.37.2" +thiserror = "=2.0.17" +tokio = "=1.48.0" +tokio-util = "=0.7.17" tracing = "=0.1.41" -tracing-subscriber = { version = "=0.3.20", features = ["fmt", "env-filter", "tracing-log"] } -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.6.1" -zbus = "=5.11.0" +zbus = "=5.12.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" diff --git a/apps/desktop/desktop_native/autotype/Cargo.toml b/apps/desktop/desktop_native/autotype/Cargo.toml index 267074d0bc8..580df30e72d 100644 --- a/apps/desktop/desktop_native/autotype/Cargo.toml +++ b/apps/desktop/desktop_native/autotype/Cargo.toml @@ -9,7 +9,7 @@ publish.workspace = true anyhow = { workspace = true } [target.'cfg(windows)'.dependencies] -mockall = "=0.13.1" +mockall = "=0.14.0" serial_test = "=3.2.0" tracing.workspace = true windows = { workspace = true, features = [ diff --git a/apps/desktop/desktop_native/autotype/src/windows/type_input.rs b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs index b757cf7752f..b2f4c6b82df 100644 --- a/apps/desktop/desktop_native/autotype/src/windows/type_input.rs +++ b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs @@ -33,7 +33,8 @@ impl InputOperations for Win32InputOperations { /// 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(super) fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { @@ -234,16 +235,16 @@ where #[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 + //! 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 super::*; - - use crate::windowing::MockErrorOperations; use serial_test::serial; use windows::Win32::Foundation::WIN32_ERROR; + use super::*; + use crate::windowing::MockErrorOperations; + #[test] fn get_alphabetic_hot_key_succeeds() { for c in ('a'..='z').chain('A'..='Z') { @@ -271,6 +272,7 @@ mod tests { #[serial] fn send_input_succeeds() { let ctxi = MockInputOperations::send_input_context(); + ctxi.checkpoint(); ctxi.expect().returning(|_| 1); send_input::(vec![build_unicode_input( @@ -278,6 +280,8 @@ mod tests { 0, )]) .unwrap(); + + drop(ctxi); } #[test] @@ -287,9 +291,11 @@ mod tests { )] fn send_input_fails_sent_zero() { let ctxi = MockInputOperations::send_input_context(); + ctxi.checkpoint(); ctxi.expect().returning(|_| 0); let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.checkpoint(); ctxge.expect().returning(|| WIN32_ERROR(1)); send_input::(vec![build_unicode_input( @@ -297,6 +303,9 @@ mod tests { 0, )]) .unwrap(); + + drop(ctxge); + drop(ctxi); } #[test] @@ -304,9 +313,11 @@ mod tests { #[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.checkpoint(); ctxi.expect().returning(|_| 2); let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.checkpoint(); ctxge.expect().returning(|| WIN32_ERROR(1)); send_input::(vec![build_unicode_input( @@ -314,5 +325,8 @@ mod tests { 0, )]) .unwrap(); + + drop(ctxge); + drop(ctxi); } } diff --git a/apps/desktop/desktop_native/autotype/src/windows/window_title.rs b/apps/desktop/desktop_native/autotype/src/windows/window_title.rs index 58f06eb54c1..4fc0b3bb3ad 100644 --- a/apps/desktop/desktop_native/autotype/src/windows/window_title.rs +++ b/apps/desktop/desktop_native/autotype/src/windows/window_title.rs @@ -127,8 +127,8 @@ where /// /// # 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. +/// - 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, @@ -169,23 +169,24 @@ where #[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 + //! 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 super::*; - - use crate::windowing::MockErrorOperations; 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.checkpoint(); ctxse .expect() .once() @@ -198,6 +199,7 @@ mod tests { .returning(|| Ok(0)); let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.checkpoint(); ctxge.expect().returning(|| WIN32_ERROR(0)); let len = get_window_title_length::( @@ -206,6 +208,9 @@ mod tests { .unwrap(); assert_eq!(len, 0); + + drop(ctxge); + drop(ctxse); } #[test] @@ -215,6 +220,7 @@ mod tests { let mut mock_handle = MockWindowHandleOperations::new(); let ctxse = MockErrorOperations::set_last_error_context(); + ctxse.checkpoint(); ctxse.expect().with(predicate::eq(0)).returning(|_| {}); mock_handle @@ -223,13 +229,18 @@ mod tests { .returning(|| Ok(0)); let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.checkpoint(); ctxge.expect().returning(|| WIN32_ERROR(1)); get_window_title_length::(&mock_handle) .unwrap(); + + drop(ctxge); + drop(ctxse); } #[test] + #[serial] fn get_window_title_succeeds() { let mut mock_handle = MockWindowHandleOperations::new(); @@ -246,11 +257,11 @@ mod tests { .unwrap(); assert_eq!(title.len(), 43); // That extra slot in the buffer for null char - assert_eq!(title, "*******************************************"); } #[test] + #[serial] fn get_window_title_returns_empty_string() { let mock_handle = MockWindowHandleOperations::new(); @@ -273,10 +284,13 @@ mod tests { .returning(|_| Ok(0)); let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.checkpoint(); ctxge.expect().returning(|| WIN32_ERROR(1)); get_window_title::(&mock_handle, 42) .unwrap(); + + drop(ctxge); } #[test] @@ -290,9 +304,12 @@ mod tests { .returning(|_| Ok(0)); let ctxge = MockErrorOperations::get_last_error_context(); + ctxge.checkpoint(); ctxge.expect().returning(|| WIN32_ERROR(0)); get_window_title::(&mock_handle, 42) .unwrap(); + + drop(ctxge); } } 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/build.js b/apps/desktop/desktop_native/build.js index 8b13fcc6eb3..54a6dba8326 100644 --- a/apps/desktop/desktop_native/build.js +++ b/apps/desktop/desktop_native/build.js @@ -11,8 +11,8 @@ const rustTargetsMap = { "aarch64-pc-windows-msvc": { nodeArch: 'arm64', platform: 'win32' }, "x86_64-apple-darwin": { nodeArch: 'x64', platform: 'darwin' }, "aarch64-apple-darwin": { nodeArch: 'arm64', platform: 'darwin' }, - 'x86_64-unknown-linux-musl': { nodeArch: 'x64', platform: 'linux' }, - 'aarch64-unknown-linux-musl': { nodeArch: 'arm64', platform: 'linux' }, + 'x86_64-unknown-linux-gnu': { nodeArch: 'x64', platform: 'linux' }, + 'aarch64-unknown-linux-gnu': { nodeArch: 'arm64', platform: 'linux' }, } // Ensure the dist directory exists @@ -45,6 +45,25 @@ 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; @@ -67,6 +86,7 @@ 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; } @@ -76,6 +96,7 @@ if (target) { installTarget(target); buildNapiModule(target, mode === "release"); buildProxyBin(target, mode === "release"); + buildImporterBinaries(false, mode === "release"); buildProcessIsolation(); return; } @@ -92,7 +113,8 @@ if (process.platform === "linux") { platformTargets.forEach(([target, _]) => { installTarget(target); - buildNapiModule(target); - buildProxyBin(target); + buildNapiModule(target, mode === "release"); + buildProxyBin(target, mode === "release"); + buildImporterBinaries(target, mode === "release"); buildProcessIsolation(); }); diff --git a/apps/desktop/desktop_native/chromium_importer/Cargo.toml b/apps/desktop/desktop_native/chromium_importer/Cargo.toml index 648a36543c2..9e9a9e0fee8 100644 --- a/apps/desktop/desktop_native/chromium_importer/Cargo.toml +++ b/apps/desktop/desktop_native/chromium_importer/Cargo.toml @@ -7,30 +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 index dd563697e5b..2a708ea572c 100644 --- a/apps/desktop/desktop_native/chromium_importer/README.md +++ b/apps/desktop/desktop_native/chromium_importer/README.md @@ -4,160 +4,137 @@ A rust library that allows you to directly import credentials from Chromium-base ## Windows ABE Architecture -On Windows chrome has additional protection measurements which needs to be circumvented in order to +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) consists of three main components that work together: +The Windows **Application Bound Encryption (ABE)** subsystem consists of two 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 +- **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** -_(The names of the binaries will be changed for the released product.)_ +See the last section for a concise summary of the entire process. -### The goal +### 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 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 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 general idea of this encryption scheme is as follows: -The next paragraphs describe what is done at each level to decrypt the key. +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**. -### 1. Client library +This triply encrypted key is stored in the `Local State` file. -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`. +The following sections describe how the key is decrypted at each level. -This function takes three arguments: +### 1. Client Library -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 +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`. -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. +This function takes two arguments: -The name of the service executable and the data to be decrypted are passed via the command line to -`admin.exe` like this: +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 -admin.exe --service-exe "c:\temp\service.exe" --encrypted "QVBQQgEAAADQjJ3fARXREYx6AMBPwpfrAQAAA..." +bitwarden_chromium_import_helper.exe --encrypted "QVBQQgEAAADQjJ3fARXREYx6AMBPwpfrAQAAA..." ``` -**At this point, the user must permit the action to be performed on the UAC screen.** +### 2. Admin Executable -### 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. -This executable receives the full path of `service.exe` and the data to be decrypted. +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. -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). +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`. -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). +With the duplicated token, `ImpersonateLoggedOnUser` is called to impersonate a system-level process. -After that, the executable stops and uninstalls the service and then exits. +> **At this point `bitwarden_chromium_import_helper.exe` is running as SYSTEM.** -### 3. System service +The received encryption key can now be decrypted using DPAPI at the **system level**. -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. +Next, the impersonation is stopped and the feshly decrypted key is decrypted at the **user level** with DPAPI one more +time. -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 `!`. +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. -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. +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: -### 4. Back to client library +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 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. +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. -In the next step, the string is decrypted at the user level with the same Windows Data Protection -API. +The response can indicate success or failure: -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`. +- On success: a Base64-encoded string. +- On failure: an error message prefixed with `!`. -After all of these steps, we have the master key which can be used to decrypt the password -information stored in the local database. +In either case, the response is sent to the named pipe server created by the client. The client responds with `ok` +(ignored). -### Summary +Finally, `bitwarden_chromium_import_helper.exe` exits. -The Windows ABE decryption process involves a three-tier architecture with named pipe communication: +### 3. Back to the Client Library -```mermaid -sequenceDiagram - participant Client as Client Library (User) - participant Admin as admin.exe (Administrator) - participant Service as service.exe (System) +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. - Client->>Client: Create named pipe server - Note over Client: \\.\pipe\BitwardenEncryptionService-admin-user +### TL;DR Steps - Client->>Admin: Launch with UAC elevation - Note over Client,Admin: --service-exe c:\path\to\service.exe - Note over Client,Admin: --encrypted QVBQQgEAAADQjJ3fARXRE... +1. **Client side:** - Client->>Client: Wait for response + 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`. - Admin->>Service: Install & start service - Note over Admin,Service: c:\path\to\service.exe +2. **Admin side:** - Service->>Service: Create named pipe server - Note over Service: \\.\pipe\BitwardenEncryptionService-service-admin + 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. - 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 -``` +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/chromium_importer/src/chromium/mod.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs index 55728460436..7011a2cce63 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs @@ -1,15 +1,20 @@ -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}; mod platform; pub(crate) use platform::SUPPORTED_BROWSERS as PLATFORM_SUPPORTED_BROWSERS; +#[cfg(target_os = "windows")] +pub use platform::*; // // Public API @@ -52,13 +57,12 @@ pub trait InstalledBrowserRetriever { pub struct DefaultInstalledBrowserRetriever {} impl InstalledBrowserRetriever for DefaultInstalledBrowserRetriever { - // TODO: Make thus async 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() { + let data_dir = get_and_validate_data_dir(config); + if data_dir.is_ok() { browsers.push((*browser).to_string()); } } @@ -67,7 +71,6 @@ impl InstalledBrowserRetriever for DefaultInstalledBrowserRetriever { } } -// 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)) @@ -85,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()) @@ -110,7 +114,7 @@ pub async fn import_logins( #[derive(Debug, Clone, Copy)] pub(crate) struct BrowserConfig { pub name: &'static str, - pub data_dir: &'static str, + pub data_dir: &'static [&'static str], } pub(crate) static SUPPORTED_BROWSER_MAP: LazyLock< @@ -122,12 +126,19 @@ pub(crate) static SUPPORTED_BROWSER_MAP: LazyLock< .collect::>() }); -fn get_browser_data_dir(config: &BrowserConfig) -> Result { - let dir = my_home() - .map_err(|_| anyhow!("Home directory not found"))? - .ok_or_else(|| anyhow!("Home directory not found"))? - .join(config.data_dir); - Ok(dir) +fn get_and_validate_data_dir(config: &BrowserConfig) -> Result { + for data_dir in config.data_dir.iter() { + let dir = dirs::home_dir() + .ok_or_else(|| anyhow!("Home directory not found"))? + .join(data_dir); + if dir.exists() { + return Ok(dir); + } + } + Err(anyhow!( + "Browser user data directory '{:?}' not found", + config.data_dir + )) } // @@ -148,13 +159,13 @@ pub(crate) 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, } @@ -171,13 +182,7 @@ fn load_local_state_for_browser(browser_name: &String) -> Result<(PathBuf, Local .get(browser_name.as_str()) .ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?; - let data_dir = get_browser_data_dir(config)?; - if !data_dir.exists() { - return Err(anyhow!( - "Browser user data directory '{}' not found", - data_dir.display() - )); - } + let data_dir = get_and_validate_data_dir(config)?; let local_state = load_local_state(&data_dir)?; @@ -197,10 +202,14 @@ fn get_profile_info(local_state: &LocalState) -> Vec { .profile .info_cache .iter() - .map(|(name, info)| ProfileInfo { - name: info.name.clone(), - folder: name.clone(), - account_name: info.gaia_name.clone(), + .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(), }) .collect() @@ -348,3 +357,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/chromium_importer/src/chromium/platform/linux.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs index 227dffdcca7..f542e23129a 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs @@ -4,31 +4,36 @@ use anyhow::{anyhow, Result}; use async_trait::async_trait; use oo7::XDG_SCHEMA_ATTRIBUTE; -use crate::chromium::{BrowserConfig, CryptoService, LocalState}; - -use crate::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.). +// 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", + data_dir: &[".config/google-chrome"], }, BrowserConfig { name: "Chromium", - data_dir: "snap/chromium/common/chromium", + data_dir: &["snap/chromium/common/chromium"], }, BrowserConfig { name: "Brave", - data_dir: "snap/brave/current/.config/BraveSoftware/Brave-Browser", + data_dir: &[ + "snap/brave/current/.config/BraveSoftware/Brave-Browser", + ".config/BraveSoftware/Brave-Browser", + ], }, BrowserConfig { name: "Opera", - data_dir: "snap/opera/current/.config/opera", + data_dir: &["snap/opera/current/.config/opera", ".config/opera"], }, ]; diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/macos.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/macos.rs index c0e770c161b..6cd746d60b6 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/macos.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/macos.rs @@ -2,9 +2,10 @@ use anyhow::{anyhow, Result}; use async_trait::async_trait; use security_framework::passwords::get_generic_password; -use crate::chromium::{BrowserConfig, CryptoService, LocalState}; - -use crate::util; +use crate::{ + chromium::{BrowserConfig, CryptoService, LocalState}, + util, +}; // // Public API @@ -13,31 +14,31 @@ use crate::util; pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ BrowserConfig { name: "Chrome", - data_dir: "Library/Application Support/Google/Chrome", + data_dir: &["Library/Application Support/Google/Chrome"], }, BrowserConfig { name: "Chromium", - data_dir: "Library/Application Support/Chromium", + data_dir: &["Library/Application Support/Chromium"], }, BrowserConfig { name: "Microsoft Edge", - data_dir: "Library/Application Support/Microsoft Edge", + data_dir: &["Library/Application Support/Microsoft Edge"], }, BrowserConfig { name: "Brave", - data_dir: "Library/Application Support/BraveSoftware/Brave-Browser", + data_dir: &["Library/Application Support/BraveSoftware/Brave-Browser"], }, BrowserConfig { name: "Arc", - data_dir: "Library/Application Support/Arc/User Data", + data_dir: &["Library/Application Support/Arc/User Data"], }, BrowserConfig { name: "Opera", - data_dir: "Library/Application Support/com.operasoftware.Opera", + data_dir: &["Library/Application Support/com.operasoftware.Opera"], }, BrowserConfig { name: "Vivaldi", - data_dir: "Library/Application Support/Vivaldi", + data_dir: &["Library/Application Support/Vivaldi"], }, ]; 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 index 2a21ef23d82..fe497de0773 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs @@ -1,7 +1,9 @@ // Platform-specific code #[cfg_attr(target_os = "linux", path = "linux.rs")] -#[cfg_attr(target_os = "windows", path = "windows.rs")] +#[cfg_attr(target_os = "windows", path = "windows/mod.rs")] #[cfg_attr(target_os = "macos", path = "macos.rs")] mod native; -pub(crate) use native::*; +// Windows exposes public const +#[allow(unused_imports)] +pub use native::*; diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows.rs deleted file mode 100644 index 79c462c29a1..00000000000 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows.rs +++ /dev/null @@ -1,204 +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}; - -use crate::util; - -// -// 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))) -} - -// -// 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/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..524b5994873 --- /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 index d92515c39f9..d03e4cdf496 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/lib.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/lib.rs @@ -1,5 +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 index bfd7f184621..9aa2cea6e5e 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/metadata.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs @@ -7,9 +7,9 @@ pub struct NativeImporterMetadata { /// Identifies the importer pub id: String, /// Describes the strategies used to obtain imported data - pub loaders: Vec<&'static str>, + pub loaders: Vec, /// Identifies the instructions for the importer - pub instructions: &'static str, + pub instructions: String, } /// Returns a map of supported importers based on the current platform. @@ -36,9 +36,9 @@ pub fn get_supported_importers( PLATFORM_SUPPORTED_BROWSERS.iter().map(|b| b.name).collect(); for (id, browser_name) in IMPORTERS { - let mut loaders: Vec<&'static str> = vec!["file"]; + let mut loaders: Vec = vec!["file".to_string()]; if supported.contains(browser_name) { - loaders.push("chromium"); + loaders.push("chromium".to_string()); } if installed_browsers.contains(&browser_name.to_string()) { @@ -47,7 +47,7 @@ pub fn get_supported_importers( NativeImporterMetadata { id: id.to_string(), loaders, - instructions: "chromium", + instructions: "chromium".to_string(), }, ); } @@ -59,9 +59,9 @@ pub fn get_supported_importers( // Tests are cfg-gated based upon OS, and must be compiled/run on each OS for full coverage #[cfg(test)] mod tests { - use super::*; use std::collections::HashSet; + use super::*; use crate::chromium::{InstalledBrowserRetriever, SUPPORTED_BROWSER_MAP}; pub struct MockInstalledBrowserRetriever {} @@ -79,12 +79,9 @@ mod tests { map.keys().cloned().collect() } - fn get_loaders( - map: &HashMap, - id: &str, - ) -> HashSet<&'static str> { + fn get_loaders(map: &HashMap, id: &str) -> HashSet { map.get(id) - .map(|m| m.loaders.iter().copied().collect::>()) + .map(|m| m.loaders.iter().cloned().collect::>()) .unwrap_or_default() } @@ -107,7 +104,7 @@ mod tests { for (key, meta) in map.iter() { assert_eq!(&meta.id, key); assert_eq!(meta.instructions, "chromium"); - assert!(meta.loaders.iter().any(|l| *l == "file")); + assert!(meta.loaders.contains(&"file".to_owned())); } } @@ -147,7 +144,7 @@ mod tests { for (key, meta) in map.iter() { assert_eq!(&meta.id, key); assert_eq!(meta.instructions, "chromium"); - assert!(meta.loaders.iter().any(|l| *l == "file")); + assert!(meta.loaders.contains(&"file".to_owned())); } } @@ -183,7 +180,7 @@ mod tests { for (key, meta) in map.iter() { assert_eq!(&meta.id, key); assert_eq!(meta.instructions, "chromium"); - assert!(meta.loaders.iter().any(|l| *l == "file")); + assert!(meta.loaders.contains(&"file".to_owned())); } } diff --git a/apps/desktop/desktop_native/chromium_importer/src/util.rs b/apps/desktop/desktop_native/chromium_importer/src/util.rs index f346d7e6dd0..2dbc6ed005b 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/util.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/util.rs @@ -32,7 +32,7 @@ pub(crate) fn split_encrypted_string_and_validate<'a>( } /// Decrypt using AES-128 in CBC mode. -#[cfg(any(target_os = "linux", target_os = "macos", test))] +#[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}; @@ -41,7 +41,8 @@ pub(crate) fn decrypt_aes_128_cbc(key: &[u8], iv: &[u8], ciphertext: &[u8]) -> R .map_err(|e| anyhow!("Failed to decrypt: {}", e)) } -/// Derives a PBKDF2 key from the static "saltysalt" salt with the given password and iteration count. +/// 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}; @@ -55,27 +56,9 @@ pub(crate) fn derive_saltysalt(password: &[u8], iterations: u32) -> Result Vec { - (0..length).map(|i| offset + i as u8 * increment).collect() - } - - fn generate_generic_array>( - offset: u8, - increment: u8, - ) -> GenericArray { - GenericArray::generate(|i| offset + i as u8 * increment) - } - fn run_split_encrypted_string_test<'a, const N: usize>( successfully_split: bool, plaintext_to_encrypt: &'a str, @@ -144,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 f6c9d669df6..dc9246f55c6 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -23,27 +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"] } memsec = { workspace = true, features = ["alloc_ext"] } -pin-project = { workspace = true } -pkcs8 = { workspace = true, features = ["alloc", "encryption", "pem"] } rand = { workspace = true } -rsa = { workspace = true } -russh-cryptovec = { workspace = true } -scopeguard = { workspace = true } -secmem-proc = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } sha2 = { workspace = true } -ssh-encoding = { workspace = true } ssh-key = { workspace = true, features = [ "encryption", "ed25519", @@ -53,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", @@ -76,21 +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 } +ashpd = { workspace = true } +homedir = { workspace = true } libc = { workspace = true } linux-keyutils = { workspace = true } -ashpd = { 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 index e37a101e2ae..55aee27dd33 100644 --- a/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs +++ b/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs @@ -1,7 +1,7 @@ use anyhow::Result; #[allow(clippy::module_inception)] -#[cfg_attr(target_os = "linux", path = "unimplemented.rs")] +#[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; @@ -17,8 +17,8 @@ pub trait BiometricTrait: Send + Sync { 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. + /// 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<()>; @@ -28,6 +28,7 @@ pub trait BiometricTrait: Send + Sync { 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 + /// 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/windows.rs b/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs index 043c2453cd0..669dd757c40 100644 --- a/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs +++ b/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs @@ -2,38 +2,40 @@ //! //! 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. +//! 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. +//! 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 +//! 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. +//! 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). +//! 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. +//! 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 tracing::{debug, warn}; 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::{ @@ -74,8 +76,8 @@ struct WindowsHelloKeychainEntry { /// 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 + // The userkeys that are held in memory MUST be protected from memory dumping attacks, to + // ensure locked vaults cannot be unlocked secure_memory: Arc>, } @@ -114,12 +116,14 @@ impl super::BiometricTrait for BiometricLockSystem { } 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 + // 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 + // 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 @@ -155,8 +159,8 @@ impl super::BiometricTrait for BiometricLockSystem { }); 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 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 @@ -175,7 +179,8 @@ impl super::BiometricTrait for BiometricLockSystem { &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. + // 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) } @@ -231,8 +236,8 @@ async fn windows_hello_authenticate_with_crypto( ) -> 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 + // 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 { @@ -243,8 +248,8 @@ async fn windows_hello_authenticate_with_crypto( 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. + // 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); }); @@ -280,11 +285,11 @@ async fn windows_hello_authenticate_with_crypto( return Err(anyhow!("Failed to sign data")); } - let signature_buffer = signature.Result()?; - let signature_value = unsafe { as_mut_bytes(&signature_buffer)? }; + let mut signature_buffer = signature.Result()?; + let signature_value = unsafe { as_mut_bytes(&mut 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. + // 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) } @@ -363,7 +368,7 @@ fn decrypt_data( Ok(plaintext) } -unsafe fn as_mut_bytes(buffer: &IBuffer) -> Result<&mut [u8]> { +unsafe fn as_mut_bytes(buffer: &mut IBuffer) -> Result<&mut [u8]> { let interop = buffer.cast::()?; unsafe { 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 index f3ffb6e4ebe..bf303c88e01 100644 --- a/apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs +++ b/apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs @@ -34,23 +34,25 @@ pub fn focus_security_prompt() { /// 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. + // 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. + // - 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. + // - 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; @@ -75,7 +77,8 @@ fn set_focus(hwnd: HWND) { ); }); - // Attach to the foreground thread once attached, we can foreground, even if in the background + // 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); @@ -91,7 +94,8 @@ fn set_focus(hwnd: HWND) { } } -/// When restoring focus to the application window, we need a less aggressive method so the electron window doesn't get frozen. +/// 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); 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/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 bad348c93e2..263cc10b716 100644 --- a/apps/desktop/desktop_native/core/src/process_isolation/linux.rs +++ b/apps/desktop/desktop_native/core/src/process_isolation/linux.rs @@ -4,15 +4,15 @@ 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; diff --git a/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs b/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs index 3ff8a6d3d83..8d8e10d92c4 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs @@ -29,8 +29,9 @@ 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 + // 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(); diff --git a/apps/desktop/desktop_native/core/src/secure_memory/encrypted_memory_store.rs b/apps/desktop/desktop_native/core/src/secure_memory/encrypted_memory_store.rs index a8952d8f55a..d116e564bc8 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/encrypted_memory_store.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/encrypted_memory_store.rs @@ -10,8 +10,8 @@ use crate::secure_memory::{ /// 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. +/// 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, diff --git a/apps/desktop/desktop_native/core/src/secure_memory/mod.rs b/apps/desktop/desktop_native/core/src/secure_memory/mod.rs index 8695904758e..d4323ce40dd 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/mod.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/mod.rs @@ -1,7 +1,7 @@ #[cfg(target_os = "windows")] pub(crate) mod dpapi; -mod encrypted_memory_store; +pub(crate) mod encrypted_memory_store; mod secure_key; /// The secure memory store provides an ephemeral key-value store for sensitive data. diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/crypto.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/crypto.rs index 1ee6c4cdf40..7e2917ade6d 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/crypto.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/crypto.rs @@ -6,9 +6,9 @@ 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. +/// 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. 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 index 0975b542877..52b75d94a09 100644 --- 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 @@ -1,10 +1,13 @@ -use super::crypto::{MemoryEncryptionKey, KEY_SIZE}; -use super::SecureKeyContainer; 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. 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 index a738d964671..29c62759740 100644 --- 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 @@ -1,9 +1,8 @@ -use crate::secure_memory::secure_key::crypto::MemoryEncryptionKey; - -use super::crypto::KEY_SIZE; -use super::SecureKeyContainer; 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 @@ -26,9 +25,9 @@ pub(super) struct KeyctlSecureKeyContainer { 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. +// 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 {} 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 index 4e6a2c4d7ac..e9f96db3148 100644 --- 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 @@ -1,8 +1,9 @@ use std::{ptr::NonNull, sync::LazyLock}; -use super::crypto::MemoryEncryptionKey; -use super::crypto::KEY_SIZE; -use super::SecureKeyContainer; +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 @@ -15,8 +16,8 @@ pub(super) struct MemfdSecretSecureKeyContainer { // 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. +// 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 { 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 index db21cd7fedc..961988c1d40 100644 --- 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 @@ -1,8 +1,9 @@ use std::ptr::NonNull; -use super::crypto::MemoryEncryptionKey; -use super::crypto::KEY_SIZE; -use super::SecureKeyContainer; +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. diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mod.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mod.rs index 6c3b53117a5..26e72f7d581 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mod.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mod.rs @@ -1,9 +1,12 @@ -//! 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. +//! 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. +//! The implementations include DPAPI on Windows, `keyctl` on Linux, and `memfd_secret` on Linux, +//! and a fallback implementation using mlock. use tracing::info; @@ -20,12 +23,13 @@ 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. +/// 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`. +/// 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); @@ -55,7 +59,8 @@ impl SecureMemoryEncryptionKey { /// 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. + /// 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; 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 61cb8fc187d..8ba64618ffa 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs @@ -7,13 +7,12 @@ use std::{ }; 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")] @@ -34,7 +33,8 @@ pub struct BitwardenDesktopAgent { 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, } 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 cb10e873a33..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,6 +8,8 @@ use std::{ }, task::{Context, Poll}, }; + +use futures::Stream; use tokio::{ net::windows::named_pipe::{NamedPipeServer, ServerOptions}, select, 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 a45c2f6c0bf..8623df13776 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs @@ -6,9 +6,8 @@ use homedir::my_home; use tokio::{net::UnixListener, sync::Mutex}; use tracing::{error, info}; -use crate::ssh_agent::peercred_unix_listener_stream::PeercredUnixListenerStream; - use super::{BitwardenDesktopAgent, SshAgentUIRequest}; +use crate::ssh_agent::peercred_unix_listener_stream::PeercredUnixListenerStream; /// User can override the default socket path with this env var const ENV_BITWARDEN_SSH_AUTH_SOCK: &str = "BITWARDEN_SSH_AUTH_SOCK"; 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 662a4658ede..2012dab2d77 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs @@ -2,6 +2,7 @@ use bitwarden_russh::ssh_agent; pub mod named_pipe_listener_stream; use std::sync::Arc; + use tokio::sync::Mutex; use super::{BitwardenDesktopAgent, SshAgentUIRequest}; diff --git a/apps/desktop/desktop_native/macos_provider/Cargo.toml b/apps/desktop/desktop_native/macos_provider/Cargo.toml index 97a8b7d545a..8a34460268a 100644 --- a/apps/desktop/desktop_native/macos_provider/Cargo.toml +++ b/apps/desktop/desktop_native/macos_provider/Cargo.toml @@ -14,19 +14,17 @@ crate-type = ["staticlib", "cdylib"] bench = false [dependencies] +uniffi = { workspace = true, features = ["cli"] } + +[target.'cfg(target_os = "macos")'.dependencies] desktop_core = { path = "../core" } futures = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["sync"] } -tokio-util = { workspace = true } tracing = { workspace = true } -tracing-oslog = "0.3.0" tracing-subscriber = { workspace = true } -uniffi = { workspace = true, features = ["cli"] } - -[target.'cfg(target_os = "macos")'.dependencies] -oslog = { workspace = true } +tracing-oslog = "=0.3.0" [build-dependencies] uniffi = { workspace = true, features = ["build"] } diff --git a/apps/desktop/desktop_native/macos_provider/README.md b/apps/desktop/desktop_native/macos_provider/README.md new file mode 100644 index 00000000000..1d4c1902465 --- /dev/null +++ b/apps/desktop/desktop_native/macos_provider/README.md @@ -0,0 +1,35 @@ +# Explainer: Mac OS Native Passkey Provider + +This document describes the changes introduced in https://github.com/bitwarden/clients/pull/13963, where we introduce the MacOS Native Passkey Provider. It gives the high level explanation of the architecture and some of the quirks and additional good to know context. + +## The high level +MacOS has native APIs (similar to iOS) to allow Credential Managers to provide credentials to the MacOS autofill system (in the PR referenced above, we only provide passkeys). + +We’ve written a Swift-based native autofill-extension. It’s bundled in the app-bundle in PlugIns, similar to the safari-extension. + +This swift extension currently communicates with our Electron app through IPC based on a unix socket. The IPC implementation is done in Rust and utilized through UniFFI + NAPI bindings. + +Footnotes: + +* We're not using the IPC framework as the implementation pre-dates the IPC framework. +* Alternatives like XPC or CFMessagePort may have better support for when the app is sandboxed. + +Electron receives the messages and passes it to Angular (through the electron-renderer event system). + +Our existing fido2 services in the renderer respond to events, displaying UI as necessary, and returns the signature back through the same mechanism, allowing people to authenticate with passkeys through the native system + UI. See [Mac OS Native Passkey Workflows](https://bitwarden.atlassian.net/wiki/spaces/EN/pages/1828356098/Mac+OS+Native+Passkey+Workflows) for demo videos. + +## Typescript + UI implementations + +We utilize the same FIDO2 implementation and interface that is already present for our browser authentication. It was designed by @coroiu with multiple ‘ui environments' in mind. + +Therefore, a lot of the plumbing is implemented in /autofill/services/desktop-fido2-user-interface.service.ts, which implements the interface that our fido2 authenticator/client expects to drive UI related behaviors. + +We’ve also implemented a couple FIDO2 UI components to handle registration/sign in flows, but also improved the “modal mode” of the desktop app. + +## Modal mode + +When modal mode is activated, the desktop app turns into a smaller modal that is always on top and cannot be resized. This is done to improve the UX of performing a passkey operation (or SSH operation). Once the operation is completed, the app returns to normal mode and its previous position. + +We are not using electron modal windows, for a couple reason. It would require us to send data in yet another layer of IPC, but also because we'd need to bootstrap entire renderer/app instead of reusing the existing window. + +Some modal modes may hide the 'traffic buttons' (window controls) due to design requirements. diff --git a/apps/desktop/desktop_native/macos_provider/build.sh b/apps/desktop/desktop_native/macos_provider/build.sh index 21e2e045af4..2f7a2d03541 100755 --- a/apps/desktop/desktop_native/macos_provider/build.sh +++ b/apps/desktop/desktop_native/macos_provider/build.sh @@ -8,6 +8,9 @@ rm -r tmp mkdir -p ./tmp/target/universal-darwin/release/ +rustup target add aarch64-apple-darwin +rustup target add x86_64-apple-darwin + cargo build --package macos_provider --target aarch64-apple-darwin --release cargo build --package macos_provider --target x86_64-apple-darwin --release diff --git a/apps/desktop/desktop_native/macos_provider/src/lib.rs b/apps/desktop/desktop_native/macos_provider/src/lib.rs index 789a56d3048..8619a77a0f2 100644 --- a/apps/desktop/desktop_native/macos_provider/src/lib.rs +++ b/apps/desktop/desktop_native/macos_provider/src/lib.rs @@ -1,4 +1,5 @@ #![cfg(target_os = "macos")] +#![allow(clippy::disallowed_macros)] // uniffi macros trip up clippy's evaluation use std::{ collections::HashMap, @@ -56,6 +57,14 @@ trait Callback: Send + Sync { fn error(&self, error: BitwardenError); } +#[derive(uniffi::Enum, Debug)] +/// Store the connection status between the macOS credential provider extension +/// and the desktop application's IPC server. +pub enum ConnectionStatus { + Connected, + Disconnected, +} + #[derive(uniffi::Object)] pub struct MacOSProviderClient { to_server_send: tokio::sync::mpsc::Sender, @@ -64,8 +73,24 @@ pub struct MacOSProviderClient { response_callbacks_counter: AtomicU32, #[allow(clippy::type_complexity)] response_callbacks_queue: Arc, Instant)>>>, + + // Flag to track connection status - atomic for thread safety without locks + connection_status: Arc, } +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Store native desktop status information to use for IPC communication +/// between the application and the macOS credential provider. +pub struct NativeStatus { + key: String, + value: String, +} + +// In our callback management, 0 is a reserved sequence number indicating that a message does not +// have a callback. +const NO_CALLBACK_INDICATOR: u32 = 0; + #[uniffi::export] impl MacOSProviderClient { // FIXME: Remove unwraps! They panic and terminate the whole application. @@ -92,13 +117,16 @@ impl MacOSProviderClient { let client = MacOSProviderClient { to_server_send, - response_callbacks_counter: AtomicU32::new(0), + response_callbacks_counter: AtomicU32::new(1), /* Start at 1 since 0 is reserved for + * "no callback" scenarios */ response_callbacks_queue: Arc::new(Mutex::new(HashMap::new())), + connection_status: Arc::new(std::sync::atomic::AtomicBool::new(false)), }; - let path = desktop_core::ipc::path("autofill"); + let path = desktop_core::ipc::path("af"); let queue = client.response_callbacks_queue.clone(); + let connection_status = client.connection_status.clone(); std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() @@ -116,9 +144,11 @@ impl MacOSProviderClient { match serde_json::from_str::(&message) { Ok(SerializedMessage::Command(CommandMessage::Connected)) => { info!("Connected to server"); + connection_status.store(true, std::sync::atomic::Ordering::Relaxed); } Ok(SerializedMessage::Command(CommandMessage::Disconnected)) => { info!("Disconnected from server"); + connection_status.store(false, std::sync::atomic::Ordering::Relaxed); } Ok(SerializedMessage::Message { sequence_number, @@ -156,12 +186,17 @@ impl MacOSProviderClient { client } + pub fn send_native_status(&self, key: String, value: String) { + let status = NativeStatus { key, value }; + self.send_message(status, None); + } + pub fn prepare_passkey_registration( &self, request: PasskeyRegistrationRequest, callback: Arc, ) { - self.send_message(request, Box::new(callback)); + self.send_message(request, Some(Box::new(callback))); } pub fn prepare_passkey_assertion( @@ -169,7 +204,7 @@ impl MacOSProviderClient { request: PasskeyAssertionRequest, callback: Arc, ) { - self.send_message(request, Box::new(callback)); + self.send_message(request, Some(Box::new(callback))); } pub fn prepare_passkey_assertion_without_user_interface( @@ -177,7 +212,18 @@ impl MacOSProviderClient { request: PasskeyAssertionWithoutUserInterfaceRequest, callback: Arc, ) { - self.send_message(request, Box::new(callback)); + self.send_message(request, Some(Box::new(callback))); + } + + pub fn get_connection_status(&self) -> ConnectionStatus { + let is_connected = self + .connection_status + .load(std::sync::atomic::Ordering::Relaxed); + if is_connected { + ConnectionStatus::Connected + } else { + ConnectionStatus::Disconnected + } } } @@ -199,7 +245,6 @@ enum SerializedMessage { } impl MacOSProviderClient { - // FIXME: Remove unwraps! They panic and terminate the whole application. #[allow(clippy::unwrap_used)] fn add_callback(&self, callback: Box) -> u32 { let sequence_number = self @@ -208,20 +253,23 @@ impl MacOSProviderClient { self.response_callbacks_queue .lock() - .unwrap() + .expect("response callbacks queue mutex should not be poisoned") .insert(sequence_number, (callback, Instant::now())); sequence_number } - // FIXME: Remove unwraps! They panic and terminate the whole application. #[allow(clippy::unwrap_used)] fn send_message( &self, message: impl Serialize + DeserializeOwned, - callback: Box, + callback: Option>, ) { - let sequence_number = self.add_callback(callback); + let sequence_number = if let Some(callback) = callback { + self.add_callback(callback) + } else { + NO_CALLBACK_INDICATOR + }; let message = serde_json::to_string(&SerializedMessage::Message { sequence_number, @@ -231,15 +279,17 @@ impl MacOSProviderClient { if let Err(e) = self.to_server_send.blocking_send(message) { // Make sure we remove the callback from the queue if we can't send the message - if let Some((cb, _)) = self - .response_callbacks_queue - .lock() - .unwrap() - .remove(&sequence_number) - { - cb.error(BitwardenError::Internal(format!( - "Error sending message: {e}" - ))); + if sequence_number != NO_CALLBACK_INDICATOR { + if let Some((callback, _)) = self + .response_callbacks_queue + .lock() + .expect("response callbacks queue mutex should not be poisoned") + .remove(&sequence_number) + { + callback.error(BitwardenError::Internal(format!( + "Error sending message: {e}" + ))); + } } } } diff --git a/apps/desktop/desktop_native/macos_provider/src/registration.rs b/apps/desktop/desktop_native/macos_provider/src/registration.rs index 9e697b75c16..c961566a86c 100644 --- a/apps/desktop/desktop_native/macos_provider/src/registration.rs +++ b/apps/desktop/desktop_native/macos_provider/src/registration.rs @@ -14,6 +14,7 @@ pub struct PasskeyRegistrationRequest { user_verification: UserVerification, supported_algorithms: Vec, window_xy: Position, + excluded_credentials: Vec>, } #[derive(uniffi::Record, Serialize, Deserialize)] diff --git a/apps/desktop/desktop_native/napi/Cargo.toml b/apps/desktop/desktop_native/napi/Cargo.toml index 4198baa4b5a..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 } 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 0a8beb8c427..375c65edb8d 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -1,169 +1,26 @@ -/* tslint:disable */ -/* eslint-disable */ - /* auto-generated by NAPI-RS */ - -export declare namespace passwords { - /** The error message returned when a password is not found during retrieval or deletion. */ - export const PASSWORD_NOT_FOUND: string - /** - * Fetch the stored password from the keychain. - * 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. */ - export function setPassword(service: string, account: string, password: string): Promise - /** - * Delete the stored password from the keychain. - * Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist. - */ - export function deletePassword(service: string, account: string): Promise - /** Checks if the os secure storage is available */ - export function isAvailable(): Promise -} -export declare namespace biometrics { - export function prompt(hwnd: Buffer, message: string): Promise - export function available(): Promise - export function setBiometricSecret(service: string, account: string, secret: string, keyMaterial: KeyMaterial | undefined | null, ivB64: string): Promise - /** - * Retrieves the biometric secret for the given service and account. - * Throws Error with message [`passwords::PASSWORD_NOT_FOUND`] if the secret does not exist. - */ - export function getBiometricSecret(service: string, account: string, keyMaterial?: KeyMaterial | undefined | null): Promise - /** - * Derives key material from biometric data. Returns a string encoded with a - * base64 encoded key and the base64 encoded challenge used to create it - * separated by a `|` character. - * - * If the iv is provided, it will be used as the challenge. Otherwise a random challenge will be generated. - * - * `format!("|")` - */ - export function deriveKeyMaterial(iv?: string | undefined | null): Promise - export interface KeyMaterial { - osKeyPartB64: string - clientKeyPartB64?: string - } - export interface OsDerivedKey { - keyB64: string - 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 -} -export declare namespace sshagent { - export interface PrivateKey { - privateKey: string - name: string - cipherId: string - } - export interface SshKey { - privateKey: string - publicKey: string - keyFingerprint: string - } - export interface SshUiRequest { - cipherId?: string - isList: boolean - processName: string - isForwarding: boolean - namespace?: string - } - export function serve(callback: (err: Error | null, arg: SshUiRequest) => any): Promise - export function stop(agentState: SshAgentState): void - export function isRunning(agentState: SshAgentState): boolean - export function setKeys(agentState: SshAgentState, newKeys: Array): void - export function lock(agentState: SshAgentState): void - export function clearKeys(agentState: SshAgentState): void - export class SshAgentState { } -} -export declare namespace processisolations { - export function disableCoredumps(): Promise - export function isCoreDumpingDisabled(): Promise - export function isolateProcess(): Promise -} -export declare namespace powermonitors { - export function onLock(callback: (err: Error | null, ) => any): Promise - export function isLockMonitorAvailable(): Promise -} -export declare namespace windows_registry { - export function createKey(key: string, subkey: string, value: string): Promise - export function deleteKey(key: string, subkey: string): Promise -} -export declare namespace ipc { - export interface IpcMessage { - clientId: number - kind: IpcMessageType - message?: string - } - export const enum IpcMessageType { - Connected = 0, - Disconnected = 1, - Message = 2 - } - export class IpcServer { +/* eslint-disable */ +export declare namespace autofill { + export class AutofillIpcServer { /** * Create and start the IPC server without blocking. * - * @param name The endpoint name to listen on. This name uniquely identifies the IPC connection and must be the same for both the server and client. - * @param callback This function will be called whenever a message is received from a client. + * @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 + 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, nativeStatusCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void): Promise /** Return the path to the IPC server. */ getPath(): string /** Stop the IPC server. */ stop(): void - /** - * 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. - */ - send(message: string): number + completeRegistration(clientId: number, sequenceNumber: number, response: PasskeyRegistrationResponse): number + completeAssertion(clientId: number, sequenceNumber: number, response: PasskeyAssertionResponse): number + completeError(clientId: number, sequenceNumber: number, error: string): number } -} -export declare namespace autostart { - export function setAutostart(autostart: boolean, params: Array): Promise -} -export declare namespace autofill { - export function runCommand(value: string): Promise - export const enum UserVerification { - Preferred = 'preferred', - Required = 'required', - Discouraged = 'discouraged' - } - export interface Position { - x: number - y: number - } - export interface PasskeyRegistrationRequest { - rpId: string - userName: string - userHandle: Array - clientDataHash: Array - userVerification: UserVerification - supportedAlgorithms: Array - windowXy: Position - } - export interface PasskeyRegistrationResponse { - rpId: string - clientDataHash: Array - credentialId: Array - attestationObject: Array + export interface NativeStatus { + key: string + value: string } export interface PasskeyAssertionRequest { rpId: string @@ -172,6 +29,14 @@ export declare namespace autofill { allowedCredentials: Array> windowXy: Position } + export interface PasskeyAssertionResponse { + rpId: string + userHandle: Array + signature: Array + clientDataHash: Array + authenticatorData: Array + credentialId: Array + } export interface PasskeyAssertionWithoutUserInterfaceRequest { rpId: string credentialId: Array @@ -182,49 +47,93 @@ export declare namespace autofill { userVerification: UserVerification windowXy: Position } - export interface PasskeyAssertionResponse { + export interface PasskeyRegistrationRequest { rpId: string + userName: string userHandle: Array - signature: Array clientDataHash: Array - authenticatorData: Array + userVerification: UserVerification + supportedAlgorithms: Array + windowXy: Position + excludedCredentials: Array> + } + export interface PasskeyRegistrationResponse { + rpId: string + clientDataHash: Array credentialId: Array + attestationObject: Array } - export class 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. - */ - 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. */ - getPath(): string - /** Stop the IPC server. */ - stop(): void - completeRegistration(clientId: number, sequenceNumber: number, response: PasskeyRegistrationResponse): number - completeAssertion(clientId: number, sequenceNumber: number, response: PasskeyAssertionResponse): number - completeError(clientId: number, sequenceNumber: number, error: string): number + export interface Position { + x: number + y: number + } + export function runCommand(value: string): Promise + export const enum UserVerification { + Preferred = 'preferred', + Required = 'required', + Discouraged = 'discouraged' } } -export declare namespace passkey_authenticator { - export function register(): void + +export declare namespace autostart { + export function setAutostart(autostart: boolean, params: Array): Promise } -export declare namespace logging { - export const enum LogLevel { - Trace = 0, - Debug = 1, - Info = 2, - Warn = 3, - Error = 4 + +export declare namespace autotype { + export function getForegroundWindowTitle(): string + export function typeInput(input: Array, keyboardShortcut: Array): void +} + +export declare namespace biometrics { + export function available(): Promise + /** + * Derives key material from biometric data. Returns a string encoded with a + * base64 encoded key and the base64 encoded challenge used to create it + * separated by a `|` character. + * + * If the iv is provided, it will be used as the challenge. Otherwise a random challenge will + * be generated. + * + * `format!("|")` + */ + export function deriveKeyMaterial(iv?: string | undefined | null): Promise + /** + * Retrieves the biometric secret for the given service and account. + * Throws Error with message [`passwords::PASSWORD_NOT_FOUND`] if the secret does not exist. + */ + export function getBiometricSecret(service: string, account: string, keyMaterial?: KeyMaterial | undefined | null): Promise + export interface KeyMaterial { + osKeyPartB64: string + clientKeyPartB64?: string } - export function initNapiLog(jsLogFn: (err: Error | null, arg0: LogLevel, arg1: string) => any): void + export interface OsDerivedKey { + keyB64: string + ivB64: string + } + export function prompt(hwnd: Buffer, message: string): Promise + export function setBiometricSecret(service: string, account: string, secret: string, keyMaterial: KeyMaterial | undefined | null, ivB64: string): Promise } + +export declare namespace biometrics_v2 { + export class 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 hasPersistent(biometricLockSystem: BiometricLockSystem, userId: string): Promise + export function initBiometricSystem(): BiometricLockSystem + export function provideKey(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise + export function unenroll(biometricLockSystem: BiometricLockSystem, userId: string): Promise + export function unlock(biometricLockSystem: BiometricLockSystem, userId: string, hwnd: Buffer): Promise + export function unlockAvailable(biometricLockSystem: BiometricLockSystem, userId: string): Promise +} + export declare namespace chromium_importer { - export interface ProfileInfo { - id: string - name: string - } + export function getAvailableProfiles(browser: string): Array + /** Returns OS aware metadata describing supported Chromium based importers as a JSON string. */ + export function getMetadata(): Record + export function importLogins(browser: string, profileId: string): Promise> export interface Login { url: string username: string @@ -245,12 +154,130 @@ export declare namespace chromium_importer { 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> + export interface ProfileInfo { + id: string + name: string + } } -export declare namespace autotype { - export function getForegroundWindowTitle(): string - export function typeInput(input: Array, keyboardShortcut: Array): void + +export declare namespace clipboards { + export function read(): Promise + export function write(text: string, password: boolean): Promise +} + +export declare namespace ipc { + export class NativeIpcServer { + /** + * Create and start the IPC server without blocking. + * + * @param name The endpoint name to listen on. This name uniquely identifies the IPC + * connection and must be the same for both the server and client. @param callback + * This function will be called whenever a message is received from a client. + */ + static listen(name: string, callback: (error: null | Error, message: IpcMessage) => void): Promise + /** Return the path to the IPC server. */ + getPath(): string + /** Stop the IPC server. */ + stop(): void + /** + * 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. + */ + send(message: string): number + } + export interface IpcMessage { + clientId: number + kind: IpcMessageType + message?: string + } + export const enum IpcMessageType { + Connected = 0, + Disconnected = 1, + Message = 2 + } +} + +export declare namespace logging { + export function initNapiLog(jsLogFn: ((err: Error | null, arg0: LogLevel, arg1: string) => any)): void + export const enum LogLevel { + Trace = 0, + Debug = 1, + Info = 2, + Warn = 3, + Error = 4 + } +} + +export declare namespace passkey_authenticator { + export function register(): void +} + +export declare namespace passwords { + /** + * Delete the stored password from the keychain. + * Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist. + */ + export function deletePassword(service: string, account: string): Promise + /** + * Fetch the stored password from the keychain. + * Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist. + */ + export function getPassword(service: string, account: string): Promise + /** Checks if the os secure storage is available */ + export function isAvailable(): Promise + /** The error message returned when a password is not found during retrieval or deletion. */ + export const PASSWORD_NOT_FOUND: string + /** + * 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 +} + +export declare namespace powermonitors { + export function isLockMonitorAvailable(): Promise + export function onLock(callback: ((err: Error | null, ) => any)): Promise +} + +export declare namespace processisolations { + export function disableCoredumps(): Promise + export function isCoreDumpingDisabled(): Promise + export function isolateProcess(): Promise +} + +export declare namespace sshagent { + export class SshAgentState { + + } + export function clearKeys(agentState: SshAgentState): void + export function isRunning(agentState: SshAgentState): boolean + export function lock(agentState: SshAgentState): void + export interface PrivateKey { + privateKey: string + name: string + cipherId: string + } + export function serve(callback: ((err: Error | null, arg: SshUiRequest) => Promise)): Promise + export function setKeys(agentState: SshAgentState, newKeys: Array): void + export interface SshKey { + privateKey: string + publicKey: string + keyFingerprint: string + } + export interface SshUiRequest { + cipherId?: string + isList: boolean + processName: string + isForwarding: boolean + namespace?: string + } + export function stop(agentState: SshAgentState): void +} + +export declare namespace windows_registry { + export function createKey(key: string, subkey: string, value: string): Promise + export function deleteKey(key: string, subkey: string): Promise } diff --git a/apps/desktop/desktop_native/napi/index.js b/apps/desktop/desktop_native/napi/index.js index acfd0dffb89..0362d9ee2bb 100644 --- a/apps/desktop/desktop_native/napi/index.js +++ b/apps/desktop/desktop_native/napi/index.js @@ -78,30 +78,24 @@ 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": nativeBinding = loadFirstAvailable( - ["desktop_napi.linux-x64-musl.node", "desktop_napi.linux-x64-gnu.node"], - "@bitwarden/desktop-napi-linux-x64-musl", + ["desktop_napi.linux-x64-gnu.node"], + "@bitwarden/desktop-napi-linux-x64-gnu", ); break; case "arm64": nativeBinding = loadFirstAvailable( - ["desktop_napi.linux-arm64-musl.node", "desktop_napi.linux-arm64-gnu.node"], - "@bitwarden/desktop-napi-linux-arm64-musl", + ["desktop_napi.linux-arm64-gnu.node"], + "@bitwarden/desktop-napi-linux-arm64-gnu", ); break; case "arm": nativeBinding = loadFirstAvailable( - ["desktop_napi.linux-arm-musl.node", "desktop_napi.linux-arm-gnu.node"], - "@bitwarden/desktop-napi-linux-arm-musl", + ["desktop_napi.linux-arm-gnu.node"], + "@bitwarden/desktop-napi-linux-arm-gnu", ); localFileExisted = existsSync(join(__dirname, "desktop_napi.linux-arm-gnueabihf.node")); try { diff --git a/apps/desktop/desktop_native/napi/package.json b/apps/desktop/desktop_native/napi/package.json index d557ccfd259..0717bfd53ea 100644 --- a/apps/desktop/desktop_native/napi/package.json +++ b/apps/desktop/desktop_native/napi/package.json @@ -3,27 +3,23 @@ "version": "0.1.0", "description": "", "scripts": { - "build": "napi build --platform --js false", + "build": "node scripts/build.js", "test": "cargo test" }, "author": "", "license": "GPL-3.0", "devDependencies": { - "@napi-rs/cli": "2.18.4" + "@napi-rs/cli": "3.2.0" }, "napi": { - "name": "desktop_napi", - "triples": { - "defaults": true, - "additional": [ - "x86_64-unknown-linux-musl", - "aarch64-unknown-linux-gnu", - "i686-pc-windows-msvc", - "armv7-unknown-linux-gnueabihf", - "aarch64-apple-darwin", - "aarch64-unknown-linux-musl", - "aarch64-pc-windows-msvc" - ] - } + "binaryName": "desktop_napi", + "targets": [ + "aarch64-apple-darwin", + "aarch64-pc-windows-msvc", + "aarch64-unknown-linux-gnu", + "armv7-unknown-linux-gnueabihf", + "i686-pc-windows-msvc", + "x86_64-unknown-linux-gnu" + ] } } 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..ad24b99d2fb --- /dev/null +++ b/apps/desktop/desktop_native/napi/scripts/build.js @@ -0,0 +1,22 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { execSync } = require('child_process'); + +const args = process.argv.slice(2); + +const isRelease = args.includes('--release'); + +const argsString = args.join(' '); + +if (isRelease) { + console.log('Building release mode.'); + + execSync(`napi build --platform --no-js ${argsString}`, { stdio: 'inherit'}); + +} else { + console.log('Building debug mode.'); + + execSync(`napi build --platform --no-js ${argsString}`, { + stdio: 'inherit', + env: { ...process.env, RUST_LOG: 'debug' } + }); +} diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 39e57bd0bb5..fe084349501 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! @@ -288,7 +290,7 @@ pub mod sshagent { use napi::{ bindgen_prelude::Promise, - threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction}, + threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, }; use tokio::{self, sync::Mutex}; use tracing::error; @@ -324,13 +326,15 @@ pub mod sshagent { #[allow(clippy::unused_async)] // FIXME: Remove unused async! #[napi] pub async fn serve( - callback: ThreadsafeFunction, + callback: ThreadsafeFunction>, ) -> napi::Result { let (auth_request_tx, mut auth_request_rx) = tokio::sync::mpsc::channel::(32); let (auth_response_tx, auth_response_rx) = tokio::sync::broadcast::channel::<(u32, bool)>(32); let auth_response_tx_arc = Arc::new(Mutex::new(auth_response_tx)); + // Wrap callback in Arc so it can be shared across spawned tasks + let callback = Arc::new(callback); tokio::spawn(async move { let _ = auth_response_rx; @@ -340,42 +344,50 @@ pub mod sshagent { tokio::spawn(async move { let auth_response_tx_arc = cloned_response_tx_arc; let callback = cloned_callback; - let promise_result: Result, napi::Error> = callback - .call_async(Ok(SshUIRequest { + // In NAPI v3, obtain the JS callback return as a Promise and await it + // in Rust + let (tx, rx) = std::sync::mpsc::channel::>(); + let status = callback.call_with_return_value( + Ok(SshUIRequest { cipher_id: request.cipher_id, is_list: request.is_list, process_name: request.process_name, is_forwarding: request.is_forwarding, namespace: request.namespace, - })) - .await; - match promise_result { - Ok(promise_result) => match promise_result.await { - Ok(result) => { - let _ = auth_response_tx_arc - .lock() - .await - .send((request.request_id, result)) - .expect("should be able to send auth response to agent"); - } - Err(e) => { - error!(error = %e, "Calling UI callback promise was rejected"); - let _ = auth_response_tx_arc - .lock() - .await - .send((request.request_id, false)) - .expect("should be able to send auth response to agent"); + }), + ThreadsafeFunctionCallMode::Blocking, + move |ret: Result, napi::Error>, _env| { + if let Ok(p) = ret { + let _ = tx.send(p); } + Ok(()) }, - Err(e) => { - error!(error = %e, "Calling UI callback could not create promise"); - let _ = auth_response_tx_arc - .lock() - .await - .send((request.request_id, false)) - .expect("should be able to send auth response to agent"); + ); + + let result = if status == napi::Status::Ok { + match rx.recv() { + Ok(promise) => match promise.await { + Ok(v) => v, + Err(e) => { + error!(error = %e, "UI callback promise rejected"); + false + } + }, + Err(e) => { + error!(error = %e, "Failed to receive UI callback promise"); + false + } } - } + } else { + error!(error = ?status, "Calling UI callback failed"); + false + }; + + let _ = auth_response_tx_arc + .lock() + .await + .send((request.request_id, result)) + .expect("should be able to send auth response to agent"); }); } }); @@ -463,14 +475,12 @@ pub mod processisolations { #[napi] pub mod powermonitors { use napi::{ - threadsafe_function::{ - ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode, - }, + threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, tokio, }; #[napi] - pub async fn on_lock(callback: ThreadsafeFunction<(), CalleeHandled>) -> napi::Result<()> { + pub async fn on_lock(callback: ThreadsafeFunction<()>) -> napi::Result<()> { let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(32); desktop_core::powermonitor::on_lock(tx) .await @@ -509,9 +519,7 @@ pub mod windows_registry { #[napi] pub mod ipc { use desktop_core::ipc::server::{Message, MessageType}; - use napi::threadsafe_function::{ - ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode, - }; + use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; #[napi(object)] pub struct IpcMessage { @@ -548,22 +556,23 @@ pub mod ipc { } #[napi] - pub struct IpcServer { + pub struct NativeIpcServer { server: desktop_core::ipc::server::Server, } #[napi] - impl IpcServer { + impl NativeIpcServer { /// Create and start the IPC server without blocking. /// - /// @param name The endpoint name to listen on. This name uniquely identifies the IPC connection and must be the same for both the server and client. - /// @param callback This function will be called whenever a message is received from a client. + /// @param name The endpoint name to listen on. This name uniquely identifies the IPC + /// connection and must be the same for both the server and client. @param callback + /// This function will be called whenever a message is received from a client. #[allow(clippy::unused_async)] // FIXME: Remove unused async! #[napi(factory)] pub async fn listen( name: String, #[napi(ts_arg_type = "(error: null | Error, message: IpcMessage) => void")] - callback: ThreadsafeFunction, + callback: ThreadsafeFunction, ) -> napi::Result { let (send, mut recv) = tokio::sync::mpsc::channel::(32); tokio::spawn(async move { @@ -580,7 +589,7 @@ pub mod ipc { )) })?; - Ok(IpcServer { server }) + Ok(NativeIpcServer { server }) } /// Return the path to the IPC server. @@ -598,8 +607,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 @@ -626,8 +636,9 @@ pub mod autostart { #[napi] pub mod autofill { use desktop_core::ipc::server::{Message, MessageType}; - use napi::threadsafe_function::{ - ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode, + use napi::{ + bindgen_prelude::FnArgs, + threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, }; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use tracing::error; @@ -682,6 +693,7 @@ pub mod autofill { pub user_verification: UserVerification, pub supported_algorithms: Vec, pub window_xy: Position, + pub excluded_credentials: Vec>, } #[napi(object)] @@ -720,6 +732,14 @@ pub mod autofill { pub window_xy: Position, } + #[napi(object)] + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct NativeStatus { + pub key: String, + pub value: String, + } + #[napi(object)] #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -733,18 +753,19 @@ pub mod autofill { } #[napi] - pub struct IpcServer { + pub struct AutofillIpcServer { server: desktop_core::ipc::server::Server, } // FIXME: Remove unwraps! They panic and terminate the whole application. #[allow(clippy::unwrap_used)] #[napi] - impl IpcServer { + impl AutofillIpcServer { /// Create and start the IPC server without blocking. /// - /// @param name The endpoint name to listen on. This name uniquely identifies the IPC connection and must be the same for both the server and client. - /// @param callback This function will be called whenever a message is received from a client. + /// @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( @@ -755,23 +776,24 @@ pub mod autofill { ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void" )] registration_callback: ThreadsafeFunction< - (u32, u32, PasskeyRegistrationRequest), - ErrorStrategy::CalleeHandled, + FnArgs<(u32, u32, PasskeyRegistrationRequest)>, >, #[napi( ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void" )] assertion_callback: ThreadsafeFunction< - (u32, u32, PasskeyAssertionRequest), - ErrorStrategy::CalleeHandled, + FnArgs<(u32, u32, PasskeyAssertionRequest)>, >, #[napi( ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void" )] assertion_without_user_interface_callback: ThreadsafeFunction< - (u32, u32, PasskeyAssertionWithoutUserInterfaceRequest), - ErrorStrategy::CalleeHandled, + FnArgs<(u32, u32, PasskeyAssertionWithoutUserInterfaceRequest)>, >, + #[napi( + ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void" + )] + native_status_callback: ThreadsafeFunction<(u32, u32, NativeStatus)>, ) -> napi::Result { let (send, mut recv) = tokio::sync::mpsc::channel::(32); tokio::spawn(async move { @@ -796,7 +818,7 @@ pub mod autofill { Ok(msg) => { let value = msg .value - .map(|value| (client_id, msg.sequence_number, value)) + .map(|value| (client_id, msg.sequence_number, value).into()) .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); assertion_callback @@ -815,7 +837,7 @@ pub mod autofill { Ok(msg) => { let value = msg .value - .map(|value| (client_id, msg.sequence_number, value)) + .map(|value| (client_id, msg.sequence_number, value).into()) .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); assertion_without_user_interface_callback @@ -833,7 +855,7 @@ pub mod autofill { Ok(msg) => { let value = msg .value - .map(|value| (client_id, msg.sequence_number, value)) + .map(|value| (client_id, msg.sequence_number, value).into()) .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); registration_callback .call(value, ThreadsafeFunctionCallMode::NonBlocking); @@ -844,6 +866,21 @@ pub mod autofill { } } + match serde_json::from_str::>(&message) { + Ok(msg) => { + let value = msg + .value + .map(|value| (client_id, msg.sequence_number, value)) + .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); + native_status_callback + .call(value, ThreadsafeFunctionCallMode::NonBlocking); + continue; + } + Err(error) => { + error!(%error, "Unable to deserialze native status."); + } + } + error!(message, "Received an unknown message2"); } } @@ -858,7 +895,7 @@ pub mod autofill { )) })?; - Ok(IpcServer { server }) + Ok(AutofillIpcServer { server }) } /// Return the path to the IPC server. @@ -946,24 +983,25 @@ 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 napi::{ + bindgen_prelude::FnArgs, + threadsafe_function::{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, }; - struct JsLogger(OnceLock>); + struct JsLogger(OnceLock>>); static JS_LOGGER: JsLogger = JsLogger(OnceLock::new()); #[napi] @@ -1035,18 +1073,26 @@ pub mod logging { let msg = (event.metadata().level().into(), buffer); if let Some(logger) = JS_LOGGER.0.get() { - let _ = logger.call(Ok(msg), ThreadsafeFunctionCallMode::NonBlocking); + let _ = logger.call(Ok(msg.into()), ThreadsafeFunctionCallMode::NonBlocking); }; } } #[napi] - pub fn init_napi_log(js_log_fn: ThreadsafeFunction<(LogLevel, String), CalleeHandled>) { + pub fn init_napi_log(js_log_fn: ThreadsafeFunction>) { let _ = JS_LOGGER.0.set(js_log_fn); + // the log level hierarchy is determined by: + // - if RUST_LOG is detected at runtime + // - if RUST_LOG is provided at compile time + // - default to INFO let filter = EnvFilter::builder() - // 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(); @@ -1064,6 +1110,8 @@ pub mod logging { #[napi] pub mod chromium_importer { + use std::collections::HashMap; + use chromium_importer::{ chromium::{ DefaultInstalledBrowserRetriever, LoginImportResult as _LoginImportResult, @@ -1071,7 +1119,6 @@ pub mod chromium_importer { }, metadata::NativeImporterMetadata as _NativeImporterMetadata, }; - use std::collections::HashMap; #[napi(object)] pub struct ProfileInfo { @@ -1103,8 +1150,8 @@ pub mod chromium_importer { #[napi(object)] pub struct NativeImporterMetadata { pub id: String, - pub loaders: Vec<&'static str>, - pub instructions: &'static str, + pub loaders: Vec, + pub instructions: String, } impl From<_LoginImportResult> for LoginImportResult { @@ -1181,7 +1228,7 @@ pub mod chromium_importer { #[napi] pub mod autotype { #[napi] - pub fn get_foreground_window_title() -> napi::Result { + pub fn get_foreground_window_title() -> napi::Result { autotype::get_foreground_window_title().map_err(|_| { napi::Error::from_reason( "Autotype Error: failed to get foreground window title".to_string(), diff --git a/apps/desktop/desktop_native/objc/Cargo.toml b/apps/desktop/desktop_native/objc/Cargo.toml index fc8910bddd3..dd808537c28 100644 --- a/apps/desktop/desktop_native/objc/Cargo.toml +++ b/apps/desktop/desktop_native/objc/Cargo.toml @@ -8,18 +8,14 @@ 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" -glob = "=0.3.2" +[target.'cfg(target_os = "macos")'.build-dependencies] +cc = "=1.2.49" +glob = "=0.3.3" [lints] workspace = true diff --git a/apps/desktop/desktop_native/objc/src/native/autofill/commands/sync.m b/apps/desktop/desktop_native/objc/src/native/autofill/commands/sync.m index fc13c04591a..037a97c7590 100644 --- a/apps/desktop/desktop_native/objc/src/native/autofill/commands/sync.m +++ b/apps/desktop/desktop_native/objc/src/native/autofill/commands/sync.m @@ -14,40 +14,64 @@ void runSync(void* context, NSDictionary *params) { // Map credentials to ASPasswordCredential objects NSMutableArray *mappedCredentials = [NSMutableArray arrayWithCapacity:credentials.count]; + for (NSDictionary *credential in credentials) { - NSString *type = credential[@"type"]; - - if ([type isEqualToString:@"password"]) { - NSString *cipherId = credential[@"cipherId"]; - NSString *uri = credential[@"uri"]; - NSString *username = credential[@"username"]; - - ASCredentialServiceIdentifier *serviceId = [[ASCredentialServiceIdentifier alloc] - initWithIdentifier:uri type:ASCredentialServiceIdentifierTypeURL]; - ASPasswordCredentialIdentity *credential = [[ASPasswordCredentialIdentity alloc] - initWithServiceIdentifier:serviceId user:username recordIdentifier:cipherId]; - - [mappedCredentials addObject:credential]; - } - - if (@available(macos 14, *)) { - if ([type isEqualToString:@"fido2"]) { + @try { + NSString *type = credential[@"type"]; + + if ([type isEqualToString:@"password"]) { NSString *cipherId = credential[@"cipherId"]; - NSString *rpId = credential[@"rpId"]; - NSString *userName = credential[@"userName"]; - NSData *credentialId = decodeBase64URL(credential[@"credentialId"]); - NSData *userHandle = decodeBase64URL(credential[@"userHandle"]); + NSString *uri = credential[@"uri"]; + NSString *username = credential[@"username"]; + + // Skip credentials with null username since MacOS crashes if we send credentials with empty usernames + if ([username isKindOfClass:[NSNull class]] || username.length == 0) { + NSLog(@"Skipping credential, username is empty: %@", credential); + continue; + } - Class passkeyCredentialIdentityClass = NSClassFromString(@"ASPasskeyCredentialIdentity"); - id credential = [[passkeyCredentialIdentityClass alloc] - initWithRelyingPartyIdentifier:rpId - userName:userName - credentialID:credentialId - userHandle:userHandle - recordIdentifier:cipherId]; + ASCredentialServiceIdentifier *serviceId = [[ASCredentialServiceIdentifier alloc] + initWithIdentifier:uri type:ASCredentialServiceIdentifierTypeURL]; + ASPasswordCredentialIdentity *passwordIdentity = [[ASPasswordCredentialIdentity alloc] + initWithServiceIdentifier:serviceId user:username recordIdentifier:cipherId]; - [mappedCredentials addObject:credential]; + [mappedCredentials addObject:passwordIdentity]; + } + else if (@available(macos 14, *)) { + // Fido2CredentialView uses `userName` (camelCase) while Login uses `username`. + // This is intentional. Fido2 fields are flattened from the FIDO2 spec's nested structure + // (user.name -> userName, rp.id -> rpId) to maintain a clear distinction between these fields. + if ([type isEqualToString:@"fido2"]) { + NSString *cipherId = credential[@"cipherId"]; + NSString *rpId = credential[@"rpId"]; + NSString *userName = credential[@"userName"]; + + // Skip credentials with null username since MacOS crashes if we send credentials with empty usernames + if ([userName isKindOfClass:[NSNull class]] || userName.length == 0) { + NSLog(@"Skipping credential, username is empty: %@", credential); + continue; + } + + NSData *credentialId = decodeBase64URL(credential[@"credentialId"]); + NSData *userHandle = decodeBase64URL(credential[@"userHandle"]); + + Class passkeyCredentialIdentityClass = NSClassFromString(@"ASPasskeyCredentialIdentity"); + id passkeyIdentity = [[passkeyCredentialIdentityClass alloc] + initWithRelyingPartyIdentifier:rpId + userName:userName + credentialID:credentialId + userHandle:userHandle + recordIdentifier:cipherId]; + + [mappedCredentials addObject:passkeyIdentity]; + } } + } @catch (NSException *exception) { + // Silently skip any credential that causes an exception + // to make sure we don't fail the entire sync + // There is likely some invalid data in the credential, and not something the user should/could be asked to correct. + NSLog(@"ERROR: Exception processing credential: %@ - %@", exception.name, exception.reason); + continue; } } diff --git a/apps/desktop/desktop_native/objc/src/native/utils.m b/apps/desktop/desktop_native/objc/src/native/utils.m index 040c723a8ac..8f9493a7afb 100644 --- a/apps/desktop/desktop_native/objc/src/native/utils.m +++ b/apps/desktop/desktop_native/objc/src/native/utils.m @@ -18,9 +18,26 @@ NSString *serializeJson(NSDictionary *dictionary, NSError *error) { } NSData *decodeBase64URL(NSString *base64URLString) { + if (base64URLString.length == 0) { + return nil; + } + + // Replace URL-safe characters with standard base64 characters NSString *base64String = [base64URLString stringByReplacingOccurrencesOfString:@"-" withString:@"+"]; base64String = [base64String stringByReplacingOccurrencesOfString:@"_" withString:@"/"]; - + + // Add padding if needed + // Base 64 strings should be a multiple of 4 in length + NSUInteger paddingLength = 4 - (base64String.length % 4); + if (paddingLength < 4) { + NSMutableString *paddedString = [NSMutableString stringWithString:base64String]; + for (NSUInteger i = 0; i < paddingLength; i++) { + [paddedString appendString:@"="]; + } + base64String = paddedString; + } + + // Decode the string NSData *nsdataFromBase64String = [[NSData alloc] initWithBase64EncodedString:base64String options:0]; diff --git a/apps/desktop/desktop_native/process_isolation/Cargo.toml b/apps/desktop/desktop_native/process_isolation/Cargo.toml index 170832c2fde..d8c6c7a618c 100644 --- a/apps/desktop/desktop_native/process_isolation/Cargo.toml +++ b/apps/desktop/desktop_native/process_isolation/Cargo.toml @@ -8,7 +8,7 @@ publish = { workspace = true } [lib] crate-type = ["cdylib"] -[dependencies] +[target.'cfg(target_os = "linux")'.dependencies] ctor = { workspace = true } desktop_core = { path = "../core" } libc = { workspace = true } diff --git a/apps/desktop/desktop_native/process_isolation/src/lib.rs b/apps/desktop/desktop_native/process_isolation/src/lib.rs index 850ffac841e..55c5d7fafae 100644 --- a/apps/desktop/desktop_native/process_isolation/src/lib.rs +++ b/apps/desktop/desktop_native/process_isolation/src/lib.rs @@ -5,8 +5,9 @@ //! On Linux, this is PR_SET_DUMPABLE to prevent debuggers from attaching, the env //! from being read and the memory from being stolen. -use desktop_core::process_isolation; use std::{ffi::c_char, sync::LazyLock}; + +use desktop_core::process_isolation; use tracing::info; static ORIGINAL_UNSETENV: LazyLock i32> = diff --git a/apps/desktop/desktop_native/proxy/Cargo.toml b/apps/desktop/desktop_native/proxy/Cargo.toml index c672f57543d..25682fe2aa3 100644 --- a/apps/desktop/desktop_native/proxy/Cargo.toml +++ b/apps/desktop/desktop_native/proxy/Cargo.toml @@ -6,7 +6,6 @@ version = { workspace = true } publish = { workspace = true } [dependencies] -anyhow = { workspace = true } desktop_core = { path = "../core" } futures = { workspace = true } tokio = { workspace = true, features = ["io-std", "io-util", "macros", "rt"] } diff --git a/apps/desktop/desktop_native/proxy/src/main.rs b/apps/desktop/desktop_native/proxy/src/main.rs index bad3a9deb54..a2a0b834bca 100644 --- a/apps/desktop/desktop_native/proxy/src/main.rs +++ b/apps/desktop/desktop_native/proxy/src/main.rs @@ -8,9 +8,6 @@ use tracing_subscriber::{ fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer as _, }; -#[cfg(target_os = "windows")] -mod windows; - #[cfg(target_os = "macos")] embed_plist::embed_info_plist!("../../../resources/info.desktop_proxy.plist"); @@ -60,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(); @@ -83,8 +76,10 @@ async fn main() { // 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]`). @@ -96,7 +91,8 @@ async fn main() { let args: Vec<_> = std::env::args().skip(1).collect(); 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); @@ -156,9 +152,6 @@ 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 msg = String::from_utf8(msg.to_vec()).unwrap(); 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/rust-toolchain.toml b/apps/desktop/desktop_native/rust-toolchain.toml index 898a61f3f4b..0992ce9d294 100644 --- a/apps/desktop/desktop_native/rust-toolchain.toml +++ b/apps/desktop/desktop_native/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.85.0" +channel = "1.91.1" components = [ "rustfmt", "clippy" ] profile = "minimal" 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..b38a1c725f2 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; @@ -152,7 +153,7 @@ fn add_authenticator() -> std::result::Result<(), String> { } } -type EXPERIMENTAL_WebAuthNPluginAddAuthenticatorFnDeclaration = unsafe extern "cdecl" fn( +type EXPERIMENTAL_WebAuthNPluginAddAuthenticatorFnDeclaration = unsafe extern "C" fn( pPluginAddAuthenticatorOptions: *const webauthn::ExperimentalWebAuthnPluginAddAuthenticatorOptions, ppPluginAddAuthenticatorResponse: *mut *mut webauthn::ExperimentalWebAuthnPluginAddAuthenticatorResponse, ) -> HRESULT; diff --git a/apps/desktop/electron-builder.beta.json b/apps/desktop/electron-builder.beta.json index 5b792097623..630a956560d 100644 --- a/apps/desktop/electron-builder.beta.json +++ b/apps/desktop/electron-builder.beta.json @@ -36,6 +36,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" } ] }, diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index f7dcfb65044..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.9.3", + "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" } ] }, @@ -112,7 +116,7 @@ "to": "libprocess_isolation.so" } ], - "target": ["deb", "freebsd", "rpm", "AppImage", "snap"], + "target": ["deb", "rpm", "AppImage", "snap"], "desktop": { "entry": { "Name": "Bitwarden", @@ -248,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/macos/autofill-extension/Base.lproj/CredentialProviderViewController.xib b/apps/desktop/macos/autofill-extension/Base.lproj/CredentialProviderViewController.xib index 1e47cc54de2..132882c6477 100644 --- a/apps/desktop/macos/autofill-extension/Base.lproj/CredentialProviderViewController.xib +++ b/apps/desktop/macos/autofill-extension/Base.lproj/CredentialProviderViewController.xib @@ -8,63 +8,56 @@ + + + + + diff --git a/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift b/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift index 5568b2e75db..3de9468c8ab 100644 --- a/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift +++ b/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift @@ -11,63 +11,138 @@ import os class CredentialProviderViewController: ASCredentialProviderViewController { let logger: Logger - // There is something a bit strange about the initialization/deinitialization in this class. - // Sometimes deinit won't be called after a request has successfully finished, - // which would leave this class hanging in memory and the IPC connection open. - // - // If instead I make this a static, the deinit gets called correctly after each request. - // I think we still might want a static regardless, to be able to reuse the connection if possible. - let client: MacOsProviderClient = { - let logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider") + @IBOutlet weak var statusLabel: NSTextField! + @IBOutlet weak var logoImageView: NSImageView! + + // The IPC client to communicate with the Bitwarden desktop app + private var client: MacOsProviderClient? + + // Timer for checking connection status + private var connectionMonitorTimer: Timer? + private var lastConnectionStatus: ConnectionStatus = .disconnected + + // We changed the getClient method to be async, here's why: + // This is so that we can check if the app is running, and launch it, without blocking the main thread + // Blocking the main thread caused MacOS layouting to 'fail' or at least be very delayed, which caused our getWindowPositioning code to sent 0,0. + // We also properly retry the IPC connection which sometimes would take some time to be up and running, depending on CPU load, phase of jupiters moon, etc. + private func getClient() async -> MacOsProviderClient { + if let client = self.client { + return client + } + let logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider") + // Check if the Electron app is running let workspace = NSWorkspace.shared let isRunning = workspace.runningApplications.contains { app in app.bundleIdentifier == "com.bitwarden.desktop" } - + if !isRunning { - logger.log("[autofill-extension] Bitwarden Desktop not running, attempting to launch") - - // Try to launch the app + logger.log("[autofill-extension] Bitwarden Desktop not running, attempting to launch") + + // Launch the app and wait for it to be ready if let appURL = workspace.urlForApplication(withBundleIdentifier: "com.bitwarden.desktop") { - let semaphore = DispatchSemaphore(value: 0) - - workspace.openApplication(at: appURL, - configuration: NSWorkspace.OpenConfiguration()) { app, error in - if let error = error { - logger.error("[autofill-extension] Failed to launch Bitwarden Desktop: \(error.localizedDescription)") - } else if let app = app { - logger.log("[autofill-extension] Successfully launched Bitwarden Desktop") - } else { - logger.error("[autofill-extension] Failed to launch Bitwarden Desktop: unknown error") + await withCheckedContinuation { continuation in + workspace.openApplication(at: appURL, configuration: NSWorkspace.OpenConfiguration()) { app, error in + if let error = error { + logger.error("[autofill-extension] Failed to launch Bitwarden Desktop: \(error.localizedDescription)") + } else { + logger.log("[autofill-extension] Successfully launched Bitwarden Desktop") + } + continuation.resume() } - semaphore.signal() } - - // Wait for launch completion with timeout - _ = semaphore.wait(timeout: .now() + 5.0) - - // Add a small delay to allow for initialization - Thread.sleep(forTimeInterval: 1.0) - } else { - logger.error("[autofill-extension] Could not find Bitwarden Desktop app") } - } else { - logger.log("[autofill-extension] Bitwarden Desktop is running") + } + + logger.log("[autofill-extension] Connecting to Bitwarden over IPC") + + // Retry connecting to the Bitwarden IPC with an increasing delay + let maxRetries = 20 + let delayMs = 500 + var newClient: MacOsProviderClient? + + for attempt in 1...maxRetries { + logger.log("[autofill-extension] Connection attempt \(attempt)") + + // Create a new client instance for each retry + newClient = MacOsProviderClient.connect() + try? await Task.sleep(nanoseconds: UInt64(100 * attempt + (delayMs * 1_000_000))) // Convert ms to nanoseconds + let connectionStatus = newClient!.getConnectionStatus() + + logger.log("[autofill-extension] Connection attempt \(attempt), status: \(connectionStatus == .connected ? "connected" : "disconnected")") + + if connectionStatus == .connected { + logger.log("[autofill-extension] Successfully connected to Bitwarden (attempt \(attempt))") + break + } else { + if attempt < maxRetries { + logger.log("[autofill-extension] Retrying connection") + } else { + logger.error("[autofill-extension] Failed to connect after \(maxRetries) attempts, final status: \(connectionStatus == .connected ? "connected" : "disconnected")") + } + } } - logger.log("[autofill-extension] Connecting to Bitwarden over IPC") - - return MacOsProviderClient.connect() - }() + self.client = newClient + return newClient! + } + + // Setup the connection monitoring timer + private func setupConnectionMonitoring() { + // Check connection status every 1 second + connectionMonitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.checkConnectionStatus() + } + + // Make sure timer runs even when UI is busy + RunLoop.current.add(connectionMonitorTimer!, forMode: .common) + + // Initial check + checkConnectionStatus() + } + + // Check the connection status by calling into Rust + // If the connection is has changed and is now disconnected, cancel the request + private func checkConnectionStatus() { + // Only check connection status if the client has been initialized. + // Initialization is done asynchronously, so we might be called before it's ready + // In that case we just skip this check and wait for the next timer tick and re-check + guard let client = self.client else { + return + } + + // Get the current connection status from Rust + let currentStatus = client.getConnectionStatus() + + // Only post notification if state changed + if currentStatus != lastConnectionStatus { + if(currentStatus == .connected) { + logger.log("[autofill-extension] Connection status changed: Connected") + } else { + logger.log("[autofill-extension] Connection status changed: Disconnected") + } + + // Save the new status + lastConnectionStatus = currentStatus + + // If we just disconnected, try to cancel the request + if currentStatus == .disconnected { + self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Bitwarden desktop app disconnected")) + } + } + } init() { logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider") logger.log("[autofill-extension] initializing extension") - super.init(nibName: nil, bundle: nil) + super.init(nibName: "CredentialProviderViewController", bundle: nil) + + // Setup connection monitoring now that self is available + setupConnectionMonitoring() } required init?(coder: NSCoder) { @@ -76,45 +151,109 @@ class CredentialProviderViewController: ASCredentialProviderViewController { deinit { logger.log("[autofill-extension] deinitializing extension") - } - - - @IBAction func cancel(_ sender: AnyObject?) { - self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue)) - } - - @IBAction func passwordSelected(_ sender: AnyObject?) { - let passwordCredential = ASPasswordCredential(user: "j_appleseed", password: "apple1234") - self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil) - } - - private func getWindowPosition() -> Position { - let frame = self.view.window?.frame ?? .zero - let screenHeight = NSScreen.main?.frame.height ?? 0 - // frame.width and frame.height is always 0. Estimating works OK for now. - let estimatedWidth:CGFloat = 400; - let estimatedHeight:CGFloat = 200; - let centerX = Int32(round(frame.origin.x + estimatedWidth/2)) - let centerY = Int32(round(screenHeight - (frame.origin.y + estimatedHeight/2))) - - return Position(x: centerX, y:centerY) + // Stop the connection monitor timer + connectionMonitorTimer?.invalidate() + connectionMonitorTimer = nil } - override func loadView() { - let view = NSView() - // Hide the native window since we only need the IPC connection - view.isHidden = true - self.view = view + private func getWindowPosition() async -> Position { + let screenHeight = NSScreen.main?.frame.height ?? 1440 + + logger.log("[autofill-extension] position: Getting window position") + + // To whomever is reading this. Sorry. But MacOS couldn't give us an accurate window positioning, possibly due to animations + // So I added some retry logic, as well as a fall back to the mouse position which is likely at the sort of the right place. + // In my testing we often succed after 4-7 attempts. + // Wait for window frame to stabilize (animation to complete) + var lastFrame: CGRect = .zero + var stableCount = 0 + let requiredStableChecks = 3 + let maxAttempts = 20 + var attempts = 0 + + while stableCount < requiredStableChecks && attempts < maxAttempts { + let currentFrame: CGRect = self.view.window?.frame ?? .zero + + if currentFrame.equalTo(lastFrame) && !currentFrame.equalTo(.zero) { + stableCount += 1 + } else { + stableCount = 0 + lastFrame = currentFrame + } + + try? await Task.sleep(nanoseconds: 16_666_666) // ~60fps (16.67ms) + attempts += 1 + } + + let finalWindowFrame = self.view.window?.frame ?? .zero + logger.log("[autofill-extension] position: Final window frame: \(NSStringFromRect(finalWindowFrame))") + + // Use stabilized window frame if available, otherwise fallback to mouse position + if finalWindowFrame.origin.x != 0 || finalWindowFrame.origin.y != 0 { + let centerX = Int32(round(finalWindowFrame.origin.x)) + let centerY = Int32(round(screenHeight - finalWindowFrame.origin.y)) + logger.log("[autofill-extension] position: Using window position: x=\(centerX), y=\(centerY)") + return Position(x: centerX, y: centerY) + } else { + // Fallback to mouse position + let mouseLocation = NSEvent.mouseLocation + let mouseX = Int32(round(mouseLocation.x)) + let mouseY = Int32(round(screenHeight - mouseLocation.y)) + logger.log("[autofill-extension] position: Using mouse position fallback: x=\(mouseX), y=\(mouseY)") + return Position(x: mouseX, y: mouseY) + } } - + + override func viewDidLoad() { + super.viewDidLoad() + + // Initially hide the view + self.view.isHidden = true + } + + override func prepareInterfaceForExtensionConfiguration() { + // Show the configuration UI + self.view.isHidden = false + + // Set the localized message + statusLabel.stringValue = NSLocalizedString("autofillConfigurationMessage", comment: "Message shown when Bitwarden is enabled in system settings") + + // Send the native status request asynchronously + Task { + let client = await getClient() + client.sendNativeStatus(key: "request-sync", value: "") + } + + // Complete the configuration after 2 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.extensionContext.completeExtensionConfigurationRequest() + } + } + + /* + In order to implement this method, we need to query the state of the vault to be unlocked and have one and only one matching credential so that it doesn't need to show ui. + If we do show UI, it's going to fail and disconnect after the platform timeout which is 3s. + For now we just claim to always need UI displayed. + */ override func provideCredentialWithoutUserInteraction(for credentialRequest: any ASCredentialRequest) { + let error = ASExtensionError(.userInteractionRequired) + self.extensionContext.cancelRequest(withError: error) + return + } + + /* + Implement this method if provideCredentialWithoutUserInteraction(for:) can fail with + ASExtensionError.userInteractionRequired. In this case, the system may present your extension's + UI and call this method. Show appropriate UI for authenticating the user then provide the password + by completing the extension request with the associated ASPasswordCredential. + */ + override func prepareInterfaceToProvideCredential(for credentialRequest: ASCredentialRequest) { let timeoutTimer = createTimer() - if let request = credentialRequest as? ASPasskeyCredentialRequest { if let passkeyIdentity = request.credentialIdentity as? ASPasskeyCredentialIdentity { - - logger.log("[autofill-extension] provideCredentialWithoutUserInteraction2(passkey) called \(request)") + + logger.log("[autofill-extension] prepareInterfaceToProvideCredential (passkey) called \(request)") class CallbackImpl: PreparePasskeyAssertionCallback { let ctx: ASCredentialProviderExtensionContext @@ -154,18 +293,25 @@ class CredentialProviderViewController: ASCredentialProviderViewController { UserVerification.discouraged } - let req = PasskeyAssertionWithoutUserInterfaceRequest( - rpId: passkeyIdentity.relyingPartyIdentifier, - credentialId: passkeyIdentity.credentialID, - userName: passkeyIdentity.userName, - userHandle: passkeyIdentity.userHandle, - recordIdentifier: passkeyIdentity.recordIdentifier, - clientDataHash: request.clientDataHash, - userVerification: userVerification, - windowXy: self.getWindowPosition() - ) - - self.client.preparePasskeyAssertionWithoutUserInterface(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer)) + /* + We're still using the old request type here, because we're sending the same data, we're expecting a single credential to be used + */ + Task { + let windowPosition = await self.getWindowPosition() + let req = PasskeyAssertionWithoutUserInterfaceRequest( + rpId: passkeyIdentity.relyingPartyIdentifier, + credentialId: passkeyIdentity.credentialID, + userName: passkeyIdentity.userName, + userHandle: passkeyIdentity.userHandle, + recordIdentifier: passkeyIdentity.recordIdentifier, + clientDataHash: request.clientDataHash, + userVerification: userVerification, + windowXy: windowPosition + ) + + let client = await getClient() + client.preparePasskeyAssertionWithoutUserInterface(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer)) + } return } } @@ -176,16 +322,6 @@ class CredentialProviderViewController: ASCredentialProviderViewController { self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Invalid authentication request")) } - /* - Implement this method if provideCredentialWithoutUserInteraction(for:) can fail with - ASExtensionError.userInteractionRequired. In this case, the system may present your extension's - UI and call this method. Show appropriate UI for authenticating the user then provide the password - by completing the extension request with the associated ASPasswordCredential. - - override func prepareInterfaceToProvideCredential(for credentialIdentity: ASPasswordCredentialIdentity) { - } - */ - private func createTimer() -> DispatchWorkItem { // Create a timer for 600 second timeout let timeoutTimer = DispatchWorkItem { [weak self] in @@ -246,18 +382,32 @@ class CredentialProviderViewController: ASCredentialProviderViewController { UserVerification.discouraged } - let req = PasskeyRegistrationRequest( - rpId: passkeyIdentity.relyingPartyIdentifier, - userName: passkeyIdentity.userName, - userHandle: passkeyIdentity.userHandle, - clientDataHash: request.clientDataHash, - userVerification: userVerification, - supportedAlgorithms: request.supportedAlgorithms.map{ Int32($0.rawValue) }, - windowXy: self.getWindowPosition() - ) + // Convert excluded credentials to an array of credential IDs + var excludedCredentialIds: [Data] = [] + if #available(macOSApplicationExtension 15.0, *) { + if let excludedCreds = request.excludedCredentials { + excludedCredentialIds = excludedCreds.map { $0.credentialID } + } + } + logger.log("[autofill-extension] prepareInterface(passkey) calling preparePasskeyRegistration") - self.client.preparePasskeyRegistration(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer)) + Task { + let windowPosition = await self.getWindowPosition() + let req = PasskeyRegistrationRequest( + rpId: passkeyIdentity.relyingPartyIdentifier, + userName: passkeyIdentity.userName, + userHandle: passkeyIdentity.userHandle, + clientDataHash: request.clientDataHash, + userVerification: userVerification, + supportedAlgorithms: request.supportedAlgorithms.map{ Int32($0.rawValue) }, + windowXy: windowPosition, + excludedCredentials: excludedCredentialIds + ) + + let client = await getClient() + client.preparePasskeyRegistration(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer)) + } return } } @@ -310,18 +460,22 @@ class CredentialProviderViewController: ASCredentialProviderViewController { UserVerification.discouraged } - let req = PasskeyAssertionRequest( - rpId: requestParameters.relyingPartyIdentifier, - clientDataHash: requestParameters.clientDataHash, - userVerification: userVerification, - allowedCredentials: requestParameters.allowedCredentials, - windowXy: self.getWindowPosition() - //extensionInput: requestParameters.extensionInput, - ) - let timeoutTimer = createTimer() - self.client.preparePasskeyAssertion(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer)) + Task { + let windowPosition = await self.getWindowPosition() + let req = PasskeyAssertionRequest( + rpId: requestParameters.relyingPartyIdentifier, + clientDataHash: requestParameters.clientDataHash, + userVerification: userVerification, + allowedCredentials: requestParameters.allowedCredentials, + windowXy: windowPosition + //extensionInput: requestParameters.extensionInput, // We don't support extensions yet + ) + + let client = await getClient() + client.preparePasskeyAssertion(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer)) + } return } } diff --git a/apps/desktop/macos/autofill-extension/Info.plist b/apps/desktop/macos/autofill-extension/Info.plist index 539cfa35b9d..7de0d4d152b 100644 --- a/apps/desktop/macos/autofill-extension/Info.plist +++ b/apps/desktop/macos/autofill-extension/Info.plist @@ -10,9 +10,9 @@ ProvidesPasskeys + ShowsConfigurationUI + - ASCredentialProviderExtensionShowsConfigurationUI - NSExtensionPointIdentifier com.apple.authentication-services-credential-provider-ui diff --git a/apps/desktop/macos/autofill-extension/autofill_extension.entitlements b/apps/desktop/macos/autofill-extension/autofill_extension.entitlements index 86c7195768e..d5c7b8a2cc8 100644 --- a/apps/desktop/macos/autofill-extension/autofill_extension.entitlements +++ b/apps/desktop/macos/autofill-extension/autofill_extension.entitlements @@ -2,11 +2,9 @@ - com.apple.developer.authentication-services.autofill-credential-provider - - com.apple.security.app-sandbox - - com.apple.security.application-groups + com.apple.security.app-sandbox + + com.apple.security.application-groups LTZ2PFU5D6.com.bitwarden.desktop diff --git a/apps/desktop/macos/autofill-extension/bitwarden-icon.png b/apps/desktop/macos/autofill-extension/bitwarden-icon.png new file mode 100644 index 00000000000..9a05bc7bbdd Binary files /dev/null and b/apps/desktop/macos/autofill-extension/bitwarden-icon.png differ diff --git a/apps/desktop/macos/autofill-extension/en.lproj/Localizable.strings b/apps/desktop/macos/autofill-extension/en.lproj/Localizable.strings new file mode 100644 index 00000000000..95730dff286 --- /dev/null +++ b/apps/desktop/macos/autofill-extension/en.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Message shown during passkey configuration */ +"autofillConfigurationMessage" = "Enabling Bitwarden..."; diff --git a/apps/desktop/macos/desktop.xcodeproj/project.pbxproj b/apps/desktop/macos/desktop.xcodeproj/project.pbxproj index ff257097f26..ed19fc9ef5d 100644 --- a/apps/desktop/macos/desktop.xcodeproj/project.pbxproj +++ b/apps/desktop/macos/desktop.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 3368DB392C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */; }; 3368DB3B2C654F3800896B75 /* BitwardenMacosProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */; }; + 9AE299092DF9D82E00AAE454 /* bitwarden-icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */; }; + 9AE299122DFB57A200AAE454 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 9AE2990D2DFB57A200AAE454 /* Localizable.strings */; }; E1DF713F2B342F6900F29026 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1DF713E2B342F6900F29026 /* AuthenticationServices.framework */; }; E1DF71422B342F6900F29026 /* CredentialProviderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DF71412B342F6900F29026 /* CredentialProviderViewController.swift */; }; E1DF71452B342F6900F29026 /* CredentialProviderViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */; }; @@ -18,6 +20,8 @@ 3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = BitwardenMacosProviderFFI.xcframework; path = ../desktop_native/macos_provider/BitwardenMacosProviderFFI.xcframework; sourceTree = ""; }; 3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BitwardenMacosProvider.swift; sourceTree = ""; }; 968ED08A2C52A47200FFFEE6 /* ReleaseAppStore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ReleaseAppStore.xcconfig; sourceTree = ""; }; + 9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "bitwarden-icon.png"; sourceTree = ""; }; + 9AE2990C2DFB57A200AAE454 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Localizable.strings; sourceTree = ""; }; D83832AB2D67B9AE003FB9F8 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; D83832AD2D67B9D0003FB9F8 /* ReleaseDeveloper.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ReleaseDeveloper.xcconfig; sourceTree = ""; }; E1DF713C2B342F6900F29026 /* autofill-extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "autofill-extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -41,6 +45,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 9AE2990E2DFB57A200AAE454 /* en.lproj */ = { + isa = PBXGroup; + children = ( + 9AE2990D2DFB57A200AAE454 /* Localizable.strings */, + ); + path = en.lproj; + sourceTree = ""; + }; E1DF711D2B342E2800F29026 = { isa = PBXGroup; children = ( @@ -73,6 +85,8 @@ E1DF71402B342F6900F29026 /* autofill-extension */ = { isa = PBXGroup; children = ( + 9AE2990E2DFB57A200AAE454 /* en.lproj */, + 9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */, 3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */, E1DF71412B342F6900F29026 /* CredentialProviderViewController.swift */, E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */, @@ -124,6 +138,7 @@ knownRegions = ( en, Base, + sv, ); mainGroup = E1DF711D2B342E2800F29026; productRefGroup = E1DF71272B342E2800F29026 /* Products */; @@ -141,6 +156,8 @@ buildActionMask = 2147483647; files = ( E1DF71452B342F6900F29026 /* CredentialProviderViewController.xib in Resources */, + 9AE299122DFB57A200AAE454 /* Localizable.strings in Resources */, + 9AE299092DF9D82E00AAE454 /* bitwarden-icon.png in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -159,6 +176,14 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ + 9AE2990D2DFB57A200AAE454 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 9AE2990C2DFB57A200AAE454 /* en */, + ); + name = Localizable.strings; + sourceTree = ""; + }; E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */ = { isa = PBXVariantGroup; children = ( diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json index b6e402a3ef6..1f4a56de18a 100644 --- a/apps/desktop/native-messaging-test-runner/package-lock.json +++ b/apps/desktop/native-messaging-test-runner/package-lock.json @@ -19,7 +19,7 @@ "yargs": "18.0.0" }, "devDependencies": { - "@types/node": "22.18.11", + "@types/node": "22.19.2", "typescript": "5.4.2" } }, @@ -117,9 +117,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.18.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.11.tgz", - "integrity": "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==", + "version": "22.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz", + "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==", "license": "MIT", "peer": true, "dependencies": { diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json index 285997f6482..83e9f01afed 100644 --- a/apps/desktop/native-messaging-test-runner/package.json +++ b/apps/desktop/native-messaging-test-runner/package.json @@ -24,7 +24,7 @@ "yargs": "18.0.0" }, "devDependencies": { - "@types/node": "22.18.11", + "@types/node": "22.19.2", "typescript": "5.4.2" }, "_moduleAliases": { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 19ab9e783d4..97ab8585a69 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.10.2", + "version": "2025.12.1", "keywords": [ "bitwarden", "password", @@ -18,6 +18,7 @@ "scripts": { "postinstall": "electron-rebuild", "start": "cross-env ELECTRON_IS_DEV=0 ELECTRON_NO_UPDATER=1 electron ./build", + "build-native-macos": "cd desktop_native && ./macos_provider/build.sh && node build.js cross-platform", "build-native": "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\"", @@ -39,15 +40,14 @@ "clean:dist": "rimraf ./dist", "pack:dir": "npm run clean:dist && electron-builder --dir -p never", "pack:lin:flatpak": "flatpak-builder --repo=../../.flatpak-repo ../../.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ../../.flatpak-repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop", - "pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/", - "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": "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", - "pack:mac:mas": "npm run clean:dist && electron-builder --mac mas --universal -p never", - "pack:mac:mas:with-extension": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never", - "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:mac:mas": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never", + "pack:mac:masdev": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never", + "pack:local:mac": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never -c.mac.provisioningProfile=\"\" -c.mas.provisioningProfile=\"\"", "pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", "pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", "pack:win:ci": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never", @@ -55,11 +55,8 @@ "dist:lin": "npm run build && npm run pack:lin", "dist:lin:arm64": "npm run build && npm run pack:lin:arm64", "dist:mac": "npm run build && npm run pack:mac", - "dist:mac:with-extension": "npm run build && npm run pack:mac:with-extension", "dist:mac:mas": "npm run build && npm run pack:mac:mas", - "dist:mac:mas:with-extension": "npm run build && npm run pack:mac:mas:with-extension", - "dist:mac:masdev": "npm run build:dev && npm run pack:mac:masdev", - "dist:mac:masdev:with-extension": "npm run build:dev && npm run pack:mac:masdev:with-extension", + "dist:mac:masdev": "npm run build && npm run pack:mac:masdev", "dist:win": "npm run build && npm run pack:win", "dist:win:ci": "npm run build && npm run pack:win:ci", "publish:lin": "npm run build && npm run clean:dist && electron-builder --linux --x64 -p always", diff --git a/apps/desktop/resources/entitlements.mac.plist b/apps/desktop/resources/entitlements.mac.plist index fe49256d71c..7763b84624d 100644 --- a/apps/desktop/resources/entitlements.mac.plist +++ b/apps/desktop/resources/entitlements.mac.plist @@ -6,8 +6,6 @@ LTZ2PFU5D6.com.bitwarden.desktop com.apple.developer.team-identifier LTZ2PFU5D6 - com.apple.developer.authentication-services.autofill-credential-provider - com.apple.security.cs.allow-jit diff --git a/apps/desktop/resources/entitlements.mas.inherit.plist b/apps/desktop/resources/entitlements.mas.inherit.plist index fca5f02d52d..7194d9409fc 100644 --- a/apps/desktop/resources/entitlements.mas.inherit.plist +++ b/apps/desktop/resources/entitlements.mas.inherit.plist @@ -4,9 +4,9 @@ com.apple.security.app-sandbox - com.apple.security.inherit - com.apple.security.cs.allow-jit + com.apple.security.inherit + diff --git a/apps/desktop/resources/entitlements.mas.plist b/apps/desktop/resources/entitlements.mas.plist index 3ebd56f0fd7..226e9827e37 100644 --- a/apps/desktop/resources/entitlements.mas.plist +++ b/apps/desktop/resources/entitlements.mas.plist @@ -6,19 +6,19 @@ LTZ2PFU5D6.com.bitwarden.desktop com.apple.developer.team-identifier LTZ2PFU5D6 - com.apple.developer.authentication-services.autofill-credential-provider - com.apple.security.app-sandbox com.apple.security.application-groups LTZ2PFU5D6.com.bitwarden.desktop - com.apple.security.network.client + com.apple.security.cs.allow-jit + + com.apple.security.device.usb com.apple.security.files.user-selected.read-write - com.apple.security.device.usb + com.apple.security.network.client com.apple.security.temporary-exception.files.home-relative-path.read-write @@ -32,10 +32,9 @@ /Library/Application Support/Microsoft Edge Beta/NativeMessagingHosts/ /Library/Application Support/Microsoft Edge Dev/NativeMessagingHosts/ /Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/ - /Library/Application Support/Vivaldi/NativeMessagingHosts/ + /Library/Application Support/Vivaldi/NativeMessagingHosts/ /Library/Application Support/Zen/NativeMessagingHosts/ + /Library/Application Support/net.imput.helium - com.apple.security.cs.allow-jit - diff --git a/apps/desktop/resources/linux-wrapper.sh b/apps/desktop/resources/linux-wrapper.sh index 50a323e1c18..3c5d16c3a3d 100644 --- a/apps/desktop/resources/linux-wrapper.sh +++ b/apps/desktop/resources/linux-wrapper.sh @@ -12,14 +12,6 @@ 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 -# If running in non-snap, add libprocess_isolation.so from app path to LD_PRELOAD -# This prevents debugger / memory dumping on all desktop processes -if [ -z "$SNAP" ] && [ -f "$APP_PATH/libprocess_isolation.so" ]; then - LIBPROCESS_ISOLATION_SO="$APP_PATH/libprocess_isolation.so" - LD_PRELOAD="$LIBPROCESS_ISOLATION_SO${LD_PRELOAD:+:$LD_PRELOAD}" - export LD_PRELOAD -fi - PARAMS="--enable-features=UseOzonePlatform,WaylandWindowDecorations --ozone-platform-hint=auto" if [ "$USE_X11" = "true" ]; then PARAMS="" diff --git a/apps/desktop/scripts/after-sign.js b/apps/desktop/scripts/after-sign.js index 7c9ad381dc2..4275ec7d051 100644 --- a/apps/desktop/scripts/after-sign.js +++ b/apps/desktop/scripts/after-sign.js @@ -16,7 +16,7 @@ async function run(context) { const appPath = `${context.appOutDir}/${appName}.app`; const macBuild = context.electronPlatformName === "darwin"; const copySafariExtension = ["darwin", "mas"].includes(context.electronPlatformName); - const copyAutofillExtension = ["darwin", "mas"].includes(context.electronPlatformName); + const copyAutofillExtension = ["darwin"].includes(context.electronPlatformName); // Disabled for mas builds let shouldResign = false; diff --git a/apps/desktop/scripts/nx-serve.js b/apps/desktop/scripts/nx-serve.js index b92a045f8e8..235691f9ce8 100644 --- a/apps/desktop/scripts/nx-serve.js +++ b/apps/desktop/scripts/nx-serve.js @@ -37,6 +37,6 @@ concurrently( { prefix: "name", outputStream: process.stdout, - killOthers: ["success", "failure"], + killOthersOn: ["success", "failure"], }, ); diff --git a/apps/desktop/scripts/start.js b/apps/desktop/scripts/start.js index 0e11ebd9083..4ffbe2eebeb 100644 --- a/apps/desktop/scripts/start.js +++ b/apps/desktop/scripts/start.js @@ -34,6 +34,6 @@ concurrently( { prefix: "name", outputStream: process.stdout, - killOthers: ["success", "failure"], + killOthersOn: ["success", "failure"], }, ); diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index e120db339d8..d5042918d2f 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 }} - + }
@@ -87,8 +101,7 @@ supportsBiometric && form.value.biometric && isWindows && - (userHasMasterPassword || (form.value.pin && userHasPinSet)) && - isWindowsV2BiometricsEnabled + (userHasMasterPassword || (form.value.pin && userHasPinSet)) " >
diff --git a/apps/desktop/src/app/accounts/settings.component.spec.ts b/apps/desktop/src/app/accounts/settings.component.spec.ts index cafc4138628..a424f230778 100644 --- a/apps/desktop/src/app/accounts/settings.component.spec.ts +++ b/apps/desktop/src/app/accounts/settings.component.spec.ts @@ -191,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,7 +302,6 @@ describe("SettingsComponent", () => { describe("windows desktop", () => { beforeEach(() => { platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop); - desktopBiometricsService.isWindowsV2BiometricsEnabled.mockResolvedValue(true); // Recreate component to apply the correct device fixture = TestBed.createComponent(SettingsComponent); @@ -449,7 +448,6 @@ describe("SettingsComponent", () => { desktopBiometricsService.hasPersistentKey.mockResolvedValue(enrolled); await component.ngOnInit(); - component.isWindowsV2BiometricsEnabled = true; component.isWindows = true; component.form.value.requireMasterPasswordOnAppRestart = true; component.userHasMasterPassword = false; @@ -558,7 +556,6 @@ describe("SettingsComponent", () => { desktopBiometricsService.hasPersistentKey.mockResolvedValue(false); await component.ngOnInit(); - component.isWindowsV2BiometricsEnabled = true; component.isWindows = true; component.form.value.requireMasterPasswordOnAppRestart = requireMasterPasswordOnAppRestart; @@ -659,6 +656,7 @@ describe("SettingsComponent", () => { describe("windows test cases", () => { beforeEach(() => { platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop); + keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey)); component.isWindows = true; component.isLinux = false; @@ -683,8 +681,6 @@ describe("SettingsComponent", () => { describe("when windows v2 biometrics is enabled", () => { beforeEach(() => { - component.isWindowsV2BiometricsEnabled = true; - keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey)); }); diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 7666e9bef1b..e3022428421 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 { + SessionTimeoutInputLegacyComponent, + 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, + SessionTimeoutInputLegacyComponent, + SessionTimeoutSettingsComponent, PermitCipherDetailsPopoverComponent, PremiumBadgeComponent, ], @@ -142,7 +148,8 @@ export class SettingsComponent implements OnInit, OnDestroy { userHasPinSet: boolean; pinEnabled$: Observable = of(true); - isWindowsV2BiometricsEnabled: boolean = false; + + consolidatedSessionTimeoutComponent$: Observable; form = this.formBuilder.group({ // Security @@ -182,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( @@ -280,12 +287,15 @@ export class SettingsComponent implements OnInit, OnDestroy { value: SshAgentPromptType.RememberUntilLock, }, ]; + + this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + ); } async ngOnInit() { - this.isWindowsV2BiometricsEnabled = await this.biometricsService.isWindowsV2BiometricsEnabled(); - this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions(); + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); // Autotype is for Windows initially @@ -608,7 +618,6 @@ export class SettingsComponent implements OnInit, OnDestroy { // 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.isWindowsV2BiometricsEnabled && this.supportsBiometric && this.form.value.requireMasterPasswordOnAppRestart && this.form.value.biometric && @@ -669,14 +678,12 @@ export class SettingsComponent implements OnInit, OnDestroy { this.form.controls.autoPromptBiometrics.setValue(false); await this.biometricStateService.setPromptAutomatically(false); - if (this.isWindowsV2BiometricsEnabled) { - // 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); - } + // 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 @@ -826,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 a809a1b23a2..6077afa8c12 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, @@ -13,6 +14,7 @@ import { } from "@bitwarden/angular/auth/guards"; import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password"; import { SetInitialPasswordComponent } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.component"; +import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { DevicesIcon, RegistrationUserAddIcon, @@ -38,15 +40,25 @@ import { TwoFactorAuthGuard, NewDeviceVerificationComponent, } from "@bitwarden/auth/angular"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components"; -import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui"; +import { + LockComponent, + ConfirmKeyConnectorDomainComponent, + RemovePasswordComponent, +} from "@bitwarden/key-management-ui"; import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard"; -import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; +import { reactiveUnlockVaultGuard } from "../autofill/guards/reactive-vault-guard"; +import { Fido2CreateComponent } from "../autofill/modal/credentials/fido2-create.component"; +import { Fido2ExcludedCiphersComponent } from "../autofill/modal/credentials/fido2-excluded-ciphers.component"; +import { Fido2VaultComponent } from "../autofill/modal/credentials/fido2-vault.component"; import { VaultV2Component } from "../vault/app/vault/vault-v2.component"; +import { VaultComponent } from "../vault/app/vault-v3/vault.component"; -import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component"; +import { DesktopLayoutComponent } from "./layout/desktop-layout.component"; import { SendComponent } from "./tools/send/send.component"; +import { SendV2Component } from "./tools/send-v2/send-v2.component"; /** * Data properties acceptable for use in route objects in the desktop @@ -65,7 +77,7 @@ const routes: Routes = [ canActivate: [redirectGuard({ loggedIn: "/vault", loggedOut: "/login", locked: "/lock" })], }, { - path: "authentication-timeout", + path: AuthRoute.AuthenticationTimeout, component: AnonLayoutWrapperComponent, children: [ { @@ -81,7 +93,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "device-verification", + path: AuthRoute.NewDeviceVerification, component: AnonLayoutWrapperComponent, canActivate: [unauthGuardFn(), activeAuthGuard()], children: [{ path: "", component: NewDeviceVerificationComponent }], @@ -98,7 +110,10 @@ const routes: Routes = [ { path: "vault", component: VaultV2Component, - canActivate: [authGuard], + canActivate: [ + authGuard, + canAccessFeature(FeatureFlag.DesktopUiMigrationMilestone1, false, "new-vault", false), + ], }, { path: "send", @@ -106,24 +121,23 @@ const routes: Routes = [ canActivate: [authGuard], }, { - path: "remove-password", - component: RemovePasswordComponent, - canActivate: [authGuard], + path: "fido2-assertion", + component: Fido2VaultComponent, }, { - path: "passkeys", - component: Fido2PlaceholderComponent, + path: "fido2-creation", + component: Fido2CreateComponent, }, { - path: "passkeys", - component: Fido2PlaceholderComponent, + path: "fido2-excluded", + component: Fido2ExcludedCiphersComponent, }, { path: "", component: AnonLayoutWrapperComponent, children: [ { - path: "signup", + path: AuthRoute.SignUp, canActivate: [unauthGuardFn()], data: { pageIcon: RegistrationUserAddIcon, @@ -141,13 +155,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, @@ -160,7 +174,7 @@ const routes: Routes = [ ], }, { - path: "login", + path: AuthRoute.Login, canActivate: [maxAccountsGuardFn()], data: { pageTitle: { @@ -179,7 +193,7 @@ const routes: Routes = [ ], }, { - path: "login-initiated", + path: AuthRoute.LoginInitiated, canActivate: [tdeDecryptionRequiredGuard()], data: { pageIcon: DevicesIcon, @@ -187,7 +201,7 @@ const routes: Routes = [ children: [{ path: "", component: LoginDecryptionOptionsComponent }], }, { - path: "sso", + path: AuthRoute.Sso, data: { pageIcon: VaultIcon, pageTitle: { @@ -207,7 +221,7 @@ const routes: Routes = [ ], }, { - path: "login-with-device", + path: AuthRoute.LoginWithDevice, data: { pageIcon: DevicesIcon, pageTitle: { @@ -227,7 +241,7 @@ const routes: Routes = [ ], }, { - path: "admin-approval-requested", + path: AuthRoute.AdminApprovalRequested, data: { pageIcon: DevicesIcon, pageTitle: { @@ -240,7 +254,7 @@ const routes: Routes = [ children: [{ path: "", component: LoginViaAuthRequestComponent }], }, { - path: "hint", + path: AuthRoute.PasswordHint, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -262,7 +276,7 @@ const routes: Routes = [ }, { path: "lock", - canActivate: [lockGuard()], + canActivate: [lockGuard(), reactiveUnlockVaultGuard], data: { pageIcon: LockIcon, pageTitle: { @@ -278,7 +292,7 @@ const routes: Routes = [ ], }, { - path: "2fa", + path: AuthRoute.TwoFactor, canActivate: [unauthGuardFn(), TwoFactorAuthGuard], children: [ { @@ -295,7 +309,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "set-initial-password", + path: AuthRoute.SetInitialPassword, canActivate: [authGuard], component: SetInitialPasswordComponent, data: { @@ -304,26 +318,52 @@ const routes: Routes = [ } satisfies AnonLayoutWrapperData, }, { - path: "change-password", + path: AuthRoute.ChangePassword, component: ChangePasswordComponent, canActivate: [authGuard], data: { pageIcon: LockIcon, } satisfies AnonLayoutWrapperData, }, + { + path: "remove-password", + component: RemovePasswordComponent, + canActivate: [authGuard], + data: { + pageTitle: { + key: "verifyYourOrganization", + }, + pageIcon: LockIcon, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + }, { path: "confirm-key-connector-domain", component: ConfirmKeyConnectorDomainComponent, canActivate: [], data: { pageTitle: { - key: "confirmKeyConnectorDomain", + key: "verifyYourOrganization", }, pageIcon: DomainIcon, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, ], }, + { + path: "", + component: DesktopLayoutComponent, + canActivate: [authGuard], + children: [ + { + path: "new-vault", + component: VaultComponent, + }, + { + path: "new-sends", + component: SendV2Component, + }, + ], + }, ]; @NgModule({ diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 7f7eddcfe95..836328142b5 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"; @@ -91,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: [], @@ -101,7 +104,7 @@ const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours - +
@@ -115,17 +118,30 @@ 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; + showHeader$ = this.accountService.showHeader$; loading = false; private lastActivity: Date = null; @@ -181,6 +197,7 @@ export class AppComponent implements OnInit, OnDestroy { private pinService: PinServiceAbstraction, private readonly tokenService: TokenService, private desktopAutotypeDefaultSettingPolicy: DesktopAutotypeDefaultSettingPolicy, + private readonly lockService: LockService, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); @@ -231,7 +248,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. @@ -244,21 +261,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": @@ -272,12 +278,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(); @@ -722,8 +728,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); @@ -800,11 +804,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/app.module.ts b/apps/desktop/src/app/app.module.ts index 4f53e587994..31131c6202a 100644 --- a/apps/desktop/src/app/app.module.ts +++ b/apps/desktop/src/app/app.module.ts @@ -15,7 +15,6 @@ import { DeleteAccountComponent } from "../auth/delete-account.component"; import { LoginModule } from "../auth/login/login.module"; import { SshAgentService } from "../autofill/services/ssh-agent.service"; import { PremiumComponent } from "../billing/app/accounts/premium.component"; -import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; import { VaultFilterModule } from "../vault/app/vault/vault-filter/vault-filter.module"; import { VaultV2Component } from "../vault/app/vault/vault-v2.component"; @@ -50,7 +49,6 @@ import { SharedModule } from "./shared/shared.module"; ColorPasswordCountPipe, HeaderComponent, PremiumComponent, - RemovePasswordComponent, SearchComponent, ], providers: [SshAgentService], 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 deleted file mode 100644 index f1f52dae439..00000000000 --- a/apps/desktop/src/app/components/fido2placeholder.component.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { Router } from "@angular/router"; -import { BehaviorSubject, Observable } from "rxjs"; - -import { - DesktopFido2UserInterfaceService, - DesktopFido2UserInterfaceSession, -} 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], - template: ` -
-

Select your passkey

- -
- -
- -
- - -
- `, -}) -export class Fido2PlaceholderComponent implements OnInit, OnDestroy { - session?: DesktopFido2UserInterfaceSession = null; - private cipherIdsSubject = new BehaviorSubject([]); - cipherIds$: Observable; - - constructor( - private readonly desktopSettingsService: DesktopSettingsService, - private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService, - private readonly router: Router, - ) {} - - ngOnInit() { - this.session = this.fido2UserInterfaceService.getCurrentSession(); - this.cipherIds$ = this.session?.availableCipherIds$; - } - - async chooseCipher(cipherId: string) { - // For now: Set UV to true - this.session?.confirmChosenCipher(cipherId, true); - - await this.router.navigate(["/"]); - await this.desktopSettingsService.setModalMode(false); - } - - ngOnDestroy() { - this.cipherIdsSubject.complete(); // Clean up the BehaviorSubject - } - - async confirmPasskey() { - try { - // Retrieve the current UI session to control the flow - if (!this.session) { - // todo: handle error - throw new Error("No session found"); - } - - // If we want to we could submit information to the session in order to create the credential - // const cipher = await session.createCredential({ - // userHandle: "userHandle2", - // userName: "username2", - // credentialName: "zxsd2", - // rpId: "webauthn.io", - // userVerification: true, - // }); - - this.session.notifyConfirmNewCredential(true); - - // Not sure this clean up should happen here or in session. - // The session currently toggles modal on and send us here - // But if this route is somehow opened outside of session we want to make sure we clean up? - await this.router.navigate(["/"]); - await this.desktopSettingsService.setModalMode(false); - } catch { - // TODO: Handle error appropriately - } - } - - async closeModal() { - await this.router.navigate(["/"]); - await this.desktopSettingsService.setModalMode(false); - - this.session.notifyConfirmNewCredential(false); - // little bit hacky: - this.session.confirmChosenCipher(null); - } -} 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/desktop-layout.component.html b/apps/desktop/src/app/layout/desktop-layout.component.html new file mode 100644 index 00000000000..1717b29acd1 --- /dev/null +++ b/apps/desktop/src/app/layout/desktop-layout.component.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/desktop/src/app/layout/desktop-layout.component.spec.ts b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts new file mode 100644 index 00000000000..74cddd02495 --- /dev/null +++ b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts @@ -0,0 +1,84 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { RouterModule } from "@angular/router"; +import { mock } from "jest-mock-extended"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { NavigationModule } from "@bitwarden/components"; + +import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component"; + +import { DesktopLayoutComponent } from "./desktop-layout.component"; + +// Mock the child component to isolate DesktopLayoutComponent testing +@Component({ + selector: "app-send-filters-nav", + template: "", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockSendFiltersNavComponent {} + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: true, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +describe("DesktopLayoutComponent", () => { + let component: DesktopLayoutComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DesktopLayoutComponent, RouterModule.forRoot([]), NavigationModule], + providers: [ + { + provide: I18nService, + useValue: mock(), + }, + ], + }) + .overrideComponent(DesktopLayoutComponent, { + remove: { imports: [SendFiltersNavComponent] }, + add: { imports: [MockSendFiltersNavComponent] }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(DesktopLayoutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("creates component", () => { + expect(component).toBeTruthy(); + }); + + it("renders bit-layout component", () => { + const compiled = fixture.nativeElement; + const layoutElement = compiled.querySelector("bit-layout"); + + expect(layoutElement).toBeTruthy(); + }); + + it("supports content projection for side-nav", () => { + const compiled = fixture.nativeElement; + const ngContent = compiled.querySelectorAll("ng-content"); + + expect(ngContent).toBeTruthy(); + }); + + it("renders send filters navigation component", () => { + const compiled = fixture.nativeElement; + const sendFiltersNav = compiled.querySelector("app-send-filters-nav"); + + expect(sendFiltersNav).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/app/layout/desktop-layout.component.ts b/apps/desktop/src/app/layout/desktop-layout.component.ts new file mode 100644 index 00000000000..0ee7065fba8 --- /dev/null +++ b/apps/desktop/src/app/layout/desktop-layout.component.ts @@ -0,0 +1,28 @@ +import { Component } from "@angular/core"; +import { RouterModule } from "@angular/router"; + +import { PasswordManagerLogo } from "@bitwarden/assets/svg"; +import { LayoutComponent, NavigationModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component"; + +import { DesktopSideNavComponent } from "./desktop-side-nav.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-layout", + imports: [ + RouterModule, + I18nPipe, + LayoutComponent, + NavigationModule, + DesktopSideNavComponent, + SendFiltersNavComponent, + ], + templateUrl: "./desktop-layout.component.html", +}) +export class DesktopLayoutComponent { + protected readonly logo = PasswordManagerLogo; +} diff --git a/apps/desktop/src/app/layout/desktop-side-nav.component.html b/apps/desktop/src/app/layout/desktop-side-nav.component.html new file mode 100644 index 00000000000..ede3f9131b7 --- /dev/null +++ b/apps/desktop/src/app/layout/desktop-side-nav.component.html @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop/src/app/layout/desktop-side-nav.component.spec.ts b/apps/desktop/src/app/layout/desktop-side-nav.component.spec.ts new file mode 100644 index 00000000000..4d5c3a90253 --- /dev/null +++ b/apps/desktop/src/app/layout/desktop-side-nav.component.spec.ts @@ -0,0 +1,74 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { NavigationModule } from "@bitwarden/components"; + +import { DesktopSideNavComponent } from "./desktop-side-nav.component"; + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: true, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +describe("DesktopSideNavComponent", () => { + let component: DesktopSideNavComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DesktopSideNavComponent, NavigationModule], + providers: [ + { + provide: I18nService, + useValue: mock(), + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DesktopSideNavComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("creates component", () => { + expect(component).toBeTruthy(); + }); + + it("renders bit-side-nav component", () => { + const compiled = fixture.nativeElement; + const sideNavElement = compiled.querySelector("bit-side-nav"); + + expect(sideNavElement).toBeTruthy(); + }); + + it("uses primary variant by default", () => { + expect(component.variant()).toBe("primary"); + }); + + it("accepts variant input", () => { + fixture.componentRef.setInput("variant", "secondary"); + fixture.detectChanges(); + + expect(component.variant()).toBe("secondary"); + }); + + it.skip("passes variant to bit-side-nav", () => { + fixture.componentRef.setInput("variant", "secondary"); + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + const sideNavElement = compiled.querySelector("bit-side-nav"); + + expect(sideNavElement.getAttribute("ng-reflect-variant")).toBe("secondary"); + }); +}); diff --git a/apps/desktop/src/app/layout/desktop-side-nav.component.ts b/apps/desktop/src/app/layout/desktop-side-nav.component.ts new file mode 100644 index 00000000000..b0d9fd16fcc --- /dev/null +++ b/apps/desktop/src/app/layout/desktop-side-nav.component.ts @@ -0,0 +1,14 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; + +import { NavigationModule, SideNavVariant } from "@bitwarden/components"; + +@Component({ + selector: "app-side-nav", + templateUrl: "desktop-side-nav.component.html", + imports: [CommonModule, NavigationModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DesktopSideNavComponent { + readonly variant = input("primary"); +} 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 ae633bd4a69..17115825bf6 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -1,12 +1,11 @@ -import { DOCUMENT } from "@angular/common"; -import { Inject, Injectable } from "@angular/core"; +import { Inject, Injectable, DOCUMENT } from "@angular/core"; import { firstValueFrom } from "rxjs"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; 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"; @@ -39,7 +38,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, diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index a0ee33a459c..59021a556e4 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -51,6 +51,7 @@ import { } from "@bitwarden/common/auth/abstractions/auth.service"; import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { ClientType } from "@bitwarden/common/enums"; @@ -62,6 +63,7 @@ import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypt 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 { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service"; +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { VaultTimeoutSettingsService, VaultTimeoutStringType, @@ -77,7 +79,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"; @@ -98,6 +103,7 @@ import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/s import { SyncService } from "@bitwarden/common/platform/sync"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { GeneratorServicesModule } from "@bitwarden/generator-components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { KdfConfigService, @@ -106,7 +112,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 +131,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 { DesktopSessionTimeoutTypeService } from "../../key-management/session-timeout/services/desktop-session-timeout-type.service"; import { flagEnabled } from "../../platform/flags"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; import { ElectronLogRendererService } from "../../platform/services/electron-log.renderer.service"; @@ -158,12 +168,12 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: BiometricsService, useClass: RendererBiometricsService, - deps: [], + deps: [TokenService], }), safeProvider({ provide: DesktopBiometricsService, useClass: RendererBiometricsService, - deps: [], + deps: [TokenService], }), safeProvider(NativeMessagingService), safeProvider(BiometricMessageHandlerService), @@ -193,8 +203,16 @@ const safeProviders: SafeProvider[] = [ // We manually override the value of SUPPORTS_SECURE_STORAGE here to avoid // the TokenService having to inject the PlatformUtilsService which introduces a // circular dependency on Desktop only. + // + // For Windows portable builds, we disable secure storage to ensure tokens are + // stored on disk (in bitwarden-appdata) rather than in Windows Credential + // Manager, making them portable across machines. This allows users to move the USB drive + // between computers while maintaining authentication. + // + // Note: Portable mode does not use secure storage for read/write/clear operations, + // preventing any collision with tokens from a regular desktop installation. provide: SUPPORTS_SECURE_STORAGE, - useValue: ELECTRON_SUPPORTS_SECURE_STORAGE, + useValue: ELECTRON_SUPPORTS_SECURE_STORAGE && !ipc.platform.isWindowsPortable, }), safeProvider({ provide: DEFAULT_VAULT_TIMEOUT, @@ -262,6 +280,7 @@ const safeProviders: SafeProvider[] = [ BiometricStateService, AccountServiceAbstraction, LogService, + AuthServiceAbstraction, ], }), safeProvider({ @@ -336,6 +355,8 @@ const safeProviders: SafeProvider[] = [ ConfigService, Fido2AuthenticatorServiceAbstraction, AccountService, + AuthService, + PlatformUtilsService, ], }), safeProvider({ @@ -475,10 +496,20 @@ const safeProviders: SafeProvider[] = [ useClass: DesktopAutotypeDefaultSettingPolicy, deps: [AccountServiceAbstraction, AuthServiceAbstraction, InternalPolicyService, ConfigService], }), + safeProvider({ + provide: SessionTimeoutTypeService, + useClass: DesktopSessionTimeoutTypeService, + deps: [], + }), + safeProvider({ + provide: SessionTimeoutSettingsComponentService, + useClass: SessionTimeoutSettingsComponentService, + deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyServiceAbstraction], + }), ]; @NgModule({ - imports: [JslibServicesModule], + imports: [JslibServicesModule, GeneratorServicesModule], declarations: [], // Do not register your dependency here! Add it to the typesafeProviders array using the helper function providers: safeProviders, 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/import/import-desktop.component.html b/apps/desktop/src/app/tools/import/import-desktop.component.html index 796d61e1b69..3ee2384691b 100644 --- a/apps/desktop/src/app/tools/import/import-desktop.component.html +++ b/apps/desktop/src/app/tools/import/import-desktop.component.html @@ -1,13 +1,21 @@ {{ "importData" | i18n }} - +
+ + @if (loading) { +
+ +
+ } +
+
+
+ + + +

{{ "noItemsInList" | i18n }}

+
+
+ +
+
+ + +
diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts new file mode 100644 index 00000000000..8657f3e375e --- /dev/null +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts @@ -0,0 +1,386 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { ChangeDetectorRef } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder } from "@angular/forms"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { SendListFiltersService } from "@bitwarden/send-ui"; + +import * as utils from "../../../utils"; +import { SearchBarService } from "../../layout/search/search-bar.service"; +import { AddEditComponent } from "../send/add-edit.component"; + +import { SendV2Component } from "./send-v2.component"; + +// Mock the invokeMenu utility function +jest.mock("../../../utils", () => ({ + invokeMenu: jest.fn(), +})); + +describe("SendV2Component", () => { + let component: SendV2Component; + let fixture: ComponentFixture; + let sendService: MockProxy; + let searchBarService: MockProxy; + let broadcasterService: MockProxy; + let accountService: MockProxy; + let policyService: MockProxy; + let sendListFiltersService: SendListFiltersService; + let changeDetectorRef: MockProxy; + + beforeEach(async () => { + sendService = mock(); + searchBarService = mock(); + broadcasterService = mock(); + accountService = mock(); + policyService = mock(); + changeDetectorRef = mock(); + + // Create real SendListFiltersService with mocked dependencies + const formBuilder = new FormBuilder(); + const i18nService = mock(); + i18nService.t.mockImplementation((key: string) => key); + sendListFiltersService = new SendListFiltersService(i18nService, formBuilder); + + // Mock sendViews$ observable + sendService.sendViews$ = of([]); + searchBarService.searchText$ = new BehaviorSubject(""); + + // Mock activeAccount$ observable for parent class ngOnInit + accountService.activeAccount$ = of({ id: "test-user-id" } as any); + policyService.policyAppliesToUser$ = jest.fn().mockReturnValue(of(false)); + + // Mock SearchService methods needed by base component + const mockSearchService = mock(); + mockSearchService.isSearchable.mockResolvedValue(false); + + await TestBed.configureTestingModule({ + imports: [SendV2Component], + providers: [ + { provide: SendService, useValue: sendService }, + { provide: I18nService, useValue: mock() }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: EnvironmentService, useValue: mock() }, + { provide: BroadcasterService, useValue: broadcasterService }, + { provide: SearchService, useValue: mockSearchService }, + { provide: PolicyService, useValue: policyService }, + { provide: SearchBarService, useValue: searchBarService }, + { provide: LogService, useValue: mock() }, + { provide: SendApiService, useValue: mock() }, + { provide: DialogService, useValue: mock() }, + { provide: ToastService, useValue: mock() }, + { provide: AccountService, useValue: accountService }, + { provide: SendListFiltersService, useValue: sendListFiltersService }, + { provide: ChangeDetectorRef, useValue: changeDetectorRef }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SendV2Component); + component = fixture.componentInstance; + }); + + it("creates component", () => { + expect(component).toBeTruthy(); + }); + + it("initializes with correct default action", () => { + expect(component.action).toBe(""); + }); + + it("subscribes to broadcaster service on init", async () => { + await component.ngOnInit(); + expect(broadcasterService.subscribe).toHaveBeenCalledWith( + "SendV2Component", + expect.any(Function), + ); + }); + + it("unsubscribes from broadcaster service on destroy", () => { + component.ngOnDestroy(); + expect(broadcasterService.unsubscribe).toHaveBeenCalledWith("SendV2Component"); + }); + + it("enables search bar on init", async () => { + await component.ngOnInit(); + expect(searchBarService.setEnabled).toHaveBeenCalledWith(true); + }); + + it("disables search bar on destroy", () => { + component.ngOnDestroy(); + expect(searchBarService.setEnabled).toHaveBeenCalledWith(false); + }); + + describe("addSend", () => { + it("sets action to Add", async () => { + await component.addSend(); + expect(component.action).toBe("add"); + }); + + it("calls resetAndLoad on addEditComponent when component exists", async () => { + const mockAddEdit = mock(); + component.addEditComponent = mockAddEdit; + + await component.addSend(); + + expect(mockAddEdit.resetAndLoad).toHaveBeenCalled(); + }); + + it("does not throw when addEditComponent is null", async () => { + component.addEditComponent = null; + await expect(component.addSend()).resolves.not.toThrow(); + }); + }); + + describe("cancel", () => { + it("resets action to None", () => { + component.action = "edit"; + component.sendId = "test-id"; + + component.cancel(new SendView()); + + expect(component.action).toBe(""); + expect(component.sendId).toBeNull(); + }); + }); + + describe("deletedSend", () => { + it("refreshes the list and resets action and sendId", async () => { + component.action = "edit"; + component.sendId = "test-id"; + jest.spyOn(component, "refresh").mockResolvedValue(); + + const mockSend = new SendView(); + await component.deletedSend(mockSend); + + expect(component.refresh).toHaveBeenCalled(); + expect(component.action).toBe(""); + expect(component.sendId).toBeNull(); + }); + }); + + describe("savedSend", () => { + it("refreshes the list and selects the saved send", async () => { + jest.spyOn(component, "refresh").mockResolvedValue(); + jest.spyOn(component, "selectSend").mockResolvedValue(); + + const mockSend = new SendView(); + mockSend.id = "saved-send-id"; + + await component.savedSend(mockSend); + + expect(component.refresh).toHaveBeenCalled(); + expect(component.selectSend).toHaveBeenCalledWith("saved-send-id"); + }); + }); + + describe("selectSend", () => { + it("sets action to Edit and updates sendId", async () => { + await component.selectSend("new-send-id"); + + expect(component.action).toBe("edit"); + expect(component.sendId).toBe("new-send-id"); + }); + + it("updates addEditComponent when it exists", async () => { + const mockAddEdit = mock(); + component.addEditComponent = mockAddEdit; + + await component.selectSend("test-send-id"); + + expect(mockAddEdit.sendId).toBe("test-send-id"); + expect(mockAddEdit.refresh).toHaveBeenCalled(); + }); + + it("does not reload if same send is already selected in edit mode", async () => { + const mockAddEdit = mock(); + component.addEditComponent = mockAddEdit; + component.sendId = "same-id"; + component.action = "edit"; + + await component.selectSend("same-id"); + + expect(mockAddEdit.refresh).not.toHaveBeenCalled(); + }); + + it("reloads if selecting different send", async () => { + const mockAddEdit = mock(); + component.addEditComponent = mockAddEdit; + component.sendId = "old-id"; + component.action = "edit"; + + await component.selectSend("new-id"); + + expect(mockAddEdit.refresh).toHaveBeenCalled(); + }); + }); + + describe("selectedSendType", () => { + it("returns the type of the currently selected send", () => { + const mockSend1 = new SendView(); + mockSend1.id = "send-1"; + mockSend1.type = SendType.Text; + + const mockSend2 = new SendView(); + mockSend2.id = "send-2"; + mockSend2.type = SendType.File; + + component.sends = [mockSend1, mockSend2]; + component.sendId = "send-2"; + + expect(component.selectedSendType).toBe(SendType.File); + }); + + it("returns undefined when no send is selected", () => { + component.sends = []; + component.sendId = "non-existent"; + + expect(component.selectedSendType).toBeUndefined(); + }); + + it("returns undefined when sendId is null", () => { + const mockSend = new SendView(); + mockSend.id = "send-1"; + mockSend.type = SendType.Text; + + component.sends = [mockSend]; + component.sendId = null; + + expect(component.selectedSendType).toBeUndefined(); + }); + }); + + describe("viewSendMenu", () => { + let mockSend: SendView; + + beforeEach(() => { + mockSend = new SendView(); + mockSend.id = "test-send"; + mockSend.name = "Test Send"; + jest.clearAllMocks(); + }); + + it("creates menu with copy link option", () => { + jest.spyOn(component, "copy").mockResolvedValue(); + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + expect(menuItems.length).toBeGreaterThanOrEqual(2); // At minimum: copy link + delete + }); + + it("includes remove password option when send has password and is not disabled", () => { + mockSend.password = "test-password"; + mockSend.disabled = false; + jest.spyOn(component, "removePassword").mockResolvedValue(true); + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + expect(menuItems.length).toBe(3); // copy link + remove password + delete + }); + + it("excludes remove password option when send has no password", () => { + mockSend.password = null; + mockSend.disabled = false; + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + expect(menuItems.length).toBe(2); // copy link + delete (no remove password) + }); + + it("excludes remove password option when send is disabled", () => { + mockSend.password = "test-password"; + mockSend.disabled = true; + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + expect(menuItems.length).toBe(2); // copy link + delete (no remove password) + }); + + it("always includes delete option", () => { + jest.spyOn(component, "delete").mockResolvedValue(true); + jest.spyOn(component, "deletedSend").mockResolvedValue(); + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + // Delete is always the last item in the menu + expect(menuItems.length).toBeGreaterThan(0); + expect(menuItems[menuItems.length - 1]).toHaveProperty("label"); + expect(menuItems[menuItems.length - 1]).toHaveProperty("click"); + }); + }); + + describe("search bar subscription", () => { + it("updates searchText when search bar text changes", () => { + const searchSubject = new BehaviorSubject("initial"); + searchBarService.searchText$ = searchSubject; + + // Create new component to trigger constructor subscription + fixture = TestBed.createComponent(SendV2Component); + component = fixture.componentInstance; + + searchSubject.next("new search text"); + + expect(component.searchText).toBe("new search text"); + }); + }); + + describe("load", () => { + it("sets loading states correctly", async () => { + jest.spyOn(component, "search").mockResolvedValue(); + + expect(component.loaded).toBeFalsy(); + + await component.load(); + + expect(component.loading).toBe(false); + expect(component.loaded).toBe(true); + }); + + it("sets up sendViews$ subscription", async () => { + const mockSends = [new SendView(), new SendView()]; + sendService.sendViews$ = of(mockSends); + jest.spyOn(component, "search").mockResolvedValue(); + + await component.load(); + + // Give observable time to emit + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(component.sends).toEqual(mockSends); + }); + + it("calls onSuccessfulLoad when it is set", async () => { + jest.spyOn(component, "search").mockResolvedValue(); + const mockCallback = jest.fn().mockResolvedValue(undefined); + component.onSuccessfulLoad = mockCallback; + + await component.load(); + + expect(mockCallback).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts new file mode 100644 index 00000000000..eb0856b76af --- /dev/null +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts @@ -0,0 +1,261 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { CommonModule } from "@angular/common"; +import { Component, OnInit, OnDestroy, ViewChild, NgZone, ChangeDetectorRef } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormsModule } from "@angular/forms"; +import { mergeMap, Subscription } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { SendListFiltersService } from "@bitwarden/send-ui"; + +import { invokeMenu, RendererMenuItem } from "../../../utils"; +import { SearchBarService } from "../../layout/search/search-bar.service"; +import { AddEditComponent } from "../send/add-edit.component"; + +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 = "SendV2Component"; + +// 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-v2", + imports: [CommonModule, JslibModule, FormsModule, AddEditComponent], + templateUrl: "./send-v2.component.html", +}) +export class SendV2Component 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; + + // The ID of the currently selected Send item being viewed or edited + sendId: string; + + // Tracks the current UI state: viewing list (None), adding new Send (Add), or editing existing Send (Edit) + action: Action = Action.None; + + // Subscription for sendViews$ cleanup + private sendViewsSubscription: Subscription; + + constructor( + sendService: SendService, + i18nService: I18nService, + platformUtilsService: PlatformUtilsService, + environmentService: EnvironmentService, + private broadcasterService: BroadcasterService, + ngZone: NgZone, + searchService: SearchService, + policyService: PolicyService, + private searchBarService: SearchBarService, + logService: LogService, + sendApiService: SendApiService, + dialogService: DialogService, + toastService: ToastService, + accountService: AccountService, + private cdr: ChangeDetectorRef, + private sendListFiltersService: SendListFiltersService, + ) { + super( + sendService, + i18nService, + platformUtilsService, + environmentService, + ngZone, + searchService, + policyService, + logService, + sendApiService, + dialogService, + toastService, + accountService, + ); + + // Listen to search bar changes and update the Send list filter + this.searchBarService.searchText$.pipe(takeUntilDestroyed()).subscribe((searchText) => { + this.searchText = searchText; + this.searchTextChanged(); + }); + + // Listen to filter changes from sidebar navigation + this.sendListFiltersService.filterForm.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe((filters) => { + this.applySendTypeFilter(filters); + }); + } + + // Initialize the component: enable search bar, subscribe to sync events, and load Send items + async ngOnInit() { + this.searchBarService.setEnabled(true); + this.searchBarService.setPlaceholderText(this.i18nService.t("searchSends")); + + await super.ngOnInit(); + + // Read current filter synchronously to avoid race condition on navigation + const currentFilter = this.sendListFiltersService.filterForm.value; + this.applySendTypeFilter(currentFilter); + + // Listen for sync completion events to refresh the Send list + 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": + await this.load(); + break; + } + }); + }); + await this.load(); + } + + // Apply send type filter to display: centralized logic for initial load and filter changes + private applySendTypeFilter(filters: Partial<{ sendType: SendType | null }>): void { + if (filters.sendType === null || filters.sendType === undefined) { + this.selectAll(); + } else { + this.selectType(filters.sendType); + } + } + + // Clean up subscriptions and disable search bar when component is destroyed + ngOnDestroy() { + this.sendViewsSubscription?.unsubscribe(); + this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); + this.searchBarService.setEnabled(false); + } + + // Load Send items from the service and display them in the list. + // Subscribes to sendViews$ observable to get updates when Sends change. + // Manually triggers change detection to ensure UI updates immediately. + // Note: The filter parameter is ignored in this implementation for desktop-specific behavior. + async load(filter: (send: SendView) => boolean = null) { + this.loading = true; + + // Recreate subscription on each load (required for sync refresh) + // Manual cleanup in ngOnDestroy is intentional - load() is called multiple times + this.sendViewsSubscription?.unsubscribe(); + + this.sendViewsSubscription = this.sendService.sendViews$ + .pipe( + mergeMap(async (sends) => { + this.sends = sends; + await this.search(null); + // Trigger change detection after data updates + this.cdr.detectChanges(); + }), + ) + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + .subscribe(); + if (this.onSuccessfulLoad != null) { + await this.onSuccessfulLoad(); + } + this.loading = false; + this.loaded = true; + } + + // Open the add Send form to create a new Send item + async addSend() { + this.action = Action.Add; + if (this.addEditComponent != null) { + await this.addEditComponent.resetAndLoad(); + } + } + + // Close the add/edit form and return to the list view + cancel(s: SendView) { + this.action = Action.None; + this.sendId = null; + } + + // Handle when a Send is deleted: refresh the list and close the edit form + async deletedSend(s: SendView) { + await this.refresh(); + this.action = Action.None; + this.sendId = null; + } + + // Handle when a Send is saved: refresh the list and re-select the saved Send + async savedSend(s: SendView) { + await this.refresh(); + // 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.selectSend(s.id); + } + + // Select a Send from the list and open it in the edit form. + // If the same Send is already selected and in edit mode, do nothing to avoid unnecessary reloads. + async selectSend(sendId: string) { + if (sendId === this.sendId && this.action === Action.Edit) { + return; + } + this.action = Action.Edit; + this.sendId = sendId; + if (this.addEditComponent != null) { + this.addEditComponent.sendId = sendId; + await this.addEditComponent.refresh(); + } + } + + // Get the type (text or file) of the currently selected Send for the edit form + get selectedSendType() { + return this.sends.find((s) => s.id === this.sendId)?.type; + } + + // Show the right-click context menu for a Send with options to copy link, remove password, or delete + viewSendMenu(send: SendView) { + const menu: RendererMenuItem[] = []; + menu.push({ + label: this.i18nService.t("copyLink"), + click: () => this.copy(send), + }); + if (send.password && !send.disabled) { + menu.push({ + label: this.i18nService.t("removePassword"), + click: async () => { + await this.removePassword(send); + if (this.sendId === send.id) { + this.sendId = null; + // 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.selectSend(send.id); + } + }, + }); + } + menu.push({ + label: this.i18nService.t("delete"), + click: async () => { + await this.delete(send); + await this.deletedSend(send); + }, + }); + + invokeMenu(menu); + } +} diff --git a/apps/desktop/src/app/tools/send/add-edit.component.html b/apps/desktop/src/app/tools/send/add-edit.component.html index 0bf2e1778e0..639c80c9060 100644 --- a/apps/desktop/src/app/tools/send/add-edit.component.html +++ b/apps/desktop/src/app/tools/send/add-edit.component.html @@ -46,7 +46,9 @@
-
{{ send.file.fileName }} ({{ send.file.sizeName }})
+
+ {{ send.file.fileName }} ({{ send.file.sizeName }}) +
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 b817adda848..076b0f6c9d5 100644 --- a/apps/desktop/src/app/tools/send/add-edit.component.ts +++ b/apps/desktop/src/app/tools/send/add-edit.component.ts @@ -19,14 +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( @@ -45,6 +54,7 @@ export class AddEditComponent extends BaseAddEditComponent { billingAccountProfileStateService: BillingAccountProfileStateService, accountService: AccountService, toastService: ToastService, + premiumUpgradePromptService: PremiumUpgradePromptService, ) { super( i18nService, @@ -62,6 +72,7 @@ export class AddEditComponent extends BaseAddEditComponent { billingAccountProfileStateService, accountService, toastService, + premiumUpgradePromptService, ); } diff --git a/apps/desktop/src/app/tools/send/send.component.ts b/apps/desktop/src/app/tools/send/send.component.ts index 3605ca3d2dc..b58c3961bde 100644 --- a/apps/desktop/src/app/tools/send/send.component.ts +++ b/apps/desktop/src/app/tools/send/send.component.ts @@ -25,13 +25,16 @@ 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"; 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/login/desktop-login-component.service.spec.ts b/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts index c88627250c9..414bbaca56f 100644 --- a/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts +++ b/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts @@ -136,6 +136,7 @@ describe("DesktopLoginComponentService", () => { codeChallenge, state, email, + undefined, ); } else { expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state); @@ -145,4 +146,55 @@ describe("DesktopLoginComponentService", () => { }); }); }); + + describe("redirectToSsoLoginWithOrganizationSsoIdentifier", () => { + // Array of all permutations of isAppImage and isDev + const permutations = [ + [true, false], // Case 1: isAppImage true + [false, true], // Case 2: isDev true + [true, true], // Case 3: all true + [false, false], // Case 4: all false + ]; + + permutations.forEach(([isAppImage, isDev]) => { + it("calls redirectToSso with orgSsoIdentifier", async () => { + (global as any).ipc.platform.isAppImage = isAppImage; + (global as any).ipc.platform.isDev = isDev; + + const email = "test@bitwarden.com"; + const state = "testState"; + const codeVerifier = "testCodeVerifier"; + const codeChallenge = "testCodeChallenge"; + const orgSsoIdentifier = "orgSsoId"; + + passwordGenerationService.generatePassword.mockResolvedValueOnce(state); + passwordGenerationService.generatePassword.mockResolvedValueOnce(codeVerifier); + jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue(codeChallenge); + + await service.redirectToSsoLoginWithOrganizationSsoIdentifier(email, orgSsoIdentifier); + + if (isAppImage || isDev) { + expect(ipc.platform.localhostCallbackService.openSsoPrompt).toHaveBeenCalledWith( + codeChallenge, + state, + email, + orgSsoIdentifier, + ); + } else { + expect(ssoUrlService.buildSsoUrl).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + expect.any(String), + expect.any(String), + email, + orgSsoIdentifier, + ); + expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state); + expect(ssoLoginService.setCodeVerifier).toHaveBeenCalledWith(codeVerifier); + expect(platformUtilsService.launchUri).toHaveBeenCalled(); + } + }); + }); + }); }); diff --git a/apps/desktop/src/auth/login/desktop-login-component.service.ts b/apps/desktop/src/auth/login/desktop-login-component.service.ts index d7e7ba0178b..6ef39eaa018 100644 --- a/apps/desktop/src/auth/login/desktop-login-component.service.ts +++ b/apps/desktop/src/auth/login/desktop-login-component.service.ts @@ -48,11 +48,12 @@ export class DesktopLoginComponentService email: string, state: string, codeChallenge: string, + orgSsoIdentifier?: string, ): Promise { // For platforms that cannot support a protocol-based (e.g. bitwarden://) callback, we use a localhost callback // Otherwise, we launch the SSO component in a browser window and wait for the callback if (ipc.platform.isAppImage || ipc.platform.isDev) { - await this.initiateSsoThroughLocalhostCallback(email, state, codeChallenge); + await this.initiateSsoThroughLocalhostCallback(email, state, codeChallenge, orgSsoIdentifier); } else { const env = await firstValueFrom(this.environmentService.environment$); const webVaultUrl = env.getWebVaultUrl(); @@ -66,6 +67,7 @@ export class DesktopLoginComponentService state, codeChallenge, email, + orgSsoIdentifier, ); this.platformUtilsService.launchUri(ssoWebAppUrl); @@ -76,9 +78,15 @@ export class DesktopLoginComponentService email: string, state: string, challenge: string, + orgSsoIdentifier?: string, ): Promise { try { - await ipc.platform.localhostCallbackService.openSsoPrompt(challenge, state, email); + await ipc.platform.localhostCallbackService.openSsoPrompt( + challenge, + state, + email, + orgSsoIdentifier, + ); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (err) { 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/guards/reactive-vault-guard.ts b/apps/desktop/src/autofill/guards/reactive-vault-guard.ts new file mode 100644 index 00000000000..d16787ef46a --- /dev/null +++ b/apps/desktop/src/autofill/guards/reactive-vault-guard.ts @@ -0,0 +1,42 @@ +import { inject } from "@angular/core"; +import { CanActivateFn, Router } from "@angular/router"; +import { combineLatest, map, switchMap, distinctUntilChanged } 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 { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; + +/** + * Reactive route guard that redirects to the unlocked vault. + * Redirects to vault when unlocked in main window. + */ +export const reactiveUnlockVaultGuard: CanActivateFn = () => { + const router = inject(Router); + const authService = inject(AuthService); + const accountService = inject(AccountService); + const desktopSettingsService = inject(DesktopSettingsService); + + return combineLatest([accountService.activeAccount$, desktopSettingsService.modalMode$]).pipe( + switchMap(([account, modalMode]) => { + if (!account) { + return [true]; + } + + // Monitor when the vault has been unlocked. + return authService.authStatusFor$(account.id).pipe( + distinctUntilChanged(), + map((authStatus) => { + // If vault is unlocked and we're not in modal mode, redirect to vault + if (authStatus === AuthenticationStatus.Unlocked && !modalMode?.isModalModeActive) { + return router.createUrlTree(["/vault"]); + } + + // Otherwise keep user on the lock screen + return true; + }), + ); + }), + ); +}; 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/main/main-ssh-agent.service.ts b/apps/desktop/src/autofill/main/main-ssh-agent.service.ts index 595ef778bcf..31196e4cf98 100644 --- a/apps/desktop/src/autofill/main/main-ssh-agent.service.ts +++ b/apps/desktop/src/autofill/main/main-ssh-agent.service.ts @@ -37,7 +37,7 @@ export class MainSshAgentService { init() { // handle sign request passing to UI sshagent - .serve(async (err: Error, sshUiRequest: sshagent.SshUiRequest) => { + .serve(async (err: Error | null, sshUiRequest: sshagent.SshUiRequest): Promise => { // clear all old (> SIGN_TIMEOUT) requests this.requestResponses = this.requestResponses.filter( (response) => response.timestamp > new Date(Date.now() - this.SIGN_TIMEOUT), diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.html b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.html new file mode 100644 index 00000000000..67fc76aa317 --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.html @@ -0,0 +1,66 @@ + diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts new file mode 100644 index 00000000000..778215895ee --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts @@ -0,0 +1,238 @@ +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { DesktopAutofillService } from "../../../autofill/services/desktop-autofill.service"; +import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service"; +import { + DesktopFido2UserInterfaceService, + DesktopFido2UserInterfaceSession, +} from "../../services/desktop-fido2-user-interface.service"; + +import { Fido2CreateComponent } from "./fido2-create.component"; + +describe("Fido2CreateComponent", () => { + let component: Fido2CreateComponent; + let mockDesktopSettingsService: MockProxy; + let mockFido2UserInterfaceService: MockProxy; + let mockAccountService: MockProxy; + let mockCipherService: MockProxy; + let mockDesktopAutofillService: MockProxy; + let mockDialogService: MockProxy; + let mockDomainSettingsService: MockProxy; + let mockLogService: MockProxy; + let mockPasswordRepromptService: MockProxy; + let mockRouter: MockProxy; + let mockSession: MockProxy; + let mockI18nService: MockProxy; + + const activeAccountSubject = new BehaviorSubject({ + id: "test-user-id" as UserId, + email: "test@example.com", + emailVerified: true, + name: "Test User", + }); + + beforeEach(async () => { + mockDesktopSettingsService = mock(); + mockFido2UserInterfaceService = mock(); + mockAccountService = mock(); + mockCipherService = mock(); + mockDesktopAutofillService = mock(); + mockDialogService = mock(); + mockDomainSettingsService = mock(); + mockLogService = mock(); + mockPasswordRepromptService = mock(); + mockRouter = mock(); + mockSession = mock(); + mockI18nService = mock(); + + mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession); + mockAccountService.activeAccount$ = activeAccountSubject; + + await TestBed.configureTestingModule({ + providers: [ + Fido2CreateComponent, + { provide: DesktopSettingsService, useValue: mockDesktopSettingsService }, + { provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: CipherService, useValue: mockCipherService }, + { provide: DesktopAutofillService, useValue: mockDesktopAutofillService }, + { provide: DialogService, useValue: mockDialogService }, + { provide: DomainSettingsService, useValue: mockDomainSettingsService }, + { provide: LogService, useValue: mockLogService }, + { provide: PasswordRepromptService, useValue: mockPasswordRepromptService }, + { provide: Router, useValue: mockRouter }, + { provide: I18nService, useValue: mockI18nService }, + ], + }).compileComponents(); + + component = TestBed.inject(Fido2CreateComponent); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function createMockCiphers(): CipherView[] { + const cipher1 = new CipherView(); + cipher1.id = "cipher-1"; + cipher1.name = "Test Cipher 1"; + cipher1.type = CipherType.Login; + cipher1.login = { + username: "test1@example.com", + uris: [{ uri: "https://example.com", match: null }], + matchesUri: jest.fn().mockReturnValue(true), + get hasFido2Credentials() { + return false; + }, + } as any; + cipher1.reprompt = CipherRepromptType.None; + cipher1.deletedDate = null; + + return [cipher1]; + } + + describe("ngOnInit", () => { + beforeEach(() => { + mockSession.getRpId.mockResolvedValue("example.com"); + Object.defineProperty(mockDesktopAutofillService, "lastRegistrationRequest", { + get: jest.fn().mockReturnValue({ + userHandle: new Uint8Array([1, 2, 3]), + }), + configurable: true, + }); + mockDomainSettingsService.getUrlEquivalentDomains.mockReturnValue(of(new Set())); + }); + + it("should initialize session and set show header to false", async () => { + const mockCiphers = createMockCiphers(); + mockCipherService.getAllDecrypted.mockResolvedValue(mockCiphers); + + await component.ngOnInit(); + + expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled(); + expect(component.session).toBe(mockSession); + }); + + it("should show error dialog when no active session found", async () => { + mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(null); + mockDialogService.openSimpleDialog.mockResolvedValue(false); + + await component.ngOnInit(); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "unableToSavePasskey" }, + content: { key: "closeThisBitwardenWindow" }, + type: "danger", + acceptButtonText: { key: "closeThisWindow" }, + acceptAction: expect.any(Function), + cancelButtonText: null, + }); + }); + }); + + describe("addCredentialToCipher", () => { + beforeEach(() => { + component.session = mockSession; + }); + + it("should add passkey to cipher", async () => { + const cipher = createMockCiphers()[0]; + + await component.addCredentialToCipher(cipher); + + expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true, cipher); + }); + + it("should not add passkey when password reprompt is cancelled", async () => { + const cipher = createMockCiphers()[0]; + cipher.reprompt = CipherRepromptType.Password; + mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(false); + + await component.addCredentialToCipher(cipher); + + expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false, cipher); + }); + + it("should call openSimpleDialog when cipher already has a fido2 credential", async () => { + const cipher = createMockCiphers()[0]; + Object.defineProperty(cipher.login, "hasFido2Credentials", { + get: jest.fn().mockReturnValue(true), + }); + mockDialogService.openSimpleDialog.mockResolvedValue(true); + + await component.addCredentialToCipher(cipher); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "overwritePasskey" }, + content: { key: "alreadyContainsPasskey" }, + type: "warning", + }); + expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true, cipher); + }); + + it("should not add passkey when user cancels overwrite dialog", async () => { + const cipher = createMockCiphers()[0]; + Object.defineProperty(cipher.login, "hasFido2Credentials", { + get: jest.fn().mockReturnValue(true), + }); + mockDialogService.openSimpleDialog.mockResolvedValue(false); + + await component.addCredentialToCipher(cipher); + + expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false, cipher); + }); + }); + + describe("confirmPasskey", () => { + beforeEach(() => { + component.session = mockSession; + }); + + it("should confirm passkey creation successfully", async () => { + await component.confirmPasskey(); + + expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true); + }); + + it("should call openSimpleDialog when session is null", async () => { + component.session = null; + mockDialogService.openSimpleDialog.mockResolvedValue(false); + + await component.confirmPasskey(); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "unableToSavePasskey" }, + content: { key: "closeThisBitwardenWindow" }, + type: "danger", + acceptButtonText: { key: "closeThisWindow" }, + acceptAction: expect.any(Function), + cancelButtonText: null, + }); + }); + }); + + describe("closeModal", () => { + it("should close modal and notify session", async () => { + component.session = mockSession; + + await component.closeModal(); + + expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false); + expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(null); + }); + }); +}); diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts new file mode 100644 index 00000000000..67237bedccd --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts @@ -0,0 +1,219 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from "@angular/core"; +import { RouterModule, Router } from "@angular/router"; +import { combineLatest, map, Observable, Subject, switchMap } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { BitwardenShield, NoResults } from "@bitwarden/assets/svg"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + DialogService, + BadgeModule, + ButtonModule, + DialogModule, + IconModule, + ItemModule, + SectionComponent, + TableModule, + SectionHeaderComponent, + BitIconButtonComponent, + SimpleDialogOptions, +} from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { DesktopAutofillService } from "../../../autofill/services/desktop-autofill.service"; +import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service"; +import { + DesktopFido2UserInterfaceService, + DesktopFido2UserInterfaceSession, +} from "../../services/desktop-fido2-user-interface.service"; + +@Component({ + standalone: true, + imports: [ + CommonModule, + RouterModule, + SectionHeaderComponent, + BitIconButtonComponent, + TableModule, + JslibModule, + IconModule, + ButtonModule, + DialogModule, + SectionComponent, + ItemModule, + BadgeModule, + ], + templateUrl: "fido2-create.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Fido2CreateComponent implements OnInit, OnDestroy { + session?: DesktopFido2UserInterfaceSession = null; + ciphers$: Observable; + private destroy$ = new Subject(); + readonly Icons = { BitwardenShield, NoResults }; + + private get DIALOG_MESSAGES() { + return { + unexpectedErrorShort: { + title: { key: "unexpectedErrorShort" }, + content: { key: "closeThisBitwardenWindow" }, + type: "danger", + acceptButtonText: { key: "closeThisWindow" }, + cancelButtonText: null as null, + acceptAction: async () => this.dialogService.closeAll(), + }, + unableToSavePasskey: { + title: { key: "unableToSavePasskey" }, + content: { key: "closeThisBitwardenWindow" }, + type: "danger", + acceptButtonText: { key: "closeThisWindow" }, + cancelButtonText: null as null, + acceptAction: async () => this.dialogService.closeAll(), + }, + overwritePasskey: { + title: { key: "overwritePasskey" }, + content: { key: "alreadyContainsPasskey" }, + type: "warning", + }, + } as const satisfies Record; + } + + constructor( + private readonly desktopSettingsService: DesktopSettingsService, + private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService, + private readonly accountService: AccountService, + private readonly cipherService: CipherService, + private readonly desktopAutofillService: DesktopAutofillService, + private readonly dialogService: DialogService, + private readonly domainSettingsService: DomainSettingsService, + private readonly passwordRepromptService: PasswordRepromptService, + private readonly router: Router, + ) {} + + async ngOnInit(): Promise { + this.session = this.fido2UserInterfaceService.getCurrentSession(); + + if (this.session) { + const rpid = await this.session.getRpId(); + this.initializeCiphersObservable(rpid); + } else { + await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey); + } + } + + async ngOnDestroy(): Promise { + this.destroy$.next(); + this.destroy$.complete(); + await this.closeModal(); + } + + async addCredentialToCipher(cipher: CipherView): Promise { + const isConfirmed = await this.validateCipherAccess(cipher); + + try { + if (!this.session) { + throw new Error("Missing session"); + } + + this.session.notifyConfirmCreateCredential(isConfirmed, cipher); + } catch { + await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey); + return; + } + + await this.closeModal(); + } + + async confirmPasskey(): Promise { + try { + if (!this.session) { + throw new Error("Missing session"); + } + + this.session.notifyConfirmCreateCredential(true); + } catch { + await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey); + } + + await this.closeModal(); + } + + async closeModal(): Promise { + await this.desktopSettingsService.setModalMode(false); + await this.accountService.setShowHeader(true); + + if (this.session) { + this.session.notifyConfirmCreateCredential(false); + this.session.confirmChosenCipher(null); + } + + await this.router.navigate(["/"]); + } + + private initializeCiphersObservable(rpid: string): void { + const lastRegistrationRequest = this.desktopAutofillService.lastRegistrationRequest; + + if (!lastRegistrationRequest || !rpid) { + return; + } + + const userHandle = Fido2Utils.bufferToString( + new Uint8Array(lastRegistrationRequest.userHandle), + ); + + this.ciphers$ = combineLatest([ + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + this.domainSettingsService.getUrlEquivalentDomains(rpid), + ]).pipe( + switchMap(async ([activeUserId, equivalentDomains]) => { + if (!activeUserId) { + return []; + } + + try { + const allCiphers = await this.cipherService.getAllDecrypted(activeUserId); + return allCiphers.filter( + (cipher) => + cipher != null && + cipher.type == CipherType.Login && + cipher.login?.matchesUri(rpid, equivalentDomains) && + Fido2Utils.cipherHasNoOtherPasskeys(cipher, userHandle) && + !cipher.deletedDate, + ); + } catch { + await this.showErrorDialog(this.DIALOG_MESSAGES.unexpectedErrorShort); + return []; + } + }), + ); + } + + private async validateCipherAccess(cipher: CipherView): Promise { + if (cipher.login.hasFido2Credentials) { + const overwriteConfirmed = await this.dialogService.openSimpleDialog( + this.DIALOG_MESSAGES.overwritePasskey, + ); + + if (!overwriteConfirmed) { + return false; + } + } + + if (cipher.reprompt) { + return this.passwordRepromptService.showPasswordPrompt(); + } + + return true; + } + + private async showErrorDialog(config: SimpleDialogOptions): Promise { + await this.dialogService.openSimpleDialog(config); + await this.closeModal(); + } +} diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html new file mode 100644 index 00000000000..792934deedc --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html @@ -0,0 +1,44 @@ +
+ + +
+ + +

+ {{ "savePasskeyQuestion" | i18n }} +

+
+ + +
+
+ +
+ +
+ +
+ {{ "passkeyAlreadyExists" | i18n }} + {{ "applicationDoesNotSupportDuplicates" | i18n }} +
+ +
+
+
+
diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.spec.ts b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.spec.ts new file mode 100644 index 00000000000..6a465136458 --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.spec.ts @@ -0,0 +1,78 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service"; +import { + DesktopFido2UserInterfaceService, + DesktopFido2UserInterfaceSession, +} from "../../services/desktop-fido2-user-interface.service"; + +import { Fido2ExcludedCiphersComponent } from "./fido2-excluded-ciphers.component"; + +describe("Fido2ExcludedCiphersComponent", () => { + let component: Fido2ExcludedCiphersComponent; + let fixture: ComponentFixture; + let mockDesktopSettingsService: MockProxy; + let mockFido2UserInterfaceService: MockProxy; + let mockAccountService: MockProxy; + let mockRouter: MockProxy; + let mockSession: MockProxy; + let mockI18nService: MockProxy; + + beforeEach(async () => { + mockDesktopSettingsService = mock(); + mockFido2UserInterfaceService = mock(); + mockAccountService = mock(); + mockRouter = mock(); + mockSession = mock(); + mockI18nService = mock(); + + mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession); + + await TestBed.configureTestingModule({ + imports: [Fido2ExcludedCiphersComponent], + providers: [ + { provide: DesktopSettingsService, useValue: mockDesktopSettingsService }, + { provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: Router, useValue: mockRouter }, + { provide: I18nService, useValue: mockI18nService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(Fido2ExcludedCiphersComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("ngOnInit", () => { + it("should initialize session", async () => { + await component.ngOnInit(); + + expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled(); + expect(component.session).toBe(mockSession); + }); + }); + + describe("closeModal", () => { + it("should close modal and notify session when session exists", async () => { + component.session = mockSession; + + await component.closeModal(); + + expect(mockDesktopSettingsService.setModalMode).toHaveBeenCalledWith(false); + expect(mockAccountService.setShowHeader).toHaveBeenCalledWith(true); + expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false); + expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]); + }); + }); +}); diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.ts b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.ts new file mode 100644 index 00000000000..049771c2252 --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.ts @@ -0,0 +1,78 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from "@angular/core"; +import { RouterModule, Router } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { BitwardenShield, NoResults } from "@bitwarden/assets/svg"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { + BadgeModule, + ButtonModule, + DialogModule, + IconModule, + ItemModule, + SectionComponent, + TableModule, + SectionHeaderComponent, + BitIconButtonComponent, +} from "@bitwarden/components"; + +import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service"; +import { + DesktopFido2UserInterfaceService, + DesktopFido2UserInterfaceSession, +} from "../../services/desktop-fido2-user-interface.service"; + +@Component({ + standalone: true, + imports: [ + CommonModule, + RouterModule, + SectionHeaderComponent, + BitIconButtonComponent, + TableModule, + JslibModule, + IconModule, + ButtonModule, + DialogModule, + SectionComponent, + ItemModule, + BadgeModule, + ], + templateUrl: "fido2-excluded-ciphers.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Fido2ExcludedCiphersComponent implements OnInit, OnDestroy { + session?: DesktopFido2UserInterfaceSession = null; + readonly Icons = { BitwardenShield, NoResults }; + + constructor( + private readonly desktopSettingsService: DesktopSettingsService, + private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService, + private readonly accountService: AccountService, + private readonly router: Router, + ) {} + + async ngOnInit(): Promise { + this.session = this.fido2UserInterfaceService.getCurrentSession(); + } + + async ngOnDestroy(): Promise { + await this.closeModal(); + } + + async closeModal(): Promise { + // Clean up modal state + await this.desktopSettingsService.setModalMode(false); + await this.accountService.setShowHeader(true); + + // Clean up session state + if (this.session) { + this.session.notifyConfirmCreateCredential(false); + this.session.confirmChosenCipher(null); + } + + // Navigate away + await this.router.navigate(["/"]); + } +} diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.html b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.html new file mode 100644 index 00000000000..ed04993d09f --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.html @@ -0,0 +1,37 @@ +
+ + +
+ + +

{{ "passkeyLogin" | i18n }}

+
+ +
+
+ + + + + {{ c.subTitle }} + {{ "select" | i18n }} + + + +
diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.spec.ts b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.spec.ts new file mode 100644 index 00000000000..70ef4461f6a --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.spec.ts @@ -0,0 +1,196 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service"; +import { + DesktopFido2UserInterfaceService, + DesktopFido2UserInterfaceSession, +} from "../../services/desktop-fido2-user-interface.service"; + +import { Fido2VaultComponent } from "./fido2-vault.component"; + +describe("Fido2VaultComponent", () => { + let component: Fido2VaultComponent; + let fixture: ComponentFixture; + let mockDesktopSettingsService: MockProxy; + let mockFido2UserInterfaceService: MockProxy; + let mockCipherService: MockProxy; + let mockAccountService: MockProxy; + let mockLogService: MockProxy; + let mockPasswordRepromptService: MockProxy; + let mockRouter: MockProxy; + let mockSession: MockProxy; + let mockI18nService: MockProxy; + + const mockActiveAccount = { id: "test-user-id", email: "test@example.com" }; + const mockCipherIds = ["cipher-1", "cipher-2", "cipher-3"]; + + beforeEach(async () => { + mockDesktopSettingsService = mock(); + mockFido2UserInterfaceService = mock(); + mockCipherService = mock(); + mockAccountService = mock(); + mockLogService = mock(); + mockPasswordRepromptService = mock(); + mockRouter = mock(); + mockSession = mock(); + mockI18nService = mock(); + + mockAccountService.activeAccount$ = of(mockActiveAccount as Account); + mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession); + mockSession.availableCipherIds$ = of(mockCipherIds); + mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of([])); + + await TestBed.configureTestingModule({ + imports: [Fido2VaultComponent], + providers: [ + { provide: DesktopSettingsService, useValue: mockDesktopSettingsService }, + { provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService }, + { provide: CipherService, useValue: mockCipherService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: LogService, useValue: mockLogService }, + { provide: PasswordRepromptService, useValue: mockPasswordRepromptService }, + { provide: Router, useValue: mockRouter }, + { provide: I18nService, useValue: mockI18nService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(Fido2VaultComponent); + component = fixture.componentInstance; + }); + + const mockCiphers: any[] = [ + { + id: "cipher-1", + name: "Test Cipher 1", + type: CipherType.Login, + login: { + username: "test1@example.com", + }, + reprompt: CipherRepromptType.None, + deletedDate: null, + }, + { + id: "cipher-2", + name: "Test Cipher 2", + type: CipherType.Login, + login: { + username: "test2@example.com", + }, + reprompt: CipherRepromptType.None, + deletedDate: null, + }, + { + id: "cipher-3", + name: "Test Cipher 3", + type: CipherType.Login, + login: { + username: "test3@example.com", + }, + reprompt: CipherRepromptType.Password, + deletedDate: null, + }, + ]; + + describe("ngOnInit", () => { + it("should initialize session and load ciphers successfully", async () => { + mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of(mockCiphers)); + + await component.ngOnInit(); + + expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled(); + expect(component.session).toBe(mockSession); + expect(component.cipherIds$).toBe(mockSession.availableCipherIds$); + expect(mockCipherService.cipherListViews$).toHaveBeenCalledWith(mockActiveAccount.id); + }); + + it("should handle when no active session found", async () => { + mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(null); + + await component.ngOnInit(); + + expect(component.session).toBeNull(); + }); + + it("should filter out deleted ciphers", async () => { + const ciphersWithDeleted = [ + ...mockCiphers.slice(0, 1), + { ...mockCiphers[1], deletedDate: new Date() }, + ...mockCiphers.slice(2), + ]; + mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of(ciphersWithDeleted)); + + await component.ngOnInit(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + let ciphersResult: CipherView[] = []; + component.ciphers$.subscribe((ciphers) => { + ciphersResult = ciphers; + }); + + expect(ciphersResult).toHaveLength(2); + expect(ciphersResult.every((cipher) => !cipher.deletedDate)).toBe(true); + }); + }); + + describe("chooseCipher", () => { + const cipher = mockCiphers[0]; + + beforeEach(() => { + component.session = mockSession; + }); + + it("should choose cipher when access is validated", async () => { + cipher.reprompt = CipherRepromptType.None; + + await component.chooseCipher(cipher); + + expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, true); + expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]); + }); + + it("should prompt for password when cipher requires reprompt", async () => { + cipher.reprompt = CipherRepromptType.Password; + mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(true); + + await component.chooseCipher(cipher); + + expect(mockPasswordRepromptService.showPasswordPrompt).toHaveBeenCalled(); + expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, true); + }); + + it("should not choose cipher when password reprompt is cancelled", async () => { + cipher.reprompt = CipherRepromptType.Password; + mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(false); + + await component.chooseCipher(cipher); + + expect(mockPasswordRepromptService.showPasswordPrompt).toHaveBeenCalled(); + expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, false); + }); + }); + + describe("closeModal", () => { + it("should close modal and notify session", async () => { + component.session = mockSession; + + await component.closeModal(); + + expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]); + expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false); + expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(null); + }); + }); +}); diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts new file mode 100644 index 00000000000..897e825c53e --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts @@ -0,0 +1,161 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from "@angular/core"; +import { RouterModule, Router } from "@angular/router"; +import { + firstValueFrom, + map, + combineLatest, + of, + BehaviorSubject, + Observable, + Subject, + takeUntil, +} from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { BitwardenShield } from "@bitwarden/assets/svg"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + BadgeModule, + ButtonModule, + DialogModule, + DialogService, + IconModule, + ItemModule, + SectionComponent, + TableModule, + BitIconButtonComponent, + SectionHeaderComponent, +} from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service"; +import { + DesktopFido2UserInterfaceService, + DesktopFido2UserInterfaceSession, +} from "../../services/desktop-fido2-user-interface.service"; + +@Component({ + standalone: true, + imports: [ + CommonModule, + RouterModule, + SectionHeaderComponent, + BitIconButtonComponent, + TableModule, + JslibModule, + IconModule, + ButtonModule, + DialogModule, + SectionComponent, + ItemModule, + BadgeModule, + ], + templateUrl: "fido2-vault.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Fido2VaultComponent implements OnInit, OnDestroy { + session?: DesktopFido2UserInterfaceSession = null; + private destroy$ = new Subject(); + private ciphersSubject = new BehaviorSubject([]); + ciphers$: Observable = this.ciphersSubject.asObservable(); + cipherIds$: Observable | undefined; + readonly Icons = { BitwardenShield }; + + constructor( + private readonly desktopSettingsService: DesktopSettingsService, + private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService, + private readonly cipherService: CipherService, + private readonly accountService: AccountService, + private readonly dialogService: DialogService, + private readonly logService: LogService, + private readonly passwordRepromptService: PasswordRepromptService, + private readonly router: Router, + ) {} + + async ngOnInit(): Promise { + this.session = this.fido2UserInterfaceService.getCurrentSession(); + this.cipherIds$ = this.session?.availableCipherIds$; + await this.loadCiphers(); + } + + async ngOnDestroy(): Promise { + this.destroy$.next(); + this.destroy$.complete(); + } + + async chooseCipher(cipher: CipherView): Promise { + if (!this.session) { + await this.dialogService.openSimpleDialog({ + title: { key: "unexpectedErrorShort" }, + content: { key: "closeThisBitwardenWindow" }, + type: "danger", + acceptButtonText: { key: "closeThisWindow" }, + cancelButtonText: null, + }); + await this.closeModal(); + + return; + } + + const isConfirmed = await this.validateCipherAccess(cipher); + this.session.confirmChosenCipher(cipher.id, isConfirmed); + + await this.closeModal(); + } + + async closeModal(): Promise { + await this.desktopSettingsService.setModalMode(false); + await this.accountService.setShowHeader(true); + + if (this.session) { + this.session.notifyConfirmCreateCredential(false); + this.session.confirmChosenCipher(null); + } + + await this.router.navigate(["/"]); + } + + private async loadCiphers(): Promise { + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + + if (!activeUserId) { + return; + } + + // Combine cipher list with optional cipher IDs filter + combineLatest([this.cipherService.cipherListViews$(activeUserId), this.cipherIds$ || of(null)]) + .pipe( + map(([ciphers, cipherIds]) => { + // Filter out deleted ciphers + const activeCiphers = ciphers.filter((cipher) => !cipher.deletedDate); + + // If specific IDs provided, filter by them + if (cipherIds?.length > 0) { + return activeCiphers.filter((cipher) => cipherIds.includes(cipher.id as string)); + } + + return activeCiphers; + }), + takeUntil(this.destroy$), + ) + .subscribe({ + next: (ciphers) => this.ciphersSubject.next(ciphers as CipherView[]), + error: (error: unknown) => this.logService.error("Failed to load ciphers", error), + }); + } + + private async validateCipherAccess(cipher: CipherView): Promise { + if (cipher.reprompt !== CipherRepromptType.None) { + return this.passwordRepromptService.showPasswordPrompt(); + } + + return true; + } +} 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..6a7a8459ea9 100644 --- a/apps/desktop/src/autofill/preload.ts +++ b/apps/desktop/src/autofill/preload.ts @@ -5,10 +5,15 @@ 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), + listenerReady: () => ipcRenderer.send("autofill.listenerReady"), + listenPasskeyRegistration: ( fn: ( clientId: number, @@ -127,41 +132,56 @@ export default { }, ); }, + + listenNativeStatus: ( + fn: (clientId: number, sequenceNumber: number, status: { key: string; value: string }) => void, + ) => { + ipcRenderer.on( + "autofill.nativeStatus", + ( + event, + data: { + clientId: number; + sequenceNumber: number; + status: { key: string; value: string }; + }, + ) => { + const { clientId, sequenceNumber, status } = data; + fn(clientId, sequenceNumber, status); + }, + ); + }, configureAutotype: (enabled: boolean, keyboardShortcut: string[]) => { ipcRenderer.send("autofill.configureAutotype", { enabled, keyboardShortcut }); }, 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..c50964e31e3 100644 --- a/apps/desktop/src/autofill/services/desktop-autofill.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autofill.service.ts @@ -1,6 +1,8 @@ import { Injectable, OnDestroy } from "@angular/core"; import { Subject, + combineLatest, + debounceTime, distinctUntilChanged, filter, firstValueFrom, @@ -8,11 +10,13 @@ import { mergeMap, switchMap, takeUntil, - EMPTY, } 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 { 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 +28,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"; @@ -46,6 +51,7 @@ import type { NativeWindowObject } from "./desktop-fido2-user-interface.service" @Injectable() export class DesktopAutofillService implements OnDestroy { private destroy$ = new Subject(); + private registrationRequest: autofill.PasskeyRegistrationRequest; constructor( private logService: LogService, @@ -53,35 +59,70 @@ export class DesktopAutofillService implements OnDestroy { private configService: ConfigService, private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction, private accountService: AccountService, + private authService: AuthService, + private platformUtilsService: PlatformUtilsService, ) {} async init() { + // Currently only supported for MacOS + if (this.platformUtilsService.getDevice() !== DeviceType.MacOsDesktop) { + return; + } + this.configService .getFeatureFlag$(FeatureFlag.MacOsNativeCredentialSync) .pipe( distinctUntilChanged(), - switchMap((enabled) => { - if (!enabled) { - return EMPTY; - } - - return this.accountService.activeAccount$.pipe( - map((account) => account?.id), - filter((userId): userId is UserId => userId != null), - switchMap((userId) => this.cipherService.cipherViews$(userId)), + filter((enabled) => enabled === true), // Only proceed if feature is enabled + switchMap(() => { + return combineLatest([ + this.accountService.activeAccount$.pipe( + map((account) => account?.id), + filter((userId): userId is UserId => userId != null), + ), + this.authService.activeAccountStatus$, + ]).pipe( + // Only proceed when the vault is unlocked + filter(([, status]) => status === AuthenticationStatus.Unlocked), + // Then get cipher views + switchMap(([userId]) => this.cipherService.cipherViews$(userId)), ); }), - // TODO: This will unset all the autofill credentials on the OS - // when the account locks. We should instead explicilty clear the credentials - // when the user logs out. Maybe by subscribing to the encrypted ciphers observable instead. + debounceTime(100), // just a precaution to not spam the sync if there are multiple changes (we typically observe a null change) + // No filter for empty arrays here - we want to sync even if there are 0 items + filter((cipherViewMap) => cipherViewMap !== null), + mergeMap((cipherViewMap) => this.sync(Object.values(cipherViewMap ?? []))), takeUntil(this.destroy$), ) .subscribe(); + // Listen for sign out to clear credentials + this.authService.activeAccountStatus$ + .pipe( + filter((status) => status === AuthenticationStatus.LoggedOut), + mergeMap(() => this.sync([])), // sync an empty array + takeUntil(this.destroy$), + ) + .subscribe(); + this.listenIpc(); } + async adHocSync(): Promise { + this.logService.debug("Performing AdHoc sync"); + const account = await firstValueFrom(this.accountService.activeAccount$); + const userId = account?.id; + + if (!userId) { + throw new Error("No active user found"); + } + + const cipherViewMap = await firstValueFrom(this.cipherService.cipherViews$(userId)); + this.logService.info("Performing AdHoc sync", Object.values(cipherViewMap ?? [])); + await this.sync(Object.values(cipherViewMap ?? [])); + } + /** Give metadata about all available credentials in the users vault */ async sync(cipherViews: CipherView[]) { const status = await this.status(); @@ -122,6 +163,11 @@ export class DesktopAutofillService implements OnDestroy { })); } + this.logService.info("Syncing autofill credentials", { + fido2Credentials, + passwordCredentials, + }); + const syncResult = await ipc.autofill.runCommand({ namespace: "autofill", command: "sync", @@ -147,107 +193,152 @@ export class DesktopAutofillService implements OnDestroy { }); } + get lastRegistrationRequest() { + return this.registrationRequest; + } + listenIpc() { - ipc.autofill.listenPasskeyRegistration((clientId, sequenceNumber, request, callback) => { - this.logService.warning("listenPasskeyRegistration", clientId, sequenceNumber, request); - this.logService.warning( - "listenPasskeyRegistration2", - this.convertRegistrationRequest(request), - ); + ipc.autofill.listenPasskeyRegistration(async (clientId, sequenceNumber, request, callback) => { + if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) { + this.logService.debug( + "listenPasskeyRegistration: MacOsNativeCredentialSync feature flag is disabled", + ); + callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null); + return; + } + + this.registrationRequest = request; + + this.logService.debug("listenPasskeyRegistration", clientId, sequenceNumber, request); + this.logService.debug("listenPasskeyRegistration2", this.convertRegistrationRequest(request)); const controller = new AbortController(); - void this.fido2AuthenticatorService - .makeCredential( + + try { + const response = await this.fido2AuthenticatorService.makeCredential( this.convertRegistrationRequest(request), - { windowXy: request.windowXy }, + { windowXy: normalizePosition(request.windowXy) }, controller, - ) - .then((response) => { - callback(null, this.convertRegistrationResponse(request, response)); - }) - .catch((error) => { - this.logService.error("listenPasskeyRegistration error", error); - callback(error, null); - }); + ); + + callback(null, this.convertRegistrationResponse(request, response)); + } catch (error) { + this.logService.error("listenPasskeyRegistration error", error); + callback(error, null); + } }); ipc.autofill.listenPasskeyAssertionWithoutUserInterface( async (clientId, sequenceNumber, request, callback) => { - this.logService.warning( + if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) { + this.logService.debug( + "listenPasskeyAssertionWithoutUserInterface: MacOsNativeCredentialSync feature flag is disabled", + ); + callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null); + return; + } + + this.logService.debug( "listenPasskeyAssertion without user interface", clientId, sequenceNumber, request, ); - // For some reason the credentialId is passed as an empty array in the request, so we need to - // get it from the cipher. For that we use the recordIdentifier, which is the cipherId. - if (request.recordIdentifier && request.credentialId.length === 0) { - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(getOptionalUserId), - ); - if (!activeUserId) { - this.logService.error("listenPasskeyAssertion error", "Active user not found"); - callback(new Error("Active user not found"), null); - return; - } - - const cipher = await this.cipherService.get(request.recordIdentifier, activeUserId); - if (!cipher) { - this.logService.error("listenPasskeyAssertion error", "Cipher not found"); - callback(new Error("Cipher not found"), null); - return; - } - - const decrypted = await this.cipherService.decrypt(cipher, activeUserId); - - const fido2Credential = decrypted.login.fido2Credentials?.[0]; - if (!fido2Credential) { - this.logService.error("listenPasskeyAssertion error", "Fido2Credential not found"); - callback(new Error("Fido2Credential not found"), null); - return; - } - - request.credentialId = Array.from( - new Uint8Array(parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId)), - ); - } - const controller = new AbortController(); - void this.fido2AuthenticatorService - .getAssertion( - this.convertAssertionRequest(request), - { windowXy: request.windowXy }, + + try { + // For some reason the credentialId is passed as an empty array in the request, so we need to + // get it from the cipher. For that we use the recordIdentifier, which is the cipherId. + if (request.recordIdentifier && request.credentialId.length === 0) { + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(getOptionalUserId), + ); + if (!activeUserId) { + this.logService.error("listenPasskeyAssertion error", "Active user not found"); + callback(new Error("Active user not found"), null); + return; + } + + const cipher = await this.cipherService.get(request.recordIdentifier, activeUserId); + if (!cipher) { + this.logService.error("listenPasskeyAssertion error", "Cipher not found"); + callback(new Error("Cipher not found"), null); + return; + } + + const decrypted = await this.cipherService.decrypt(cipher, activeUserId); + + const fido2Credential = decrypted.login.fido2Credentials?.[0]; + if (!fido2Credential) { + this.logService.error("listenPasskeyAssertion error", "Fido2Credential not found"); + callback(new Error("Fido2Credential not found"), null); + return; + } + + request.credentialId = Array.from( + new Uint8Array(parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId)), + ); + } + + const response = await this.fido2AuthenticatorService.getAssertion( + this.convertAssertionRequest(request, true), + { windowXy: normalizePosition(request.windowXy) }, controller, - ) - .then((response) => { - callback(null, this.convertAssertionResponse(request, response)); - }) - .catch((error) => { - this.logService.error("listenPasskeyAssertion error", error); - callback(error, null); - }); + ); + + callback(null, this.convertAssertionResponse(request, response)); + } catch (error) { + this.logService.error("listenPasskeyAssertion error", error); + callback(error, null); + return; + } }, ); ipc.autofill.listenPasskeyAssertion(async (clientId, sequenceNumber, request, callback) => { - this.logService.warning("listenPasskeyAssertion", clientId, sequenceNumber, request); + if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) { + this.logService.debug( + "listenPasskeyAssertion: MacOsNativeCredentialSync feature flag is disabled", + ); + callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null); + return; + } + + this.logService.debug("listenPasskeyAssertion", clientId, sequenceNumber, request); const controller = new AbortController(); - void this.fido2AuthenticatorService - .getAssertion( + try { + const response = await this.fido2AuthenticatorService.getAssertion( this.convertAssertionRequest(request), - { windowXy: request.windowXy }, + { windowXy: normalizePosition(request.windowXy) }, controller, - ) - .then((response) => { - callback(null, this.convertAssertionResponse(request, response)); - }) - .catch((error) => { - this.logService.error("listenPasskeyAssertion error", error); - callback(error, null); - }); + ); + + callback(null, this.convertAssertionResponse(request, response)); + } catch (error) { + this.logService.error("listenPasskeyAssertion error", error); + callback(error, null); + } }); + + // Listen for native status messages + ipc.autofill.listenNativeStatus(async (clientId, sequenceNumber, status) => { + if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) { + this.logService.debug( + "listenNativeStatus: MacOsNativeCredentialSync feature flag is disabled", + ); + return; + } + + this.logService.info("Received native status", status.key, status.value); + if (status.key === "request-sync") { + // perform ad-hoc sync + await this.adHocSync(); + } + }); + + ipc.autofill.listenerReady(); } private convertRegistrationRequest( @@ -269,7 +360,10 @@ export class DesktopAutofillService implements OnDestroy { alg, type: "public-key", })), - excludeCredentialDescriptorList: [], + excludeCredentialDescriptorList: request.excludedCredentials.map((credentialId) => ({ + id: new Uint8Array(credentialId), + type: "public-key" as const, + })), requireResidentKey: true, requireUserVerification: request.userVerification === "required" || request.userVerification === "preferred", @@ -301,18 +395,19 @@ export class DesktopAutofillService implements OnDestroy { request: | autofill.PasskeyAssertionRequest | autofill.PasskeyAssertionWithoutUserInterfaceRequest, + assumeUserPresence: boolean = false, ): Fido2AuthenticatorGetAssertionParams { let allowedCredentials; if ("credentialId" in request) { allowedCredentials = [ { - id: new Uint8Array(request.credentialId), + id: new Uint8Array(request.credentialId).buffer, type: "public-key" as const, }, ]; } else { allowedCredentials = request.allowedCredentials.map((credentialId) => ({ - id: new Uint8Array(credentialId), + id: new Uint8Array(credentialId).buffer, type: "public-key" as const, })); } @@ -325,7 +420,7 @@ export class DesktopAutofillService implements OnDestroy { requireUserVerification: request.userVerification === "required" || request.userVerification === "preferred", fallbackSupported: false, - assumeUserPresence: true, // For desktop assertions, it's safe to assume UP has been checked by OS dialogues + assumeUserPresence, }; } @@ -350,3 +445,13 @@ export class DesktopAutofillService implements OnDestroy { this.destroy$.complete(); } } + +function normalizePosition(position: { x: number; y: number }): { x: number; y: number } { + // Add 100 pixels to the x-coordinate to offset the native OS dialog positioning. + const xPositionOffset = 100; + + return { + x: Math.round(position.x + xPositionOffset), + y: Math.round(position.y), + }; +} 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 24ec3907a62..7ee889e7b81 100644 --- a/apps/desktop/src/autofill/services/desktop-autotype.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autotype.service.ts @@ -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,11 +67,8 @@ 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); }); } @@ -176,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/autofill/services/desktop-fido2-user-interface.service.ts b/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts index 3caf13fa5b7..19946ab590c 100644 --- a/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts +++ b/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts @@ -66,7 +66,7 @@ export class DesktopFido2UserInterfaceService nativeWindowObject: NativeWindowObject, abortController?: AbortController, ): Promise { - this.logService.warning("newSession", fallbackSupported, abortController, nativeWindowObject); + this.logService.debug("newSession", fallbackSupported, abortController, nativeWindowObject); const session = new DesktopFido2UserInterfaceSession( this.authService, this.cipherService, @@ -94,9 +94,11 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi ) {} private confirmCredentialSubject = new Subject(); - private createdCipher: Cipher; - private availableCipherIdsSubject = new BehaviorSubject(null); + private updatedCipher: CipherView; + + private rpId = new BehaviorSubject(null); + private availableCipherIdsSubject = new BehaviorSubject([""]); /** * Observable that emits available cipher IDs once they're confirmed by the UI */ @@ -114,7 +116,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi assumeUserPresence, masterPasswordRepromptRequired, }: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> { - this.logService.warning("pickCredential desktop function", { + this.logService.debug("pickCredential desktop function", { cipherIds, userVerification, assumeUserPresence, @@ -123,6 +125,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi try { // Check if we can return the credential without user interaction + await this.accountService.setShowHeader(false); if (assumeUserPresence && cipherIds.length === 1 && !masterPasswordRepromptRequired) { this.logService.debug( "shortcut - Assuming user presence and returning cipherId", @@ -136,22 +139,27 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi // make the cipherIds available to the UI. this.availableCipherIdsSubject.next(cipherIds); - await this.showUi("/passkeys", this.windowObject.windowXy); + await this.showUi("/fido2-assertion", this.windowObject.windowXy, false); const chosenCipherResponse = await this.waitForUiChosenCipher(); this.logService.debug("Received chosen cipher", chosenCipherResponse); return { - cipherId: chosenCipherResponse.cipherId, - userVerified: chosenCipherResponse.userVerified, + cipherId: chosenCipherResponse?.cipherId, + userVerified: chosenCipherResponse?.userVerified, }; } finally { // Make sure to clean up so the app is never stuck in modal mode? await this.desktopSettingsService.setModalMode(false); + await this.accountService.setShowHeader(true); } } + async getRpId(): Promise { + return firstValueFrom(this.rpId.pipe(filter((id) => id != null))); + } + confirmChosenCipher(cipherId: string, userVerified: boolean = false): void { this.chosenCipherSubject.next({ cipherId, userVerified }); this.chosenCipherSubject.complete(); @@ -159,7 +167,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi private async waitForUiChosenCipher( timeoutMs: number = 60000, - ): Promise<{ cipherId: string; userVerified: boolean } | undefined> { + ): Promise<{ cipherId?: string; userVerified: boolean } | undefined> { try { return await lastValueFrom(this.chosenCipherSubject.pipe(timeout(timeoutMs))); } catch { @@ -174,7 +182,10 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi /** * Notifies the Fido2UserInterfaceSession that the UI operations has completed and it can return to the OS. */ - notifyConfirmNewCredential(confirmed: boolean): void { + notifyConfirmCreateCredential(confirmed: boolean, updatedCipher?: CipherView): void { + if (updatedCipher) { + this.updatedCipher = updatedCipher; + } this.confirmCredentialSubject.next(confirmed); this.confirmCredentialSubject.complete(); } @@ -195,60 +206,79 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi async confirmNewCredential({ credentialName, userName, + userHandle, userVerification, rpId, }: NewCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> { - this.logService.warning( + this.logService.debug( "confirmNewCredential", credentialName, userName, + userHandle, userVerification, rpId, ); + this.rpId.next(rpId); try { - await this.showUi("/passkeys", this.windowObject.windowXy); + await this.showUi("/fido2-creation", this.windowObject.windowXy, false); // Wait for the UI to wrap up const confirmation = await this.waitForUiNewCredentialConfirmation(); if (!confirmation) { return { cipherId: undefined, userVerified: false }; } - // Create the credential - await this.createCredential({ - credentialName, - userName, - rpId, - userHandle: "", - userVerification, - }); - // wait for 10ms to help RXJS catch up(?) - // We sometimes get a race condition from this.createCredential not updating cipherService in time - //console.log("waiting 10ms.."); - //await new Promise((resolve) => setTimeout(resolve, 10)); - //console.log("Just waited 10ms"); - - // Return the new cipher (this.createdCipher) - return { cipherId: this.createdCipher.id, userVerified: userVerification }; + if (this.updatedCipher) { + await this.updateCredential(this.updatedCipher); + return { cipherId: this.updatedCipher.id, userVerified: userVerification }; + } else { + // Create the cipher + const createdCipher = await this.createCipher({ + credentialName, + userName, + rpId, + userHandle, + userVerification, + }); + return { cipherId: createdCipher.id, userVerified: userVerification }; + } } finally { // Make sure to clean up so the app is never stuck in modal mode? await this.desktopSettingsService.setModalMode(false); + await this.accountService.setShowHeader(true); } } - private async showUi(route: string, position?: { x: number; y: number }): Promise { + private async hideUi(): Promise { + await this.desktopSettingsService.setModalMode(false); + await this.router.navigate(["/"]); + } + + private async showUi( + route: string, + position?: { x: number; y: number }, + showTrafficButtons: boolean = false, + disableRedirect?: boolean, + ): Promise { // Load the UI: - await this.desktopSettingsService.setModalMode(true, position); - await this.router.navigate(["/passkeys"]); + await this.desktopSettingsService.setModalMode(true, showTrafficButtons, position); + await this.accountService.setShowHeader(showTrafficButtons); + await this.router.navigate([ + route, + { + "disable-redirect": disableRedirect || null, + }, + ]); } /** - * Can be called by the UI to create a new credential with user input etc. + * Can be called by the UI to create a new cipher with user input etc. * @param param0 */ - async createCredential({ credentialName, userName, rpId }: NewCredentialParams): Promise { + async createCipher({ credentialName, userName, rpId }: NewCredentialParams): Promise { // Store the passkey on a new cipher to avoid replacing something important + const cipher = new CipherView(); cipher.name = credentialName; @@ -267,32 +297,81 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); + if (!activeUserId) { + throw new Error("No active user ID found!"); + } + const encCipher = await this.cipherService.encrypt(cipher, activeUserId); - const createdCipher = await this.cipherService.createWithServer(encCipher); - this.createdCipher = createdCipher; + try { + const createdCipher = await this.cipherService.createWithServer(encCipher); - return createdCipher; + return createdCipher; + } catch { + throw new Error("Unable to create cipher"); + } + } + + async updateCredential(cipher: CipherView): Promise { + this.logService.info("updateCredential"); + await firstValueFrom( + this.accountService.activeAccount$.pipe( + map(async (a) => { + if (a) { + const encCipher = await this.cipherService.encrypt(cipher, a.id); + await this.cipherService.updateWithServer(encCipher); + } + }), + ), + ); } async informExcludedCredential(existingCipherIds: string[]): Promise { - this.logService.warning("informExcludedCredential", existingCipherIds); + this.logService.debug("informExcludedCredential", existingCipherIds); + + // make the cipherIds available to the UI. + this.availableCipherIdsSubject.next(existingCipherIds); + + await this.accountService.setShowHeader(false); + await this.showUi("/fido2-excluded", this.windowObject.windowXy, false); } async ensureUnlockedVault(): Promise { - this.logService.warning("ensureUnlockedVault"); + this.logService.debug("ensureUnlockedVault"); const status = await firstValueFrom(this.authService.activeAccountStatus$); if (status !== AuthenticationStatus.Unlocked) { - throw new Error("Vault is not unlocked"); + await this.showUi("/lock", this.windowObject.windowXy, true, true); + + let status2: AuthenticationStatus; + try { + status2 = await lastValueFrom( + this.authService.activeAccountStatus$.pipe( + filter((s) => s === AuthenticationStatus.Unlocked), + take(1), + timeout(1000 * 60 * 5), // 5 minutes + ), + ); + } catch (error) { + this.logService.warning("Error while waiting for vault to unlock", error); + } + + if (status2 === AuthenticationStatus.Unlocked) { + await this.router.navigate(["/"]); + } + + if (status2 !== AuthenticationStatus.Unlocked) { + await this.hideUi(); + throw new Error("Vault is not unlocked"); + } } } async informCredentialNotFound(): Promise { - this.logService.warning("informCredentialNotFound"); + this.logService.debug("informCredentialNotFound"); } async close() { - this.logService.warning("close"); + this.logService.debug("close"); } } diff --git a/apps/desktop/src/billing/app/accounts/premium.component.html b/apps/desktop/src/billing/app/accounts/premium.component.html index d88602bed1e..c5f9722f133 100644 --- a/apps/desktop/src/billing/app/accounts/premium.component.html +++ b/apps/desktop/src/billing/app/accounts/premium.component.html @@ -13,7 +13,7 @@