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 ed7fcac96e6..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 diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 997812735de..96e16776545 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -197,7 +197,6 @@ "nx", "oo7", "oslog", - "parse5", "pin-project", "pkg", "postcss", @@ -215,6 +214,8 @@ "simplelog", "style-loader", "sysinfo", + "tokio", + "tokio-util", "tracing", "tracing-subscriber", "ts-node", @@ -261,6 +262,11 @@ 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. diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 03a09ac8c48..6978edd8b3c 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -175,9 +175,23 @@ jobs: - name: Check out repo 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: @@ -186,7 +200,7 @@ jobs: node-version: ${{ env._NODE_VERSION }} - name: Cache Rust dependencies - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: | apps/desktop/desktop_native -> target @@ -195,7 +209,7 @@ jobs: - 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 @@ -248,10 +262,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 x86_64-unknown-linux-musl - node build.js --target=x86_64-unknown-linux-musl + node build.js --release - name: Build application run: npm run dist:lin @@ -342,7 +356,7 @@ jobs: node-version: ${{ env._NODE_VERSION }} - name: Cache Rust dependencies - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: | apps/desktop/desktop_native -> target @@ -351,7 +365,7 @@ jobs: - 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 squashfs-tools ruby ruby-dev rubygems build-essential + 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 @@ -411,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 + node build.js --release - name: Check index.d.ts generated if: github.event_name == 'pull_request' && steps.cache.outputs.cache-hit != 'true' @@ -490,7 +504,7 @@ jobs: node-version: ${{ env._NODE_VERSION }} - name: Cache Rust dependencies - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: | apps/desktop/desktop_native -> target @@ -756,7 +770,7 @@ jobs: node-version: ${{ env._NODE_VERSION }} - name: Cache Rust dependencies - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: | apps/desktop/desktop_native -> target @@ -1001,13 +1015,13 @@ jobs: - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: - python-version: '3.14' + 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@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: | apps/desktop/desktop_native -> target @@ -1020,6 +1034,7 @@ jobs: rustup show echo "GitHub ref: $GITHUB_REF" echo "GitHub event: $GITHUB_EVENT" + xcodebuild -showsdks - name: Cache Build id: build-cache @@ -1238,13 +1253,13 @@ jobs: - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: - python-version: '3.14' + 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@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: | apps/desktop/desktop_native -> target @@ -1257,6 +1272,7 @@ jobs: rustup show echo "GitHub ref: $GITHUB_REF" echo "GitHub event: $GITHUB_EVENT" + xcodebuild -showsdks - name: Get Build Cache id: build-cache @@ -1510,13 +1526,13 @@ jobs: - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: - python-version: '3.14' + 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@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: | apps/desktop/desktop_native -> target @@ -1529,6 +1545,7 @@ jobs: rustup show echo "GitHub ref: $GITHUB_REF" echo "GitHub event: $GITHUB_EVENT" + xcodebuild -showsdks - name: Get Build Cache id: build-cache diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 48d3eca2f4e..f2e6db96b30 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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) diff --git a/.github/workflows/publish-web.yml b/.github/workflows/publish-web.yml index 62d9342cf61..fb1de5a1bc5 100644 --- a/.github/workflows/publish-web.yml +++ b/.github/workflows/publish-web.yml @@ -187,6 +187,8 @@ jobs: 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 diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index faf119cce2b..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: @@ -102,11 +102,12 @@ jobs: 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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: - ref: main + ref: ${{ github.ref }} token: ${{ steps.app-token.outputs.token }} persist-credentials: true @@ -467,6 +468,7 @@ jobs: 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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 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 dfc0f28b9c6..c8f4c959c52 100644 --- a/.github/workflows/test-browser-interactions.yml +++ b/.github/workflows/test-browser-interactions.yml @@ -75,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 f53bfc39d36..faee7220e7b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -148,7 +148,7 @@ jobs: components: llvm-tools - name: Cache cargo registry - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: workspaces: "apps/desktop/desktop_native -> target" diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 5a75a21dcd8..266cf79d8b1 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -28,7 +28,7 @@ const preview: Preview = { ], parameters: { a11y: { - element: "#storybook-root", + context: "#storybook-root", }, controls: { matchers: { diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index 03d5eb0a9f6..452cbafe6d5 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -1406,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)" }, @@ -1475,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 جيغابايت وحدة تخزين مشفرة لمرفقات الملفات." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "الوصول الطارئ." }, diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 7dbd1ba3e7c..c4197f831d3 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -586,7 +586,7 @@ "message": "Arxivlənmiş elementlər ümumi axtarış nəticələrindən və avto-doldurma təkliflərindən xaric ediləcək. Bu elementi arxivləmək istədiyinizə əminsiniz?" }, "upgradeToUseArchive": { - "message": "A premium membership is required to use Archive." + "message": "Arxivi istifadə etmək üçün premium üzvlük tələb olunur." }, "edit": { "message": "Düzəliş et" @@ -598,7 +598,7 @@ "message": "Hamısına bax" }, "showAll": { - "message": "Show all" + "message": "Hamısını göstər" }, "viewLess": { "message": "Daha azına bax" @@ -1406,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)" }, @@ -1475,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." }, @@ -1874,7 +1904,7 @@ "message": "Son istifadə ili" }, "monthly": { - "message": "month" + "message": "ay" }, "expiration": { "message": "Bitmə vaxtı" @@ -4912,7 +4942,7 @@ "message": "Premium" }, "unlockFeaturesWithPremium": { - "message": "Unlock reporting, emergency access, and more security features with Premium." + "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" @@ -5825,10 +5855,10 @@ "message": "\"Premium\"a yüksəlt" }, "unlockAdvancedSecurity": { - "message": "Unlock advanced security features" + "message": "Qabaqcıl təhlükəsizlik özəlliklərinin kilidini aç" }, "unlockAdvancedSecurityDesc": { - "message": "A Premium subscription gives you more tools to stay secure and in control" + "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" diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 89651f0038e..806aedfab27 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -1406,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)" }, @@ -1475,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 ГБ зашыфраванага сховішча для далучаных файлаў." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Экстранны доступ." }, diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index b40f4b91cb4..76b20f8bd2e 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -1406,6 +1406,27 @@ "learnMore": { "message": "Научете повече" }, + "migrationsFailed": { + "message": "Възникна грешка при обновяването на настройките за шифроване." + }, + "updateEncryptionSettingsTitle": { + "message": "Обновете настройките си за шифроване" + }, + "updateEncryptionSettingsDesc": { + "message": "Новите препоръчани настройки за шифроване ще подобрят сигурността на акаунта Ви. Въведете главната си парола, за да ги обновите сега." + }, + "confirmIdentityToContinue": { + "message": "Потвърдете самоличността си, за да продължите" + }, + "enterYourMasterPassword": { + "message": "Въведете главната си парола" + }, + "updateSettings": { + "message": "Обновяване на настройките" + }, + "later": { + "message": "По-късно" + }, "authenticatorKeyTotp": { "message": "Удостоверителен ключ (TOTP)" }, @@ -1475,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB пространство за файлове, които се шифрират." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ пространство за файлове, които се шифрират.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Авариен достъп" }, diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 4f8e7054305..4845282e825 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -1406,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)" }, @@ -1475,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "ফাইল সংযুক্তির জন্য ১ জিবি এনক্রিপ্টেড স্থান।" }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Emergency access." }, diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index 9d5631c47e2..cff1dd150cb 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 255263a6da7..ff0e2de51af 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index eff2c6c0ea7..fa8dc426c05 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -1406,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)" }, @@ -1475,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" }, diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 99fcdffcc97..45f91f5ccc7 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 865e6ff7dda..50d79ff1f9d 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -1406,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)" }, @@ -1475,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" }, diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index c013e4516ed..969f8c63de3 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -1406,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)" }, @@ -1475,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." }, @@ -2446,7 +2476,7 @@ } }, "topLayerHijackWarning": { - "message": "Diese Seite stört die Bitwarden-Nutzung. Das Bitwarden Inline-Menü wurde aus Sicherheitsgründen vorübergehend deaktiviert." + "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" @@ -5813,7 +5843,7 @@ "message": "Notfallzugriff" }, "breachMonitoring": { - "message": "Datenleck-Überwachung" + "message": "Datendiebstahl-Überwachung" }, "andMoreFeatures": { "message": "Und mehr!" diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index 476a6165a8e..2be53da65ec 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -1406,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)" }, @@ -1475,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 GB κρυπτογραφημένο αποθηκευτικό χώρο για συνημμένα αρχεία." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Πρόσβαση έκτακτης ανάγκης." }, diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 21149499485..09ea964823c 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1406,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)" }, @@ -1436,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" }, @@ -1445,6 +1475,9 @@ "selectFile": { "message": "Select a file" }, + "itemsTransferred": { + "message": "Items transferred" + }, "maxFileSize": { "message": "Maximum file size is 500 MB." }, @@ -1475,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." }, @@ -3210,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" }, @@ -5818,8 +5857,8 @@ "andMoreFeatures": { "message": "And more!" }, - "planDescPremium": { - "message": "Complete online security" + "advancedOnlineSecurity": { + "message": "Advanced online security" }, "upgradeToPremium": { "message": "Upgrade to Premium" @@ -5849,7 +5888,141 @@ "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 103d45f0685..63d9214632a 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -1406,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)" }, @@ -1475,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" }, diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index 2713381986c..9ef5cb2a061 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -1406,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)" }, @@ -1475,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" }, diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index e19bd11ba28..92f6226a3de 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 7a9737f71ff..bade6b0dff9 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index b61213da989..90cfc13f6ef 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 550de006cf6..7a4c8744429 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -1406,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)" }, @@ -1475,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "۱ گیگابایت فضای ذخیره‌سازی رمزگذاری شده برای پیوست‌های پرونده." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "دسترسی اضطراری." }, diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index bdf2ebd641c..d7f0f600b9e 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -1406,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)" }, @@ -1475,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ö" }, diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 31284265b2e..50964716ad0 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index dadcd0c041e..6ddf7ea5873 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index bd657b9d4b7..5695ae16035 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index 2e9243206b9..67ca71338b2 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -1406,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)" }, @@ -1475,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 ג'יגה של מקום אחסון עבור קבצים מצורפים." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "גישת חירום." }, diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 58db7ac8ad6..b839e31cd96 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index 8a5a09aec9c..da78924b1eb 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index bacda584ba1..388a069e05a 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -1406,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ú)" }, @@ -1475,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" }, diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 88147f804d1..d18b25c51ed 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index d4d032737b8..9bb75d7f449 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -35,7 +35,7 @@ "message": "La tua organizzazione richiede un accesso Single Sign-On (SSO)." }, "welcomeBack": { - "message": "Bentornato/a" + "message": "Bentornato" }, "setAStrongPassword": { "message": "Imposta una password robusta" @@ -586,7 +586,7 @@ "message": "Gli elementi archiviati sono esclusi dai risultati di ricerca e suggerimenti di autoriempimento. Vuoi davvero archiviare questo elemento?" }, "upgradeToUseArchive": { - "message": "A premium membership is required to use Archive." + "message": "Per utilizzare Archivio è necessario un abbonamento premium." }, "edit": { "message": "Modifica" @@ -598,7 +598,7 @@ "message": "Mostra tutto" }, "showAll": { - "message": "Show all" + "message": "Mostra tutto" }, "viewLess": { "message": "Vedi meno" @@ -1406,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)" }, @@ -1475,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." }, @@ -1874,7 +1904,7 @@ "message": "Anno di scadenza" }, "monthly": { - "message": "month" + "message": "mese" }, "expiration": { "message": "Scadenza" @@ -5825,10 +5855,10 @@ "message": "Aggiorna a Premium" }, "unlockAdvancedSecurity": { - "message": "Unlock advanced security features" + "message": "Sblocca funzionalità di sicurezza avanzate" }, "unlockAdvancedSecurityDesc": { - "message": "A Premium subscription gives you more tools to stay secure and in control" + "message": "Un abbonamento Premium ti offre più strumenti per rimanere sicuro e in controllo" }, "explorePremium": { "message": "Scopri Premium" diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index c13b139e13a..0b0883beaf3 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -1406,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)" }, @@ -1475,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1GB の暗号化されたファイルストレージ" }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "緊急アクセス" }, diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index a54a0f2c657..ebb01f095f3 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index f829937ac51..684a04d9175 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index bd2be23828c..05ea413b522 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index 1ad08accfc9..b35fe8283f7 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -1406,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)" }, @@ -1475,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1GB의 암호화된 파일 저장소." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "비상 접근" }, diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index 46ffb24e3df..7fcc2df0330 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index 754781dc4f7..a719320fc8c 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -1406,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)" }, @@ -1475,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" }, diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index 2f7f8d30e74..b72d82cd93f 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index 70f8fe5393c..03c3b4a70ae 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index f829937ac51..684a04d9175 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index 0eba9c953a3..6226d26312f 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index f829937ac51..684a04d9175 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index 601aac16f94..04e6e1bb7f3 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index f829937ac51..684a04d9175 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index f829937ac51..684a04d9175 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 9741f94da36..24729a2331b 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 319d415b612..c7a5300a873 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -1406,6 +1406,27 @@ "learnMore": { "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)" }, @@ -1475,6 +1496,15 @@ "ppremiumSignUpStorage": { "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." }, diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 6b8568ddb1b..c03f3038f98 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index db7a1b8c657..85db8d8a4f3 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 0535027daa9..58f2b372459 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -1406,6 +1406,27 @@ "learnMore": { "message": "Подробнее" }, + "migrationsFailed": { + "message": "Произошла ошибка при обновлении настроек шифрования." + }, + "updateEncryptionSettingsTitle": { + "message": "Обновите настройки шифрования" + }, + "updateEncryptionSettingsDesc": { + "message": "Новые рекомендуемые настройки шифрования повысят безопасность вашего аккаунта. Введите мастер-пароль, чтобы обновить сейчас." + }, + "confirmIdentityToContinue": { + "message": "Подтвердите вашу личность, чтобы продолжить" + }, + "enterYourMasterPassword": { + "message": "Введите ваш мастер-пароль" + }, + "updateSettings": { + "message": "Обновить настройки" + }, + "later": { + "message": "Позже" + }, "authenticatorKeyTotp": { "message": "Ключ аутентификатора (TOTP)" }, @@ -1475,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 ГБ зашифрованного хранилища для вложенных файлов." }, + "premiumSignUpStorageV2": { + "message": "Зашифрованного хранилища для вложенных файлов: $SIZE$", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Экстренный доступ" }, diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index bb46b283322..ead646d0ac0 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 283442d95da..70620e6c5e5 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -1406,6 +1406,27 @@ "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": "Overovací kľúč (TOTP)" }, @@ -1475,6 +1496,15 @@ "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." }, diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index 2d20050d7f1..4867f4b89f2 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 0c7562987fc..c04c113caca 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -1406,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": "Једнократни код" }, @@ -1475,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1ГБ шифровано складиште за прилоге." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Хитан приступ." }, diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index f5d55585b80..a6cf12541fd 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index 4d185501855..934cb8e1a01 100644 --- a/apps/browser/src/_locales/ta/messages.json +++ b/apps/browser/src/_locales/ta/messages.json @@ -1406,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)" }, @@ -1475,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "கோப்பு இணைப்புகளுக்கு 1 GB என்க்ரிப்ட் செய்யப்பட்ட ஸ்டோரேஜ்." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "அவசர அணுகல்." }, diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index f829937ac51..684a04d9175 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index ef6ba5b2077..bade59ad99b 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index e6004ef387f..0ba7e2ffec3 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -1406,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)" }, @@ -1475,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." }, diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index a1922a5abd8..147e63cce02 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -1406,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)" }, @@ -1475,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "1 ГБ зашифрованого сховища для файлів." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "Екстрений доступ." }, diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index 415cf474af0..a94a38ffb63 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -586,7 +586,7 @@ "message": "Các mục đã lưu trữ sẽ bị loại khỏi kết quả tìm kiếm chung và gợi ý tự động điền. Bạn có chắc chắn muốn lưu trữ mục này không?" }, "upgradeToUseArchive": { - "message": "A premium membership is required to use Archive." + "message": "Cần là thành viên premium để sử dụng Archive." }, "edit": { "message": "Sửa" @@ -598,10 +598,10 @@ "message": "Xem tất cả" }, "showAll": { - "message": "Show all" + "message": "Hiện tất cả" }, "viewLess": { - "message": "View less" + "message": "Ẩn bớt" }, "viewLogin": { "message": "Xem đăng nhập" @@ -806,10 +806,10 @@ "message": "Mỗi khi khóa máy" }, "onIdle": { - "message": "On system idle" + "message": "Khi hệ thống rãnh rỗi" }, "onSleep": { - "message": "On system sleep" + "message": "Khi hệ thống ngủ" }, "onRestart": { "message": "Mỗi khi khởi động lại trình duyệt" @@ -1406,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)" }, @@ -1475,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." }, @@ -1874,7 +1904,7 @@ "message": "Năm hết hạn" }, "monthly": { - "message": "month" + "message": "tháng" }, "expiration": { "message": "Hết hạn" @@ -2446,7 +2476,7 @@ } }, "topLayerHijackWarning": { - "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + "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" @@ -4912,7 +4942,7 @@ "message": "Cao cấp" }, "unlockFeaturesWithPremium": { - "message": "Unlock reporting, emergency access, and more security features with Premium." + "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" @@ -4999,7 +5029,7 @@ } }, "defaultLabelWithValue": { - "message": "Default ( $VALUE$ )", + "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": { @@ -5825,19 +5855,19 @@ "message": "Nâng cấp lên gói Cao cấp" }, "unlockAdvancedSecurity": { - "message": "Unlock advanced security features" + "message": "Mở khóa các tính năng bảo mật nâng cao" }, "unlockAdvancedSecurityDesc": { - "message": "A Premium subscription gives you more tools to stay secure and in control" + "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": "Explore Premium" + "message": "Khám phá Premium" }, "loadingVault": { - "message": "Loading vault" + "message": "Đang tải kho" }, "vaultLoaded": { - "message": "Vault loaded" + "message": "Đã tải kho" }, "settingDisabledByPolicy": { "message": "Cài đặt này bị vô hiệu hóa bởi chính sách tổ chức của bạn.", @@ -5850,6 +5880,6 @@ "message": "Số thẻ" }, "sessionTimeoutSettingsAction": { - "message": "Timeout action" + "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 7a0662fb9f8..fd4b6380b1b 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -1406,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)" }, @@ -1473,7 +1494,16 @@ "message": "注册高级会员将获得:" }, "ppremiumSignUpStorage": { - "message": "1 GB 文件附件加密存储。" + "message": "1 GB 文件附件加密存储空间。" + }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ 文件附件加密存储空间。", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } }, "premiumSignUpEmergency": { "message": "紧急访问。" diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index 38fb50d6c8b..de78e415fa6 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -1406,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)" }, @@ -1475,6 +1496,15 @@ "ppremiumSignUpStorage": { "message": "用於檔案附件的 1 GB 加密儲存空間。" }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpEmergency": { "message": "緊急存取" }, 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 @@
`; - const shadowRoot = document.getElementById("shadow-root").attachShadow({ mode: "open" }); + const shadowRoot = document.getElementById("shadow-root")!.attachShadow({ mode: "open" }); shadowRoot.innerHTML = ` `; @@ -1668,7 +1668,7 @@ describe("AutofillOverlayContentService", () => { pageDetailsMock, ); await flushPromises(); - buttonElement.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" })); + buttonElement?.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" })); expect(sendExtensionMessageSpy).toHaveBeenCalledWith( "formFieldSubmitted", @@ -1716,6 +1716,85 @@ describe("AutofillOverlayContentService", () => { }); }); + describe("refreshMenuLayerPosition", () => { + it("calls refreshTopLayerPosition on the inline menu content service", () => { + autofillOverlayContentService.refreshMenuLayerPosition(); + + expect(inlineMenuContentService.refreshTopLayerPosition).toHaveBeenCalled(); + }); + + it("does not throw if inline menu content service is not available", () => { + const serviceWithoutInlineMenu = new AutofillOverlayContentService( + domQueryService, + domElementVisibilityService, + inlineMenuFieldQualificationService, + ); + + expect(() => serviceWithoutInlineMenu.refreshMenuLayerPosition()).not.toThrow(); + }); + }); + + describe("getOwnedInlineMenuTagNames", () => { + it("returns tag names from the inline menu content service", () => { + inlineMenuContentService.getOwnedTagNames.mockReturnValue(["div", "span"]); + + const result = autofillOverlayContentService.getOwnedInlineMenuTagNames(); + + expect(result).toEqual(["div", "span"]); + }); + + it("returns an empty array if inline menu content service is not available", () => { + const serviceWithoutInlineMenu = new AutofillOverlayContentService( + domQueryService, + domElementVisibilityService, + inlineMenuFieldQualificationService, + ); + + const result = serviceWithoutInlineMenu.getOwnedInlineMenuTagNames(); + + expect(result).toEqual([]); + }); + }); + + describe("getUnownedTopLayerItems", () => { + it("returns unowned top layer items from the inline menu content service", () => { + const mockElements = document.querySelectorAll("div"); + inlineMenuContentService.getUnownedTopLayerItems.mockReturnValue(mockElements); + + const result = autofillOverlayContentService.getUnownedTopLayerItems(true); + + expect(result).toEqual(mockElements); + expect(inlineMenuContentService.getUnownedTopLayerItems).toHaveBeenCalledWith(true); + }); + + it("returns undefined if inline menu content service is not available", () => { + const serviceWithoutInlineMenu = new AutofillOverlayContentService( + domQueryService, + domElementVisibilityService, + inlineMenuFieldQualificationService, + ); + + const result = serviceWithoutInlineMenu.getUnownedTopLayerItems(); + + expect(result).toBeUndefined(); + }); + }); + + describe("clearUserFilledFields", () => { + it("deletes all user filled fields", () => { + const mockElement1 = document.createElement("input") as FillableFormFieldElement; + const mockElement2 = document.createElement("input") as FillableFormFieldElement; + autofillOverlayContentService["userFilledFields"] = { + username: mockElement1, + password: mockElement2, + }; + + autofillOverlayContentService.clearUserFilledFields(); + + expect(autofillOverlayContentService["userFilledFields"]).toEqual({}); + }); + }); + describe("handleOverlayRepositionEvent", () => { const repositionEvents = [EVENTS.SCROLL, EVENTS.RESIZE]; repositionEvents.forEach((repositionEvent) => { @@ -2049,7 +2128,7 @@ describe("AutofillOverlayContentService", () => { }); it("skips focusing an element if no recently focused field exists", async () => { - autofillOverlayContentService["mostRecentlyFocusedField"] = undefined; + (autofillOverlayContentService as any)["mostRecentlyFocusedField"] = null; sendMockExtensionMessage({ command: "redirectAutofillInlineMenuFocusOut", @@ -2149,7 +2228,6 @@ describe("AutofillOverlayContentService", () => { }); it("returns null if the sub frame URL cannot be parsed correctly", async () => { - delete globalThis.location; globalThis.location = { href: "invalid-base" } as Location; sendMockExtensionMessage( { 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 6f2c00a4dd4..367599f7ad0 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -1400,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/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts index f7c46a9fa77..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 @@ -945,7 +945,8 @@ export class InlineMenuFieldQualificationService !fieldType || !this.usernameFieldTypes.has(fieldType) || this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet) || - this.fieldHasDisqualifyingAttributeValue(field) + this.fieldHasDisqualifyingAttributeValue(field) || + this.isTotpField(field) ) { return false; } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 143f5d1f6b3..2540571abb0 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -297,6 +297,7 @@ 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"; @@ -738,6 +739,10 @@ export default class MainBackground { this.accountService, ); + const sessionTimeoutTypeService = new BrowserSessionTimeoutTypeService( + this.platformUtilsService, + ); + this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService( this.accountService, pinStateService, @@ -749,6 +754,7 @@ export default class MainBackground { this.stateProvider, this.logService, VaultTimeoutStringType.OnRestart, // default vault timeout + sessionTimeoutTypeService, ); this.apiService = new ApiService( @@ -835,10 +841,7 @@ export default class MainBackground { ); this.pinService = new PinService( - this.accountService, this.encryptService, - this.kdfConfigService, - this.keyGenerationService, this.logService, this.keyService, this.sdkService, @@ -1106,7 +1109,7 @@ export default class MainBackground { this.collectionService, this.keyService, this.encryptService, - this.pinService, + this.keyGenerationService, this.accountService, this.restrictedItemTypesService, ); @@ -1114,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, @@ -1128,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, diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 597babdc777..eba6b01fe90 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -294,19 +294,11 @@ export default class RuntimeBackground { await this.openPopup(); break; case VaultMessages.OpenAtRiskPasswords: { - if (await this.shouldRejectManyOriginMessage(msg)) { - return; - } - await this.main.openAtRisksPasswordsPage(); this.announcePopupOpen(); break; } case VaultMessages.OpenBrowserExtensionToUrl: { - if (await this.shouldRejectManyOriginMessage(msg)) { - return; - } - await this.main.openTheExtensionToPage(msg.url); this.announcePopupOpen(); break; 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 47d72751af3..fea3e558057 100644 --- a/apps/browser/src/billing/popup/settings/premium-v2.component.html +++ b/apps/browser/src/billing/popup/settings/premium-v2.component.html @@ -12,7 +12,7 @@
  • - {{ "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/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 index 297718687eb..24925e25e24 100644 --- 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 @@ -1,56 +1,24 @@ -import { defer, Observable, of } from "rxjs"; - +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { VaultTimeout, - VaultTimeoutOption, VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui"; -export class BrowserSessionTimeoutSettingsComponentService - implements SessionTimeoutSettingsComponentService -{ - availableTimeoutOptions$: Observable = defer(() => { - const options: VaultTimeoutOption[] = [ - { name: this.i18nService.t("immediately"), value: 0 }, - { name: this.i18nService.t("oneMinute"), value: 1 }, - { name: this.i18nService.t("fiveMinutes"), value: 5 }, - { name: this.i18nService.t("fifteenMinutes"), value: 15 }, - { name: this.i18nService.t("thirtyMinutes"), value: 30 }, - { name: this.i18nService.t("oneHour"), value: 60 }, - { name: this.i18nService.t("fourHours"), value: 240 }, - ]; - - const showOnLocked = - !this.platformUtilsService.isFirefox() && - !this.platformUtilsService.isSafari() && - !(this.platformUtilsService.isOpera() && navigator.platform === "MacIntel"); - - if (showOnLocked) { - options.push({ - name: this.i18nService.t("onLocked"), - value: VaultTimeoutStringType.OnLocked, - }); - } - - options.push( - { name: this.i18nService.t("onRestart"), value: VaultTimeoutStringType.OnRestart }, - { name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never }, - ); - - return of(options); - }); - +export class BrowserSessionTimeoutSettingsComponentService extends SessionTimeoutSettingsComponentService { constructor( - private readonly i18nService: I18nService, - private readonly platformUtilsService: PlatformUtilsService, + i18nService: I18nService, + sessionTimeoutTypeService: SessionTimeoutTypeService, + policyService: PolicyService, private readonly messagingService: MessagingService, - ) {} + ) { + super(i18nService, sessionTimeoutTypeService, policyService); + } - onTimeoutSave(timeout: VaultTimeout): void { + 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/platform/popup/layout/popup-tab-navigation.component.ts b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts index 4abd9cd4803..26138d57954 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts @@ -22,7 +22,7 @@ export type NavButton = { templateUrl: "popup-tab-navigation.component.html", imports: [CommonModule, LinkModule, RouterModule, JslibModule, IconModule], host: { - class: "tw-block tw-h-full tw-w-full tw-flex tw-flex-col", + class: "tw-block tw-size-full tw-flex tw-flex-col", }, }) export class PopupTabNavigationComponent { diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index a36396afa1a..eb64c076192 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -43,11 +43,16 @@ import { TwoFactorAuthGuard, } from "@bitwarden/auth/angular"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components"; -import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui"; +import { + LockComponent, + ConfirmKeyConnectorDomainComponent, + RemovePasswordComponent, +} from "@bitwarden/key-management-ui"; import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component"; import { AuthExtensionRoute } from "../auth/popup/constants/auth-extension-route.constant"; import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard"; +import { platformPopoutGuard } from "../auth/popup/guards/platform-popout.guard"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { ExtensionDeviceManagementComponent } from "../auth/popup/settings/extension-device-management.component"; import { Fido2Component } from "../autofill/popup/fido2/fido2.component"; @@ -58,7 +63,6 @@ import { NotificationsSettingsComponent } from "../autofill/popup/settings/notif import { PremiumV2Component } from "../billing/popup/settings/premium-v2.component"; import { PhishingWarning } from "../dirt/phishing-detection/popup/phishing-warning.component"; import { ProtectedByComponent } from "../dirt/phishing-detection/popup/protected-by-component"; -import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; import BrowserPopupUtils from "../platform/browser/browser-popup-utils"; import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service"; import { RouteCacheOptions } from "../platform/services/popup-view-cache-background.service"; @@ -187,9 +191,22 @@ const routes: Routes = [ }, { path: "remove-password", - component: RemovePasswordComponent, + component: ExtensionAnonLayoutWrapperComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, + children: [ + { + path: "", + component: RemovePasswordComponent, + data: { + pageTitle: { + key: "verifyYourOrganization", + }, + showBackButton: false, + pageIcon: LockIcon, + } satisfies ExtensionAnonLayoutWrapperData, + }, + ], }, { path: "view-cipher", @@ -414,7 +431,7 @@ const routes: Routes = [ }, { path: AuthRoute.LoginWithPasskey, - canActivate: [unauthGuardFn(unauthRouteOverrides)], + canActivate: [unauthGuardFn(unauthRouteOverrides), platformPopoutGuard(["linux"])], data: { pageIcon: TwoFactorAuthSecurityKeyIcon, pageTitle: { @@ -645,7 +662,7 @@ const routes: Routes = [ component: ConfirmKeyConnectorDomainComponent, data: { pageTitle: { - key: "confirmKeyConnectorDomain", + key: "verifyYourOrganization", }, showBackButton: true, pageIcon: DomainIcon, diff --git a/apps/browser/src/popup/app.component.html b/apps/browser/src/popup/app.component.html index 3d81354ca35..3a5c8021e17 100644 --- a/apps/browser/src/popup/app.component.html +++ b/apps/browser/src/popup/app.component.html @@ -13,8 +13,11 @@
} @else { -
- + +
+
+ +
+
- } diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 71846cc6444..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,13 +84,7 @@ import "../platform/popup/locales"; CalloutModule, LinkModule, ], - declarations: [ - AppComponent, - ColorPasswordPipe, - ColorPasswordCountPipe, - TabsV2Component, - RemovePasswordComponent, - ], + declarations: [AppComponent, ColorPasswordPipe, ColorPasswordCountPipe, TabsV2Component], exports: [CalloutModule], providers: [CurrencyPipe, DatePipe], bootstrap: [AppComponent], 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 e57e98fd0cc..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! + */ + +$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/services.module.ts b/apps/browser/src/popup/services/services.module.ts index c462319dc2e..bb89eff1147 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -76,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, @@ -135,6 +136,7 @@ import { DialogService, ToastService, } from "@bitwarden/components"; +import { GeneratorServicesModule } from "@bitwarden/generator-components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { BiometricsService, @@ -170,6 +172,7 @@ import { InlineMenuFieldQualificationService } from "../../autofill/services/inl 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"; @@ -723,15 +726,25 @@ const safeProviders: SafeProvider[] = [ useClass: ExtensionNewDeviceVerificationComponentService, deps: [], }), + safeProvider({ + provide: SessionTimeoutTypeService, + useClass: BrowserSessionTimeoutTypeService, + deps: [PlatformUtilsService], + }), safeProvider({ provide: SessionTimeoutSettingsComponentService, useClass: BrowserSessionTimeoutSettingsComponentService, - deps: [I18nServiceAbstraction, PlatformUtilsService, MessagingServiceAbstraction], + 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/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts index 1bffcd9ad51..f2c9d470816 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts @@ -381,4 +381,88 @@ describe("AddEditV2Component", () => { expect(navigate).toHaveBeenCalledWith(["/tabs/vault"]); }); }); + + describe("reloadAddEditCipherData", () => { + beforeEach(fakeAsync(() => { + addEditCipherInfo$.next({ + cipher: { + name: "InitialName", + type: CipherType.Login, + login: { + password: "initialPassword", + username: "initialUsername", + uris: [{ uri: "https://initial.com" }], + }, + }, + } as AddEditCipherInfo); + queryParams$.next({}); + tick(); + + cipherServiceMock.setAddEditCipherInfo.mockClear(); + })); + + it("replaces all initialValues with new data, clearing stale fields", fakeAsync(() => { + const newCipherInfo = { + cipher: { + name: "UpdatedName", + type: CipherType.Login, + login: { + password: "updatedPassword", + uris: [{ uri: "https://updated.com" }], + }, + }, + } as AddEditCipherInfo; + + addEditCipherInfo$.next(newCipherInfo); + + const messageListener = component["messageListener"]; + messageListener({ command: "reloadAddEditCipherData" }); + tick(); + + expect(component.config.initialValues).toEqual({ + name: "UpdatedName", + password: "updatedPassword", + loginUri: "https://updated.com", + } as OptionalInitialValues); + + expect(cipherServiceMock.setAddEditCipherInfo).toHaveBeenCalledWith(null, "UserId"); + })); + + it("does not reload data if config is not set", fakeAsync(() => { + component.config = null; + + const messageListener = component["messageListener"]; + messageListener({ command: "reloadAddEditCipherData" }); + tick(); + + expect(cipherServiceMock.setAddEditCipherInfo).not.toHaveBeenCalled(); + })); + + it("does not reload data if latestCipherInfo is null", fakeAsync(() => { + addEditCipherInfo$.next(null); + + const messageListener = component["messageListener"]; + messageListener({ command: "reloadAddEditCipherData" }); + tick(); + + expect(component.config.initialValues).toEqual({ + name: "InitialName", + password: "initialPassword", + username: "initialUsername", + loginUri: "https://initial.com", + } as OptionalInitialValues); + + expect(cipherServiceMock.setAddEditCipherInfo).not.toHaveBeenCalled(); + })); + + it("ignores messages with different commands", fakeAsync(() => { + const initialValues = component.config.initialValues; + + const messageListener = component["messageListener"]; + messageListener({ command: "someOtherCommand" }); + tick(); + + expect(component.config.initialValues).toBe(initialValues); + })); + }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts index 60e44cefbdf..22aad854dd0 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; +import { Component, OnInit, OnDestroy } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; import { ActivatedRoute, Params, Router } from "@angular/router"; @@ -158,7 +158,7 @@ export type AddEditQueryParams = Partial>; IconButtonModule, ], }) -export class AddEditV2Component implements OnInit { +export class AddEditV2Component implements OnInit, OnDestroy { headerText: string; config: CipherFormConfig; canDeleteCipher$: Observable; @@ -200,12 +200,58 @@ export class AddEditV2Component implements OnInit { this.subscribeToParams(); } + private messageListener: (message: any) => void; + async ngOnInit() { this.fido2PopoutSessionData = await firstValueFrom(this.fido2PopoutSessionData$); if (BrowserPopupUtils.inPopout(window)) { this.popupCloseWarningService.enable(); } + + // Listen for messages to reload cipher data when the pop up is already open + this.messageListener = async (message: any) => { + if (message?.command === "reloadAddEditCipherData") { + try { + await this.reloadCipherData(); + } catch (error) { + this.logService.error("Failed to reload cipher data", error); + } + } + }; + BrowserApi.addListener(chrome.runtime.onMessage, this.messageListener); + } + + ngOnDestroy() { + if (this.messageListener) { + BrowserApi.removeListener(chrome.runtime.onMessage, this.messageListener); + } + } + + /** + * Reloads the cipher data when the popup is already open and new form data is submitted. + * This completely replaces the initialValues to clear any stale data from the previous submission. + */ + private async reloadCipherData() { + if (!this.config) { + return; + } + + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + const latestCipherInfo = await firstValueFrom( + this.cipherService.addEditCipherInfo$(activeUserId), + ); + + if (latestCipherInfo != null) { + this.config = { + ...this.config, + initialValues: mapAddEditCipherInfoToInitialValues(latestCipherInfo), + }; + + // Be sure to clear the "cached" cipher info, so it doesn't get used again + await this.cipherService.setAddEditCipherInfo(null, activeUserId); + } } /** diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts index 871163ac80b..1da2d352c14 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts @@ -1,4 +1,4 @@ -import { Component, Input } from "@angular/core"; +import { Component, input, ChangeDetectionStrategy } from "@angular/core"; import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; import { ActivatedRoute, Router } from "@angular/router"; @@ -25,31 +25,23 @@ import { PopupRouterCacheService } from "../../../../../platform/popup/view-cach import { AttachmentsV2Component } from "./attachments-v2.component"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "popup-header", template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, }) class MockPopupHeaderComponent { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() pageTitle: string; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() backAction: () => void; + readonly pageTitle = input(); + readonly backAction = input<() => void>(); } -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "popup-footer", template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, }) class MockPopupFooterComponent { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() pageTitle: string; + readonly pageTitle = input(); } describe("AttachmentsV2Component", () => { @@ -120,7 +112,7 @@ describe("AttachmentsV2Component", () => { const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[1] .componentInstance; - expect(cipherAttachment.submitBtn).toEqual(submitBtn); + expect(cipherAttachment.submitBtn()).toEqual(submitBtn); }); it("navigates the user to the edit view `onUploadSuccess`", fakeAsync(() => { diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts index 577b7d96771..b9f48b7407b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts @@ -108,7 +108,7 @@ describe("ItemMoreOptionsComponent", () => { { provide: RestrictedItemTypesService, useValue: { restricted$: of([]) } }, { provide: CipherArchiveService, - useValue: { userCanArchive$: () => of(true), hasArchiveFlagEnabled$: () => of(true) }, + useValue: { userCanArchive$: () => of(true), hasArchiveFlagEnabled$: of(true) }, }, { provide: ToastService, useValue: { showToast: () => {} } }, { provide: Router, useValue: { navigate: () => Promise.resolve(true) } }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index 4dfaf7bc66f..b65acc6ca8e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -141,7 +141,7 @@ export class ItemMoreOptionsComponent { }), ); - protected showArchive$: Observable = this.cipherArchiveService.hasArchiveFlagEnabled$(); + protected showArchive$: Observable = this.cipherArchiveService.hasArchiveFlagEnabled$; protected canArchive$: Observable = this.accountService.activeAccount$.pipe( getUserId, diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html index 7dd0a5a3bc7..fa8683c12dc 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html @@ -1,5 +1,5 @@ - diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts index 5563cd3033b..4b992d9f1ee 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts @@ -180,7 +180,7 @@ describe("VaultV2Component", () => { const nudgesSvc = { showNudgeSpotlight$: jest.fn().mockImplementation((_type: NudgeType) => of(false)), dismissNudge: jest.fn().mockResolvedValue(undefined), - } as Partial; + }; const dialogSvc = {} as Partial; @@ -209,6 +209,10 @@ describe("VaultV2Component", () => { .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({ @@ -256,9 +260,7 @@ describe("VaultV2Component", () => { { provide: StateProvider, useValue: mock() }, { provide: ConfigService, - useValue: { - getFeatureFlag$: (_: string) => of(false), - }, + useValue: configSvc, }, { provide: SearchService, @@ -453,7 +455,9 @@ describe("VaultV2Component", () => { hasPremiumFromAnySource$.next(false); - (nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => + configSvc.getFeatureFlag$.mockImplementation((_flag: string) => of(true)); + + nudgesSvc.showNudgeSpotlight$.mockImplementation((type: NudgeType) => of(type === NudgeType.PremiumUpgrade), ); @@ -482,9 +486,11 @@ describe("VaultV2Component", () => { })); 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$ as jest.Mock).mockImplementation((type: NudgeType) => { + nudgesSvc.showNudgeSpotlight$.mockImplementation((type: NudgeType) => { return of(type === NudgeType.EmptyVaultNudge); }); 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 9cee4f66b67..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 @@ -137,6 +137,10 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { FeatureFlag.VaultLoadingSkeletons, ); + protected premiumSpotlightFeatureFlag$ = this.configService.getFeatureFlag$( + FeatureFlag.BrowserPremiumSpotlight, + ); + private showPremiumNudgeSpotlight$ = this.activeUserId$.pipe( switchMap((userId) => this.nudgesService.showNudgeSpotlight$(NudgeType.PremiumUpgrade, userId)), ); @@ -164,6 +168,7 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { ); protected showPremiumSpotlight$ = combineLatest([ + this.premiumSpotlightFeatureFlag$, this.showPremiumNudgeSpotlight$, this.showHasItemsVaultSpotlight$, this.hasPremium$, @@ -171,8 +176,13 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { this.accountAgeInDays$, ]).pipe( map( - ([showPremiumNudge, showHasItemsNudge, hasPremium, count, age]) => - showPremiumNudge && !showHasItemsNudge && !hasPremium && count >= 5 && age >= 7, + ([featureFlagEnabled, showPremiumNudge, showHasItemsNudge, hasPremium, count, age]) => + featureFlagEnabled && + showPremiumNudge && + !showHasItemsNudge && + !hasPremium && + count >= 5 && + age >= 7, ), shareReplay({ bufferSize: 1, refCount: true }), ); diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts index c6db820c232..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 @@ -49,7 +49,7 @@ export class VaultSettingsV2Component implements OnInit, OnDestroy { this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId))), ); - protected readonly showArchiveItem = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$()); + protected readonly showArchiveItem = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$); protected readonly userHasArchivedItems = toSignal( this.userId$.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/tailwind.config.js b/apps/browser/tailwind.config.js index 134001bbf13..faaa7fa4128 100644 --- a/apps/browser/tailwind.config.js +++ b/apps/browser/tailwind.config.js @@ -12,5 +12,6 @@ config.content = [ "../../libs/vault/src/**/*.{html,ts}", "../../libs/pricing/src/**/*.{html,ts}", ]; +config.corePlugins.preflight = true; module.exports = config; diff --git a/apps/cli/package.json b/apps/cli/package.json index adddc99b4d7..0ae4bf9ce30 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -75,7 +75,7 @@ "inquirer": "8.2.6", "jsdom": "26.1.0", "jszip": "3.10.1", - "koa": "2.16.3", + "koa": "3.1.1", "koa-bodyparser": "4.4.1", "koa-json": "2.0.2", "lowdb": "1.0.0", @@ -83,12 +83,12 @@ "multer": "2.0.2", "node-fetch": "2.6.12", "node-forge": "1.3.2", - "open": "10.1.2", + "open": "11.0.0", "papaparse": "5.5.3", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", "semver": "7.7.3", - "tldts": "7.0.18", + "tldts": "7.0.19", "zxcvbn": "4.4.2" } } diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index d0ab062d0b3..d8859318b52 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -31,6 +31,7 @@ import { TwoFactorService, TwoFactorApiService } from "@bitwarden/common/auth/tw 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"; @@ -81,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) { @@ -111,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 { @@ -229,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) { @@ -277,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); } @@ -322,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."); } @@ -367,6 +402,8 @@ export class LoginCommand { } } + await this.encryptedMigrator.runMigrations(response.userId, password); + return await this.handleSuccessResponse(response); } catch (e) { if ( @@ -688,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 93e711d748f..35816b56fb2 100644 --- a/apps/cli/src/commands/get.command.ts +++ b/apps/cli/src/commands/get.command.ts @@ -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); 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 bd51cf4dd91..dbe17224d07 100644 --- a/apps/cli/src/oss-serve-configurator.ts +++ b/apps/cli/src/oss-serve-configurator.ts @@ -176,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-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/program.ts b/apps/cli/src/program.ts index a5f12b34035..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); @@ -277,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) { @@ -306,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 c163b7581b4..2d4ea7d00b5 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -76,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"; @@ -207,6 +211,7 @@ import { 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"; @@ -324,6 +329,7 @@ export class ServiceContainer { cipherEncryptionService: CipherEncryptionService; restrictedItemTypesService: RestrictedItemTypesService; cliRestrictedItemTypesService: CliRestrictedItemTypesService; + encryptedMigrator: EncryptedMigrator; securityStateService: SecurityStateService; masterPasswordUnlockService: MasterPasswordUnlockService; cipherArchiveService: CipherArchiveService; @@ -486,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, @@ -524,6 +527,8 @@ export class ServiceContainer { this.accountService, ); + const sessionTimeoutTypeService = new CliSessionTimeoutTypeService(); + this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService( this.accountService, pinStateService, @@ -535,6 +540,7 @@ export class ServiceContainer { this.stateProvider, this.logService, VaultTimeoutStringType.Never, // default vault timeout + sessionTimeoutTypeService, ); const refreshAccessTokenErrorCallback = () => { @@ -899,7 +905,7 @@ export class ServiceContainer { this.collectionService, this.keyService, this.encryptService, - this.pinService, + this.keyGenerationService, this.accountService, this.restrictedItemTypesService, ); @@ -907,7 +913,7 @@ export class ServiceContainer { this.individualExportService = new IndividualVaultExportService( this.folderService, this.cipherService, - this.pinService, + this.keyGenerationService, this.keyService, this.encryptService, this.cryptoFunctionService, @@ -921,7 +927,7 @@ export class ServiceContainer { this.organizationExportService = new OrganizationVaultExportService( this.cipherService, this.vaultExportApiService, - this.pinService, + this.keyGenerationService, this.keyService, this.encryptService, this.cryptoFunctionService, @@ -975,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/vault/create.command.ts b/apps/cli/src/vault/create.command.ts index 5602c593942..d826766dc65 100644 --- a/apps/cli/src/vault/create.command.ts +++ b/apps/cli/src/vault/create.command.ts @@ -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 3b9b8c2db27..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,9 +99,9 @@ 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" @@ -138,14 +123,14 @@ dependencies = [ [[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", @@ -347,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]] @@ -457,7 +427,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", - "windows 0.61.1", + "windows", ] [[package]] @@ -501,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" @@ -509,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" @@ -556,9 +532,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.46" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "shlex", @@ -614,7 +590,7 @@ dependencies = [ "hex", "oo7", "pbkdf2", - "rand 0.9.1", + "rand 0.9.2", "rusqlite", "security-framework", "serde", @@ -623,7 +599,7 @@ dependencies = [ "tokio", "tracing", "verifysign", - "windows 0.61.1", + "windows", ] [[package]] @@ -709,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", ] @@ -770,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" @@ -867,7 +833,7 @@ dependencies = [ "memsec", "oo7", "pin-project", - "rand 0.9.1", + "rand 0.9.2", "scopeguard", "secmem-proc", "security-framework", @@ -877,13 +843,13 @@ dependencies = [ "sha2", "ssh-key", "sysinfo", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tokio-util", "tracing", "typenum", "widestring", - "windows 0.61.1", + "windows", "windows-future", "zbus", "zbus_polkit", @@ -1409,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,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]] @@ -1663,6 +1623,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1674,9 +1644,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.177" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "libloading" @@ -1685,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]] @@ -1841,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" @@ -1889,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", @@ -1923,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" @@ -1970,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" @@ -2173,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" @@ -2190,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", @@ -2208,7 +2153,7 @@ dependencies = [ "num", "num-bigint-dig", "pbkdf2", - "rand 0.9.1", + "rand 0.9.2", "serde", "sha2", "subtle", @@ -2548,7 +2493,7 @@ dependencies = [ name = "process_isolation" version = "0.0.0" dependencies = [ - "ctor 0.5.0", + "ctor", "desktop_core", "libc", "tracing", @@ -2591,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", @@ -2660,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]] @@ -2748,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" @@ -2798,6 +2731,12 @@ dependencies = [ "rustix 1.0.7", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.20" @@ -2870,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", @@ -3068,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]] @@ -3188,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]] @@ -3239,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]] @@ -3259,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", @@ -3289,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", @@ -3303,14 +3241,14 @@ 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", @@ -3319,9 +3257,9 @@ dependencies = [ [[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", @@ -3680,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" @@ -3745,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" @@ -3852,16 +3846,6 @@ 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" @@ -3869,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", @@ -3881,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]] @@ -3902,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", @@ -3915,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" @@ -3941,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" @@ -3981,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", ] @@ -3996,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" @@ -4262,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]] @@ -4434,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", @@ -4452,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", @@ -4468,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 0b09daa9bdd..26f791fd660 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -21,13 +21,13 @@ publish = false [workspace.dependencies] aes = "=0.8.4" aes-gcm = "=0.10.3" -anyhow = "=1.0.94" +anyhow = "=1.0.100" arboard = { version = "=3.6.1", default-features = false } -ashpd = "=0.11.0" +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" @@ -37,33 +37,33 @@ 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" -libc = "=0.2.177" +libc = "=0.2.178" linux-keyutils = "=0.2.4" memsec = "=0.7.0" -napi = "=2.16.17" -napi-build = "=2.2.0" -napi-derive = "=2.16.13" -oo7 = "=0.4.3" +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" 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-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", @@ -77,7 +77,7 @@ 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" diff --git a/apps/desktop/desktop_native/build.js b/apps/desktop/desktop_native/build.js index a7ed89a9c17..e267e28a08c 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 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 e57b40b5778..7011a2cce63 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs @@ -61,8 +61,8 @@ impl InstalledBrowserRetriever for DefaultInstalledBrowserRetriever { 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()); } } @@ -114,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< @@ -126,11 +126,19 @@ pub(crate) static SUPPORTED_BROWSER_MAP: LazyLock< .collect::>() }); -fn get_browser_data_dir(config: &BrowserConfig) -> Result { - let dir = dirs::home_dir() - .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 + )) } // @@ -174,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)?; 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 14e38797640..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 @@ -18,19 +18,22 @@ use crate::{ 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 5d0b4f0c75c..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 @@ -14,31 +14,31 @@ use crate::{ 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/windows/mod.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/mod.rs index 9cc89ed2161..524b5994873 100644 --- 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 @@ -25,27 +25,27 @@ pub use signature::*; pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ BrowserConfig { name: "Brave", - data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data", + data_dir: &["AppData/Local/BraveSoftware/Brave-Browser/User Data"], }, BrowserConfig { name: "Chrome", - data_dir: "AppData/Local/Google/Chrome/User Data", + data_dir: &["AppData/Local/Google/Chrome/User Data"], }, BrowserConfig { name: "Chromium", - data_dir: "AppData/Local/Chromium/User Data", + data_dir: &["AppData/Local/Chromium/User Data"], }, BrowserConfig { name: "Microsoft Edge", - data_dir: "AppData/Local/Microsoft/Edge/User Data", + data_dir: &["AppData/Local/Microsoft/Edge/User Data"], }, BrowserConfig { name: "Opera", - data_dir: "AppData/Roaming/Opera Software/Opera Stable", + data_dir: &["AppData/Roaming/Opera Software/Opera Stable"], }, BrowserConfig { name: "Vivaldi", - data_dir: "AppData/Local/Vivaldi/User Data", + data_dir: &["AppData/Local/Vivaldi/User Data"], }, ]; diff --git a/apps/desktop/desktop_native/chromium_importer/src/metadata.rs b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs index 51a181f7f49..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(), }, ); } @@ -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.contains(&"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.contains(&"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.contains(&"file")); + assert!(meta.loaders.contains(&"file".to_owned())); } } 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 32d2eb7e6e6..669dd757c40 100644 --- a/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs +++ b/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs @@ -285,8 +285,8 @@ 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. @@ -368,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/macos_provider/Cargo.toml b/apps/desktop/desktop_native/macos_provider/Cargo.toml index 50f1834851d..8a34460268a 100644 --- a/apps/desktop/desktop_native/macos_provider/Cargo.toml +++ b/apps/desktop/desktop_native/macos_provider/Cargo.toml @@ -24,7 +24,7 @@ serde_json = { workspace = true } tokio = { workspace = true, features = ["sync"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } -tracing-oslog = "0.3.0" +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 a5a134b0bfe..8619a77a0f2 100644 --- a/apps/desktop/desktop_native/macos_provider/src/lib.rs +++ b/apps/desktop/desktop_native/macos_provider/src/lib.rs @@ -57,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, @@ -65,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. @@ -93,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("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() @@ -117,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, @@ -157,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( @@ -170,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( @@ -178,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 + } } } @@ -200,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 @@ -209,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, @@ -232,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/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 01bfa65d571..375c65edb8d 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -1,125 +1,7 @@ -/* 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. * @@ -127,49 +9,18 @@ export declare namespace 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 @@ -178,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 @@ -188,50 +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 @@ -252,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 64819be4405..0362d9ee2bb 100644 --- a/apps/desktop/desktop_native/napi/index.js +++ b/apps/desktop/desktop_native/napi/index.js @@ -82,20 +82,20 @@ switch (platform) { 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 ca17377c9f2..5401207c252 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": "node scripts/build.js", + "build": "napi build --platform --no-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 deleted file mode 100644 index a6680f5d311..00000000000 --- a/apps/desktop/desktop_native/napi/scripts/build.js +++ /dev/null @@ -1,14 +0,0 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -const { execSync } = require('child_process'); - -const args = process.argv.slice(2); -const isRelease = args.includes('--release'); - -if (isRelease) { - console.log('Building release mode.'); -} else { - console.log('Building debug mode.'); - process.env.RUST_LOG = 'debug'; -} - -execSync(`napi build --platform --js false`, { stdio: 'inherit', env: process.env }); diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index c34e7574f68..25dfdd08336 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -290,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; @@ -326,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; @@ -342,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"); }); } }); @@ -465,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 @@ -511,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 { @@ -550,12 +556,12 @@ 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 @@ -566,7 +572,7 @@ pub mod ipc { 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 { @@ -583,7 +589,7 @@ pub mod ipc { )) })?; - Ok(IpcServer { server }) + Ok(NativeIpcServer { server }) } /// Return the path to the IPC server. @@ -630,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; @@ -686,6 +693,7 @@ pub mod autofill { pub user_verification: UserVerification, pub supported_algorithms: Vec, pub window_xy: Position, + pub excluded_credentials: Vec>, } #[napi(object)] @@ -724,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")] @@ -737,14 +753,14 @@ 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 @@ -760,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 { @@ -801,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 @@ -820,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 @@ -838,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); @@ -849,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"); } } @@ -863,7 +895,7 @@ pub mod autofill { )) })?; - Ok(IpcServer { server }) + Ok(AutofillIpcServer { server }) } /// Return the path to the IPC server. @@ -956,19 +988,20 @@ pub mod logging { 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::{ - filter::EnvFilter, + filter::{EnvFilter, LevelFilter}, fmt::format::{DefaultVisitor, Writer}, layer::SubscriberExt, util::SubscriberInitExt, Layer, }; - struct JsLogger(OnceLock>); + struct JsLogger(OnceLock>>); static JS_LOGGER: JsLogger = JsLogger(OnceLock::new()); #[napi] @@ -1040,26 +1073,18 @@ 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() - .with_default_directive( - option_env!("RUST_LOG") - .unwrap_or("info") - .parse() - .expect("should provide valid log level at compile time."), - ) + // set the default log level to INFO. + .with_default_directive(LevelFilter::INFO.into()) // parse directives from the RUST_LOG environment variable, // overriding the default directive for matching targets. .from_env_lossy(); @@ -1117,8 +1142,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 { @@ -1195,7 +1220,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 5ef791fb586..dd808537c28 100644 --- a/apps/desktop/desktop_native/objc/Cargo.toml +++ b/apps/desktop/desktop_native/objc/Cargo.toml @@ -14,8 +14,8 @@ tokio = { workspace = true } tracing = { workspace = true } [target.'cfg(target_os = "macos")'.build-dependencies] -cc = "=1.2.46" -glob = "=0.3.2" +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/rust-toolchain.toml b/apps/desktop/desktop_native/rust-toolchain.toml index c1ab6b3240a..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.87.0" +channel = "1.91.1" components = [ "rustfmt", "clippy" ] profile = "minimal" 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 893fdf765fc..b38a1c725f2 100644 --- a/apps/desktop/desktop_native/windows_plugin_authenticator/src/lib.rs +++ b/apps/desktop/desktop_native/windows_plugin_authenticator/src/lib.rs @@ -153,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/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 9ad1ffb3ec0..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.19.1", + "@types/node": "22.19.2", "typescript": "5.4.2" } }, @@ -117,9 +117,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", - "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", + "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 21a6ba3626a..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.19.1", + "@types/node": "22.19.2", "typescript": "5.4.2" }, "_moduleAliases": { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index bb8118cb7eb..5e85d34cebc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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\"", @@ -44,10 +45,9 @@ "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/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 8abd84ee39c..d5042918d2f 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -44,12 +44,12 @@

{{ "vaultTimeoutHeader" | i18n }}

- - + {{ diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 68863312ffe..e3022428421 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -55,7 +55,7 @@ import { } from "@bitwarden/components"; import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management"; import { - SessionTimeoutInputComponent, + SessionTimeoutInputLegacyComponent, SessionTimeoutSettingsComponent, } from "@bitwarden/key-management-ui"; import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault"; @@ -97,7 +97,7 @@ import { NativeMessagingManifestService } from "../services/native-messaging-man SectionHeaderComponent, SelectModule, TypographyModule, - SessionTimeoutInputComponent, + SessionTimeoutInputLegacyComponent, SessionTimeoutSettingsComponent, PermitCipherDetailsPopoverComponent, PremiumBadgeComponent, diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index f822a9ae4d9..42f9d7ae779 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -43,7 +43,11 @@ import { import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components"; -import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui"; +import { + LockComponent, + ConfirmKeyConnectorDomainComponent, + RemovePasswordComponent, +} from "@bitwarden/key-management-ui"; import { VaultFilterServiceAbstraction, VaultFilterService, @@ -52,12 +56,13 @@ import { } from "@bitwarden/vault"; import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard"; -import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; -import { DesktopPremiumUpgradePromptService } from "../services/desktop-premium-upgrade-prompt.service"; +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"; @@ -123,17 +128,16 @@ 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: "", @@ -279,7 +283,7 @@ const routes: Routes = [ }, { path: "lock", - canActivate: [lockGuard()], + canActivate: [lockGuard(), reactiveUnlockVaultGuard], data: { pageIcon: LockIcon, pageTitle: { @@ -328,13 +332,24 @@ const routes: Routes = [ 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, diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 6243ba1e538..836328142b5 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -104,7 +104,7 @@ const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours - +
@@ -141,6 +141,7 @@ export class AppComponent implements OnInit, OnDestroy { @ViewChild("loginApproval", { read: ViewContainerRef, static: true }) loginApprovalModalRef: ViewContainerRef; + showHeader$ = this.accountService.showHeader$; loading = false; private lastActivity: Date = null; 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/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/layout/desktop-layout.component.html b/apps/desktop/src/app/layout/desktop-layout.component.html index dd4c77f1021..b0edb64f723 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.html +++ b/apps/desktop/src/app/layout/desktop-layout.component.html @@ -1,9 +1,9 @@ - + - + diff --git a/apps/desktop/src/app/layout/desktop-layout.component.spec.ts b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts index cc2f7e58dfb..74cddd02495 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.spec.ts +++ b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts @@ -1,3 +1,4 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { RouterModule } from "@angular/router"; import { mock } from "jest-mock-extended"; @@ -5,8 +6,18 @@ 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) => ({ @@ -34,7 +45,12 @@ describe("DesktopLayoutComponent", () => { useValue: mock(), }, ], - }).compileComponents(); + }) + .overrideComponent(DesktopLayoutComponent, { + remove: { imports: [SendFiltersNavComponent] }, + add: { imports: [MockSendFiltersNavComponent] }, + }) + .compileComponents(); fixture = TestBed.createComponent(DesktopLayoutComponent); component = fixture.componentInstance; @@ -58,4 +74,11 @@ describe("DesktopLayoutComponent", () => { 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 index 798b8a629a3..9d94a21007a 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.ts +++ b/apps/desktop/src/app/layout/desktop-layout.component.ts @@ -6,6 +6,7 @@ import { LayoutComponent, NavigationModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { VaultFilterComponent } from "../../vault/app/vault-v3/vault-filter/vault-filter.component"; +import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component"; import { DesktopSideNavComponent } from "./desktop-side-nav.component"; @@ -20,6 +21,7 @@ import { DesktopSideNavComponent } from "./desktop-side-nav.component"; NavigationModule, DesktopSideNavComponent, VaultFilterComponent, + SendFiltersNavComponent, ], templateUrl: "./desktop-layout.component.html", }) diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 03d6eb5c908..e4dd144fa20 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, @@ -101,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, @@ -128,7 +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 { DesktopSessionTimeoutSettingsComponentService } from "../../key-management/session-timeout/services/desktop-session-timeout-settings-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"; @@ -165,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), @@ -344,6 +347,7 @@ const safeProviders: SafeProvider[] = [ ConfigService, Fido2AuthenticatorServiceAbstraction, AccountService, + AuthService, PlatformUtilsService, ], }), @@ -484,15 +488,20 @@ const safeProviders: SafeProvider[] = [ useClass: DesktopAutotypeDefaultSettingPolicy, deps: [AccountServiceAbstraction, AuthServiceAbstraction, InternalPolicyService, ConfigService], }), + safeProvider({ + provide: SessionTimeoutTypeService, + useClass: DesktopSessionTimeoutTypeService, + deps: [], + }), safeProvider({ provide: SessionTimeoutSettingsComponentService, - useClass: DesktopSessionTimeoutSettingsComponentService, - deps: [I18nServiceAbstraction], + 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/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 index 8055bc07667..8657f3e375e 100644 --- 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 @@ -1,22 +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; - fixture.detectChanges(); }); 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 index 4840cd4cce8..eb0856b76af 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts @@ -1,9 +1,261 @@ -import { Component, ChangeDetectionStrategy } from "@angular/core"; +// 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: [], - template: "

Sends V2 Component

", - changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, JslibModule, FormsModule, AddEditComponent], + templateUrl: "./send-v2.component.html", }) -export class SendV2Component {} +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/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/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-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 @@ +
+ + +
+ + +

+ {{ "savePasskeyQuestion" | i18n }} +

+
+ + +
+
+ + +
+
+ +
+ {{ "noMatchingLoginsForSite" | i18n }} +
+ +
+
+ + + + {{ c.subTitle }} + {{ "save" | i18n }} + + + + + + +
+
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/preload.ts b/apps/desktop/src/autofill/preload.ts index e839ac223b7..6a7a8459ea9 100644 --- a/apps/desktop/src/autofill/preload.ts +++ b/apps/desktop/src/autofill/preload.ts @@ -12,6 +12,8 @@ export default { runCommand: (params: RunCommandParams): Promise> => ipcRenderer.invoke("autofill.runCommand", params), + listenerReady: () => ipcRenderer.send("autofill.listenerReady"), + listenPasskeyRegistration: ( fn: ( clientId: number, @@ -130,6 +132,25 @@ 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 }); }, diff --git a/apps/desktop/src/autofill/services/desktop-autofill.service.ts b/apps/desktop/src/autofill/services/desktop-autofill.service.ts index 18f4652d72a..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,10 +10,11 @@ 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"; @@ -48,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, @@ -55,6 +59,7 @@ export class DesktopAutofillService implements OnDestroy { private configService: ConfigService, private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction, private accountService: AccountService, + private authService: AuthService, private platformUtilsService: PlatformUtilsService, ) {} @@ -68,28 +73,56 @@ export class DesktopAutofillService implements OnDestroy { .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(); @@ -130,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", @@ -155,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( @@ -277,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", @@ -309,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, })); } @@ -333,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, }; } @@ -358,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-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 @@
  • - {{ "premiumSignUpStorage" | i18n }} + {{ "premiumSignUpStorageV2" | i18n: `${storageProvidedGb} GB` }}
  • diff --git a/apps/desktop/src/billing/app/accounts/premium.component.ts b/apps/desktop/src/billing/app/accounts/premium.component.ts index 637969c1a21..4aff0cc03e1 100644 --- a/apps/desktop/src/billing/app/accounts/premium.component.ts +++ b/apps/desktop/src/billing/app/accounts/premium.component.ts @@ -3,6 +3,7 @@ import { Component } from "@angular/core"; import { PremiumComponent as BasePremiumComponent } from "@bitwarden/angular/billing/components/premium.component"; 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"; @@ -28,6 +29,7 @@ export class PremiumComponent extends BasePremiumComponent { billingAccountProfileStateService: BillingAccountProfileStateService, toastService: ToastService, accountService: AccountService, + billingApiService: BillingApiServiceAbstraction, ) { super( i18nService, @@ -39,6 +41,7 @@ export class PremiumComponent extends BasePremiumComponent { billingAccountProfileStateService, toastService, accountService, + billingApiService, ); } } diff --git a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts index 8e28d3ca614..3a47086b1aa 100644 --- a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts @@ -1,5 +1,7 @@ import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; @@ -13,6 +15,10 @@ import { DesktopBiometricsService } from "./desktop.biometrics.service"; */ @Injectable() export class RendererBiometricsService extends DesktopBiometricsService { + constructor(private tokenService: TokenService) { + super(); + } + async authenticateWithBiometrics(): Promise { return await ipc.keyManagement.biometric.authenticateWithBiometrics(); } @@ -31,6 +37,10 @@ export class RendererBiometricsService extends DesktopBiometricsService { } async getBiometricsStatusForUser(id: UserId): Promise { + if ((await firstValueFrom(this.tokenService.hasAccessToken$(id))) === false) { + return BiometricsStatus.NotEnabledInConnectedDesktopApp; + } + return await ipc.keyManagement.biometric.getBiometricsStatusForUser(id); } diff --git a/apps/desktop/src/key-management/key-connector/remove-password.component.html b/apps/desktop/src/key-management/key-connector/remove-password.component.html deleted file mode 100644 index 5276e00c531..00000000000 --- a/apps/desktop/src/key-management/key-connector/remove-password.component.html +++ /dev/null @@ -1,20 +0,0 @@ -
    -
    -

    {{ "removeMasterPassword" | i18n }}

    -

    {{ "removeMasterPasswordForOrganizationUserKeyConnector" | i18n }}

    -

    {{ "organizationName" | i18n }}:

    -

    {{ organization.name }}

    -

    {{ "keyConnectorDomain" | i18n }}:

    -

    {{ organization.keyConnectorUrl }}

    -
    - - -
    -
    -
    diff --git a/apps/desktop/src/key-management/key-connector/remove-password.component.ts b/apps/desktop/src/key-management/key-connector/remove-password.component.ts deleted file mode 100644 index d9fea9409f8..00000000000 --- a/apps/desktop/src/key-management/key-connector/remove-password.component.ts +++ /dev/null @@ -1,12 +0,0 @@ -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/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts deleted file mode 100644 index 91c8126cdd7..00000000000 --- a/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { defer, from, map, Observable } from "rxjs"; - -import { - VaultTimeout, - VaultTimeoutOption, - VaultTimeoutStringType, -} from "@bitwarden/common/key-management/vault-timeout"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui"; - -export class DesktopSessionTimeoutSettingsComponentService - implements SessionTimeoutSettingsComponentService -{ - availableTimeoutOptions$: Observable = defer(() => - from(ipc.platform.powermonitor.isLockMonitorAvailable()).pipe( - map((isLockMonitorAvailable) => { - const options: VaultTimeoutOption[] = [ - { name: this.i18nService.t("oneMinute"), value: 1 }, - { name: this.i18nService.t("fiveMinutes"), value: 5 }, - { name: this.i18nService.t("fifteenMinutes"), value: 15 }, - { name: this.i18nService.t("thirtyMinutes"), value: 30 }, - { name: this.i18nService.t("oneHour"), value: 60 }, - { name: this.i18nService.t("fourHours"), value: 240 }, - { name: this.i18nService.t("onIdle"), value: VaultTimeoutStringType.OnIdle }, - { name: this.i18nService.t("onSleep"), value: VaultTimeoutStringType.OnSleep }, - ]; - - if (isLockMonitorAvailable) { - options.push({ - name: this.i18nService.t("onLocked"), - value: VaultTimeoutStringType.OnLocked, - }); - } - - options.push( - { name: this.i18nService.t("onRestart"), value: VaultTimeoutStringType.OnRestart }, - { name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never }, - ); - - return options; - }), - ), - ); - - constructor(private readonly i18nService: I18nService) {} - - onTimeoutSave(_: VaultTimeout): void {} -} diff --git a/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.spec.ts b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.spec.ts new file mode 100644 index 00000000000..d3ece8842b2 --- /dev/null +++ b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.spec.ts @@ -0,0 +1,125 @@ +import { + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; + +import { DesktopSessionTimeoutTypeService } from "./desktop-session-timeout-type.service"; + +describe("DesktopSessionTimeoutTypeService", () => { + let service: DesktopSessionTimeoutTypeService; + let mockIsLockMonitorAvailable: jest.Mock; + + beforeEach(() => { + mockIsLockMonitorAvailable = jest.fn(); + + (global as any).ipc = { + platform: { + powermonitor: { + isLockMonitorAvailable: mockIsLockMonitorAvailable, + }, + }, + }; + + service = new DesktopSessionTimeoutTypeService(); + }); + + describe("isAvailable", () => { + it("should return false for Immediately", async () => { + const result = await service.isAvailable(VaultTimeoutNumberType.Immediately); + + expect(result).toBe(false); + }); + + it.each([ + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + 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", () => { + it("should return true when lock monitor is available", async () => { + mockIsLockMonitorAvailable.mockResolvedValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(true); + expect(mockIsLockMonitorAvailable).toHaveBeenCalled(); + }); + + it("should return false when lock monitor is not available", async () => { + mockIsLockMonitorAvailable.mockResolvedValue(false); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(false); + expect(mockIsLockMonitorAvailable).toHaveBeenCalled(); + }); + }); + }); + + describe("getOrPromoteToAvailable", () => { + it.each([ + VaultTimeoutNumberType.OnMinute, + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.Never, + 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("should return OnMinute when Immediately is not available", async () => { + jest.spyOn(service, "isAvailable").mockResolvedValue(false); + + const result = await service.getOrPromoteToAvailable(VaultTimeoutNumberType.Immediately); + + expect(result).toBe(VaultTimeoutNumberType.OnMinute); + expect(service.isAvailable).toHaveBeenCalledWith(VaultTimeoutNumberType.Immediately); + }); + + it("should return OnSleep when OnLocked is not available", async () => { + jest.spyOn(service, "isAvailable").mockResolvedValue(false); + + const result = await service.getOrPromoteToAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(VaultTimeoutStringType.OnSleep); + expect(service.isAvailable).toHaveBeenCalledWith(VaultTimeoutStringType.OnLocked); + }); + + it.each([ + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutNumberType.OnMinute, + 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/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.ts b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.ts new file mode 100644 index 00000000000..1f09e83b0f1 --- /dev/null +++ b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.ts @@ -0,0 +1,46 @@ +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; +import { + isVaultTimeoutTypeNumeric, + VaultTimeout, + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; + +export class DesktopSessionTimeoutTypeService implements SessionTimeoutTypeService { + async isAvailable(type: VaultTimeout): Promise { + switch (type) { + case VaultTimeoutNumberType.Immediately: + return false; + case VaultTimeoutStringType.OnIdle: + case VaultTimeoutStringType.OnSleep: + case VaultTimeoutStringType.OnRestart: + case VaultTimeoutStringType.Never: + case VaultTimeoutStringType.Custom: + return true; + case VaultTimeoutStringType.OnLocked: + return await ipc.platform.powermonitor.isLockMonitorAvailable(); + default: + if (isVaultTimeoutTypeNumeric(type)) { + return true; + } + break; + } + + return false; + } + + async getOrPromoteToAvailable(type: VaultTimeout): Promise { + const available = await this.isAvailable(type); + if (!available) { + switch (type) { + case VaultTimeoutNumberType.Immediately: + return VaultTimeoutNumberType.OnMinute; + case VaultTimeoutStringType.OnLocked: + return VaultTimeoutStringType.OnSleep; + default: + return VaultTimeoutStringType.OnRestart; + } + } + return type; + } +} diff --git a/apps/desktop/src/locales/af/messages.json b/apps/desktop/src/locales/af/messages.json index 1c6a2bc49c9..a4230a128c5 100644 --- a/apps/desktop/src/locales/af/messages.json +++ b/apps/desktop/src/locales/af/messages.json @@ -1093,6 +1093,24 @@ "learnMore": { "message": "Leer meer" }, + "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" + }, "featureUnavailable": { "message": "Funksie Onbeskikbaar" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GG geënkripteerde berging vir lêeraanhegsels." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Alle Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Is u seker u wil die “Nooit”-opsie gebruik? Deur u vergrendelopsies op “Nooit” te stel word u kluis se enkripsie op u toestel bewaar. Indien u hierdie opsie gebruik moet u verseker dat u toestel behoorlik beskerm is." }, "vault": { - "message": "Kluis" + "message": "Kluis", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Meld aan met meesterwagwoord" diff --git a/apps/desktop/src/locales/ar/messages.json b/apps/desktop/src/locales/ar/messages.json index ca404f4e179..54a9425c901 100644 --- a/apps/desktop/src/locales/ar/messages.json +++ b/apps/desktop/src/locales/ar/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "الميزة غير متوفرة" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 جيغابايت وحدة تخزين مشفرة لمرفقات الملفات." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "خيارات تسجيل الدخول بخطوتين المملوكة مثل YubiKey و Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "كل الإرسالات", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "هل أنت متأكد من أنك تريد استخدام خيار \"مطلقا\"؟ إعداد خيارات القفل إلى \"مطلقا\" يخزن مفتاح تشفير المستودع الخاص بك على جهازك. إذا كنت تستخدم هذا الخيار، يجب أن تتأكد من الحفاظ على حماية جهازك بشكل صحيح." }, "vault": { - "message": "الخزانة" + "message": "الخزانة", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "تسجيل الدخول باستخدام كلمة المرور الرئيسية" diff --git a/apps/desktop/src/locales/az/messages.json b/apps/desktop/src/locales/az/messages.json index 55c2bdcd677..9e8c77aa76a 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Özəllik əlçatmazdır" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "Fayl qoşmaları üçün 1 GB şifrələnmiş saxlama sahəsi." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "YubiKey və Duo kimi mülkiyyətçi iki addımlı giriş seçimləri." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Əlaqə məlumatları" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Bütün \"Send\"lər", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "\"Heç vaxt\"i seçmək istədiyinizə əminsiniz? Kilid seçimini \"Heç vaxt\" olaraq ayarlasanız, seyfinizin şifrələmə açarı cihazınızda saxlanılacaq. Bu seçimi istifadə etsəniz, cihazınızı daha yaxşı mühafizə etdiyinizə əmin olmalısınız." }, "vault": { - "message": "Seyf" + "message": "Seyf", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Ana parolla giriş et" diff --git a/apps/desktop/src/locales/be/messages.json b/apps/desktop/src/locales/be/messages.json index b2e4db47b32..e9b3834a9f7 100644 --- a/apps/desktop/src/locales/be/messages.json +++ b/apps/desktop/src/locales/be/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Функцыя недаступна" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 ГБ зашыфраванага сховішча для далучаных файлаў." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Прапрыетарныя варыянты двухэтапнага ўваходу, такія як YubiKey, FIDO U2F і Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Усе Send'ы", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Вы сапраўды хочаце адключыць блакіроўку сховішча? Прызначыўшы параметр блакіравання \"Ніколі\", ключ шыфравання будзе захоўвацца на вашай прыладзе. Калі вы выкарыстоўваеце гэты параметр, вы павінны быць упэўнены ў тым, што ваша прылада надзейна абаронена." }, "vault": { - "message": "Сховішча" + "message": "Сховішча", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Увайсці з асноўным паролем" diff --git a/apps/desktop/src/locales/bg/messages.json b/apps/desktop/src/locales/bg/messages.json index ad03c2cc023..f27fb467c0c 100644 --- a/apps/desktop/src/locales/bg/messages.json +++ b/apps/desktop/src/locales/bg/messages.json @@ -1093,6 +1093,24 @@ "learnMore": { "message": "Научете повече" }, + "migrationsFailed": { + "message": "Възникна грешка при обновяването на настройките за шифроване." + }, + "updateEncryptionSettingsTitle": { + "message": "Обновете настройките си за шифроване" + }, + "updateEncryptionSettingsDesc": { + "message": "Новите препоръчани настройки за шифроване ще подобрят сигурността на акаунта Ви. Въведете главната си парола, за да ги обновите сега." + }, + "confirmIdentityToContinue": { + "message": "Потвърдете самоличността си, за да продължите" + }, + "enterYourMasterPassword": { + "message": "Въведете главната си парола" + }, + "updateSettings": { + "message": "Обновяване на настройките" + }, "featureUnavailable": { "message": "Функцията е недостъпна" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 ГБ пространство за файлове, които се шифроват." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ пространство за файлове, които се шифрират.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Частно двустепенно удостоверяване чрез YubiKey и Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Информация за контакт" }, + "send": { + "message": "Изпращане", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Всички изпращания", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Уверени ли сте, че искате да зададете стойност „Никога“? Това води до съхранение на шифриращия ключ за трезора във устройството ви. Ако използвате тази възможност, е много важно да имате надлежна защита на устройството си." }, "vault": { - "message": "Трезор" + "message": "Трезор", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Вписване с главната парола" diff --git a/apps/desktop/src/locales/bn/messages.json b/apps/desktop/src/locales/bn/messages.json index d6c61c1ab51..f9f072f290e 100644 --- a/apps/desktop/src/locales/bn/messages.json +++ b/apps/desktop/src/locales/bn/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "বৈশিষ্ট্য অনুপলব্ধ" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "ফাইল সংযুক্তির জন্য ১ জিবি এনক্রিপ্টেড স্থান।" }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "All Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/bs/messages.json b/apps/desktop/src/locales/bs/messages.json index 569f1072c4b..b8798320a98 100644 --- a/apps/desktop/src/locales/bs/messages.json +++ b/apps/desktop/src/locales/bs/messages.json @@ -1093,6 +1093,24 @@ "learnMore": { "message": "Saznajte 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" + }, "featureUnavailable": { "message": "Funkcija nije dostupna" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB šifriranog prostora za pohranu podataka." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Svi Send-ovi", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/ca/messages.json b/apps/desktop/src/locales/ca/messages.json index de468f1e8b3..a7674cbc753 100644 --- a/apps/desktop/src/locales/ca/messages.json +++ b/apps/desktop/src/locales/ca/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Característica no disponible" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "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" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Opcions propietàries de doble factor com ara YubiKey i Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Informació de contacte" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Tots els Send", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Esteu segur que voleu utilitzar l'opció \"Mai\"? En configurar les opcions de bloqueig a \"Mai\" s'emmagatzema la clau de xifratge de la vostra caixa forta al vostre dispositiu. Si utilitzeu aquesta opció, heu d'assegurar-vos que conserveu el dispositiu degudament protegit." }, "vault": { - "message": "Caixa forta" + "message": "Caixa forta", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Inici de sessió amb contrasenya mestra" diff --git a/apps/desktop/src/locales/cs/messages.json b/apps/desktop/src/locales/cs/messages.json index c02dbabbc93..b93e5d0f513 100644 --- a/apps/desktop/src/locales/cs/messages.json +++ b/apps/desktop/src/locales/cs/messages.json @@ -1093,6 +1093,24 @@ "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í" + }, "featureUnavailable": { "message": "Funkce není dostupná" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB šifrovaného uložiště pro přílohy." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ šifrovaného úložiště pro přílohy.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Volby proprietálních dvoufázových přihlášení jako je YubiKey a Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Všechny Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Opravdu chcete použít volbu \"Nikdy\"? Nastavením volby uzamčení na \"Nikdy\" bude šifrovací klíč k trezoru uložen přímo ve Vašem zařízení. Pokud tuto možnost použijete, měli byste Vaše zařízení řádně zabezpečit a chránit." }, "vault": { - "message": "Trezor" + "message": "Trezor", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Přihlásit se pomocí hlavního hesla" diff --git a/apps/desktop/src/locales/cy/messages.json b/apps/desktop/src/locales/cy/messages.json index 25b52fcc101..5a00ad90bbd 100644 --- a/apps/desktop/src/locales/cy/messages.json +++ b/apps/desktop/src/locales/cy/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Feature unavailable" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "All Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/da/messages.json b/apps/desktop/src/locales/da/messages.json index 1d135a533f2..622c9e9187d 100644 --- a/apps/desktop/src/locales/da/messages.json +++ b/apps/desktop/src/locales/da/messages.json @@ -1093,6 +1093,24 @@ "learnMore": { "message": "Læs 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" + }, "featureUnavailable": { "message": "Funktion utilgængelig" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB krypteret lagerplads til filvedhæftninger." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietære totrins-login muligheder, såsom YubiKey og Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Alle Send", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Sikker på, at indstillingen \"Aldrig\" skal bruges? Sættes låseindstillinger til \"Aldrig\", gemmes din bokskrypteringsnøgle på enheden. Bruges denne indstilling, så sørg for at holde din enhed ordentligt beskyttet." }, "vault": { - "message": "Boks" + "message": "Boks", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log ind med hovedadgangskoden" diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index 6fc220faded..2743ec21c8a 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -1093,6 +1093,24 @@ "learnMore": { "message": "Mehr erfahren" }, + "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" + }, "featureUnavailable": { "message": "Funktion nicht verfügbar" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "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" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietäre Optionen für die Zwei-Faktor Authentifizierung wie YubiKey und Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Kontaktinformationen" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Alle Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Bist du sicher, dass du die Option \"Nie\" verwenden möchtest? Durch das Setzen der Sperroptionen zu \"Nie\" wird der Verschlüsselungscode deines Tresors auf deinem Gerät gespeichert. Wenn du diese Option verwendest, solltest du sicherstellen, dass dein Gerät ausreichend geschützt ist." }, "vault": { - "message": "Tresor" + "message": "Tresor", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Mit Master-Passwort anmelden" diff --git a/apps/desktop/src/locales/el/messages.json b/apps/desktop/src/locales/el/messages.json index 0b869c1e02f..bd8269db4ea 100644 --- a/apps/desktop/src/locales/el/messages.json +++ b/apps/desktop/src/locales/el/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Μη διαθέσιμη λειτουργία" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB κρυπτογραφημένο αποθηκευτικό χώρο για συνημμένα αρχεία." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Ιδιόκτητες επιλογές σύνδεσης δύο βημάτων, όπως το YubiKey και το Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Στοιχεία επικοινωνίας" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Όλα τα Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Είστε βέβαιοι ότι θέλετε να χρησιμοποιήσετε την επιλογή \"Ποτέ\"; Ο ορισμός των επιλογών κλειδώματος σε \"Ποτέ\" αποθηκεύει το κλειδί κρυπτογράφησης του θησαυ/κίου σας στη συσκευή σας. Εάν χρησιμοποιήσετε αυτήν την επιλογή, θα πρέπει να διασφαλίσετε ότι θα διατηρείτε τη συσκευή σας κατάλληλα προστατευμένη." }, "vault": { - "message": "Κρύπτη" + "message": "Κρύπτη", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Συνδεθείτε με τον κύριο κωδικό πρόσβασης" diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index f6f078611c9..48e346d9c68 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -708,6 +708,18 @@ "addAttachment": { "message": "Add attachment" }, + "itemsTransferred": { + "message": "Items transferred" + }, + "fixEncryption": { + "message": "Fix encryption" + }, + "fixEncryptionTooltip": { + "message": "This file is using an outdated encryption method." + }, + "attachmentUpdated": { + "message": "Attachment updated" + }, "maxFileSizeSansPunctuation": { "message": "Maximum file size is 500 MB" }, @@ -908,6 +920,12 @@ "unexpectedError": { "message": "An unexpected error has occurred." }, + "unexpectedErrorShort": { + "message": "Unexpected error" + }, + "closeThisBitwardenWindow": { + "message": "Close this Bitwarden window and try again." + }, "itemInformation": { "message": "Item information" }, @@ -1093,6 +1111,24 @@ "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" + }, "featureUnavailable": { "message": "Feature unavailable" }, @@ -1490,6 +1526,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2592,9 +2637,6 @@ "removedMasterPassword": { "message": "Master password removed" }, - "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" }, @@ -3329,7 +3371,7 @@ "orgTrustWarning1": { "message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint." }, - "trustUser":{ + "trustUser": { "message": "Trust user" }, "inputRequired": { @@ -3859,6 +3901,75 @@ "fileSavedToDevice": { "message": "File saved to device. Manage from your device downloads." }, + "importantNotice": { + "message": "Important notice" + }, + "setupTwoStepLogin": { + "message": "Set up two-step login" + }, + "newDeviceVerificationNoticeContentPage1": { + "message": "Bitwarden will send a code to your account email to verify logins from new devices starting in February 2025." + }, + "newDeviceVerificationNoticeContentPage2": { + "message": "You can set up two-step login as an alternative way to protect your account or change your email to one you can access." + }, + "remindMeLater": { + "message": "Remind me later" + }, + "newDeviceVerificationNoticePageOneFormContent": { + "message": "Do you have reliable access to your email, $EMAIL$?", + "placeholders": { + "email": { + "content": "$1", + "example": "your_name@email.com" + } + } + }, + "newDeviceVerificationNoticePageOneEmailAccessNo": { + "message": "No, I do not" + }, + "newDeviceVerificationNoticePageOneEmailAccessYes": { + "message": "Yes, I can reliably access my email" + }, + "turnOnTwoStepLogin": { + "message": "Turn on two-step login" + }, + "changeAcctEmail": { + "message": "Change account email" + }, + "passkeyLogin": { + "message": "Log in with passkey?" + }, + "savePasskeyQuestion": { + "message": "Save passkey?" + }, + "saveNewPasskey": { + "message": "Save as new login" + }, + "savePasskeyNewLogin": { + "message": "Save passkey as new login" + }, + "noMatchingLoginsForSite": { + "message": "No matching logins for this site" + }, + "overwritePasskey": { + "message": "Overwrite passkey?" + }, + "unableToSavePasskey": { + "message": "Unable to save passkey" + }, + "alreadyContainsPasskey": { + "message": "This item already contains a passkey. Are you sure you want to overwrite the current passkey?" + }, + "passkeyAlreadyExists": { + "message": "A passkey already exists for this application." + }, + "applicationDoesNotSupportDuplicates": { + "message": "This application does not support duplicates." + }, + "closeThisWindow": { + "message": "Close this window" + }, "allowScreenshots": { "message": "Allow screen capture" }, @@ -4217,16 +4328,147 @@ "andMoreFeatures": { "message": "And more!" }, - "planDescPremium": { - "message": "Complete online security" + "advancedOnlineSecurity": { + "message": "Advanced online security" }, "upgradeToPremium": { "message": "Upgrade to Premium" }, + "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" }, "sessionTimeoutHeader": { "message": "Session timeout" + }, + "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" + } + } + }, + "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 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 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/desktop/src/locales/en_GB/messages.json b/apps/desktop/src/locales/en_GB/messages.json index 16af69361c6..4e1f8569caf 100644 --- a/apps/desktop/src/locales/en_GB/messages.json +++ b/apps/desktop/src/locales/en_GB/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Feature unavailable" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "All Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/en_IN/messages.json b/apps/desktop/src/locales/en_IN/messages.json index c6f1253bb59..1413debe49c 100644 --- a/apps/desktop/src/locales/en_IN/messages.json +++ b/apps/desktop/src/locales/en_IN/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Feature unavailable" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "All Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/eo/messages.json b/apps/desktop/src/locales/eo/messages.json index 28a9f3b8bce..5cb6995eeb6 100644 --- a/apps/desktop/src/locales/eo/messages.json +++ b/apps/desktop/src/locales/eo/messages.json @@ -1093,6 +1093,24 @@ "learnMore": { "message": "Lerni pli" }, + "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" + }, "featureUnavailable": { "message": "La funkcio nedisponeblas" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Ĉiuj Send'oj", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Trezorejo" + "message": "Trezorejo", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Saluti per la ĉefa pasvorto" diff --git a/apps/desktop/src/locales/es/messages.json b/apps/desktop/src/locales/es/messages.json index 9966fa1064c..af90b40af65 100644 --- a/apps/desktop/src/locales/es/messages.json +++ b/apps/desktop/src/locales/es/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Característica no disponible" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1GB de espacio en disco cifrado." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Opciones de inicio de sesión con autenticación de dos pasos propietarios como YubiKey y Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Información de contacto" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Todos los Send", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "¿Está seguro de que quieres usar la opción \"Nunca\"? Al ajustar las opciones de bloqueo a \"Nunca\", la clave de cifrado de su caja fuerte se guardará en tu dispositivo. Si usas esta opción, asegúrate de mantener tu dispositivo debidamente protegido." }, "vault": { - "message": "Caja fuerte" + "message": "Caja fuerte", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Iniciar sesión con contraseña maestra" diff --git a/apps/desktop/src/locales/et/messages.json b/apps/desktop/src/locales/et/messages.json index d85c52bb763..8ab2818f7ea 100644 --- a/apps/desktop/src/locales/et/messages.json +++ b/apps/desktop/src/locales/et/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Funktsioon pole saadaval" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB ulatuses krüpteeritud salvestusruum." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Eraomanduses kaheastmelise logimise valikud, nagu näiteks YubiKey ja Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Kõik Sendid", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Oled kindel, et soovid kasutada valikut \"Mitte kunagi\"? Sellega talletatakse sinu hoidla krüpteerimise võtit seadme mälus. Peaksid olema väga hoolas ja kindel, et seade on ohutu ja selles ei ole pahavara." }, "vault": { - "message": "Hoidla" + "message": "Hoidla", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Logi sisse ülemparooliga" diff --git a/apps/desktop/src/locales/eu/messages.json b/apps/desktop/src/locales/eu/messages.json index 36401df0078..451e77c4ec0 100644 --- a/apps/desktop/src/locales/eu/messages.json +++ b/apps/desktop/src/locales/eu/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Ezaugarria ez dago erabilgarri" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "Eranskinentzako 1GB-eko zifratutako biltegia." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Send guztiak", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Ziur zaude \"Inoiz ez\" aukera erabili nahi duzula? Zure blokeo aukerak \"Inoiz ez\" bezala konfiguratzeak kutxa gotorraren zifratze-gakoa gailuan gordetzen du. Aukera hau erabiltzen baduzu, gailua behar bezala babestuta duzula ziurtatu behar duzu." }, "vault": { - "message": "Kutxa gotorra" + "message": "Kutxa gotorra", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Hasi saioa pasahitz nagusiarekin" diff --git a/apps/desktop/src/locales/fa/messages.json b/apps/desktop/src/locales/fa/messages.json index ac0e83dd44d..f7dedc42542 100644 --- a/apps/desktop/src/locales/fa/messages.json +++ b/apps/desktop/src/locales/fa/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "ویژگی موجود نیست" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "۱ گیگابایت فضای ذخیره‌سازی رمزنگاری شده برای پرونده‌های پیوست." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "گزینه‌های ورود اضافی دو مرحله‌ای مانند YubiKey و Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "اطلاعات تماس" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "همه ارسال‌ها", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "آیا جداً می‌خواهید از گزینه \"هرگز\" استفاده کنید؟ تنظیم کردن گزینه قفل به \"هرگز\" کلیدهای رمزنگاری گاوصندوقتان را بر روی دستگاه شما ذخیره خواهد کرد. اگر از این گزینه استفاده می‌کنید باید اطمینان داشته باشید که دستگاه شما کاملا محافظت شده است." }, "vault": { - "message": "گاوصندوق" + "message": "گاوصندوق", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "با کلمه عبور اصلی وارد شوید" diff --git a/apps/desktop/src/locales/fi/messages.json b/apps/desktop/src/locales/fi/messages.json index e2952659d03..2021248bae4 100644 --- a/apps/desktop/src/locales/fi/messages.json +++ b/apps/desktop/src/locales/fi/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Ominaisuus ei ole käytettävissä" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 Gt salattua tallennustilaa tiedostoliitteille." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Kaksivaiheisen kirjautumisen erikoisvaihtoehdot, kuten YubiKey ja Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Yhteystiedot" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Kaikki Sendit", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Haluatko varmasti käyttää asetusta \"Ei koskaan\"? Se tallentaa holvisi salausavaimen laitteellesi. Jos käytät asetusta, varmista, että laite on suojattu hyvin." }, "vault": { - "message": "Holvi" + "message": "Holvi", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Kirjaudu pääsalasanalla" diff --git a/apps/desktop/src/locales/fil/messages.json b/apps/desktop/src/locales/fil/messages.json index 6eaa5577807..9c8bbaf0a34 100644 --- a/apps/desktop/src/locales/fil/messages.json +++ b/apps/desktop/src/locales/fil/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Hindi magagamit ang tampok" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB naka encrypt na imbakan para sa mga attachment ng file." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Lahat ng Mga Padala", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Sigurado ka bang gusto mong gamitin ang opsyon na \"Never\" Ang pagtatakda ng iyong mga pagpipilian sa lock sa \"Hindi kailanman\" ay nag iimbak ng key ng pag encrypt ng iyong vault sa iyong aparato. Kung gagamitin mo ang pagpipiliang ito dapat mong tiyakin na pinapanatili mong protektado nang maayos ang iyong aparato." }, "vault": { - "message": "Ayos" + "message": "Ayos", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Mag-login gamit ang pangunahing password" diff --git a/apps/desktop/src/locales/fr/messages.json b/apps/desktop/src/locales/fr/messages.json index 6cca98444b8..8f7bfc39c4a 100644 --- a/apps/desktop/src/locales/fr/messages.json +++ b/apps/desktop/src/locales/fr/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Fonctionnalité non disponible" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "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" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Options de connexion propriétaires à deux facteurs telles que YubiKey et Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Informations de contact" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Tous les Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Êtes-vous sûr de vouloir utiliser l'option \"Jamais\" ? Définir le verrouillage sur \"Jamais\" stocke la clé de chiffrement de votre coffre sur votre appareil. Si vous utilisez cette option, vous devez vous assurer de correctement protéger votre appareil." }, "vault": { - "message": "Coffre" + "message": "Coffre", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Se connecter avec le mot de passe principal" diff --git a/apps/desktop/src/locales/gl/messages.json b/apps/desktop/src/locales/gl/messages.json index d607bb8d097..0211550b08d 100644 --- a/apps/desktop/src/locales/gl/messages.json +++ b/apps/desktop/src/locales/gl/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Feature unavailable" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "All Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/he/messages.json b/apps/desktop/src/locales/he/messages.json index 868cd9ccbc5..5881405c190 100644 --- a/apps/desktop/src/locales/he/messages.json +++ b/apps/desktop/src/locales/he/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "יכולת זו לא זמינה" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 ג'יגה של מקום אחסון מוצפן עבור קבצים מצורפים." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "אפשרויות כניסה דו־שלבית קנייניות כגון YubiKey ו־Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "פרטי איש קשר" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "כל הסֵנְדים", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "האם אתה בטוח שברצונך להשתמש באפשרות \"לעולם לא\"? הגדרת אפשרויות הנעילה שלך ל\"לעולם לא\" מאחסנת את מפתח ההצפנה של הכספת שלך במכשיר שלך. אם אתה משתמש באפשרות זו עליך לוודא שאתה שומר על המכשיר שלך מוגן כראוי." }, "vault": { - "message": "כספת" + "message": "כספת", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "כניסה עם סיסמה ראשית" diff --git a/apps/desktop/src/locales/hi/messages.json b/apps/desktop/src/locales/hi/messages.json index 2ab323eedc9..2fac0a369fd 100644 --- a/apps/desktop/src/locales/hi/messages.json +++ b/apps/desktop/src/locales/hi/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Feature unavailable" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "All Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/hr/messages.json b/apps/desktop/src/locales/hr/messages.json index 0f7a8185118..07effb638b8 100644 --- a/apps/desktop/src/locales/hr/messages.json +++ b/apps/desktop/src/locales/hr/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Značajka nije dostupna" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB šifriranog prostora za pohranu podataka." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Mogućnosti za prijavu u dva koraka kao što su YubiKey i Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Kontaktne informacije" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Svi Sendovi", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Sigurno želiš koristiti opciju „Nikada”? Postavljanje opcija zaključavanja na „Nikada” pohranjuje šifru tvojeg trezora na tvom uređaju. Ako koristiš ovu opciju, trebalo bi osigurati da je uređaj pravilno zaštićen." }, "vault": { - "message": "Trezor" + "message": "Trezor", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Prijava glavnom lozinkom" diff --git a/apps/desktop/src/locales/hu/messages.json b/apps/desktop/src/locales/hu/messages.json index 9a6dd787f8c..583d5b86a59 100644 --- a/apps/desktop/src/locales/hu/messages.json +++ b/apps/desktop/src/locales/hu/messages.json @@ -1093,6 +1093,24 @@ "learnMore": { "message": "További információ" }, + "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" + }, "featureUnavailable": { "message": "Ez a funkció nem érhető el." }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB titkosított fájlmelléklet tárhely." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ titkosított tárhely a fájlmellékletekhez.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Saját kétlépcsős bejelentkezési lehetőségek mint a YubiKey és a Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Elérhetőségi adatok" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Összes küldés", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Biztosan szeretnénk használni a \"Soha\" opciót? A zárolási opciók \"Soha\" értékre állítása a széf titkosítási kulcsát az eszközön tárolja. Ennek az opciónak a használatakor célszerű az eszköz megfelelő védettségét biztosítani." }, "vault": { - "message": "Széf" + "message": "Széf", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Bejelentkezés mesterjelszóval" diff --git a/apps/desktop/src/locales/id/messages.json b/apps/desktop/src/locales/id/messages.json index 188ee153da1..fbbb1440990 100644 --- a/apps/desktop/src/locales/id/messages.json +++ b/apps/desktop/src/locales/id/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Fitur Tidak Tersedia" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB penyimpanan berkas yang dienkripsi." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Semua Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Brankas" + "message": "Brankas", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/it/messages.json b/apps/desktop/src/locales/it/messages.json index 8caf4982356..64e31e4136f 100644 --- a/apps/desktop/src/locales/it/messages.json +++ b/apps/desktop/src/locales/it/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Funzionalità non disponibile" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "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" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Opzioni di verifica in due passaggi proprietarie come YubiKey e Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Informazioni di contatto" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Tutti i Send", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Sei sicuro di voler usare l'opzione \"Mai\"? Impostare le opzioni di blocco su \"Mai\" salverà la chiave di crittografia della cassaforte sul tuo dispositivo. Se usi questa opzione, assicurati di mantenere il tuo dispositivo adeguatamente protetto." }, "vault": { - "message": "Cassaforte" + "message": "Cassaforte", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Accedi con password principale" diff --git a/apps/desktop/src/locales/ja/messages.json b/apps/desktop/src/locales/ja/messages.json index ca50828b12c..5accef2b5ee 100644 --- a/apps/desktop/src/locales/ja/messages.json +++ b/apps/desktop/src/locales/ja/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "サービスが利用できません" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1GB の暗号化されたファイルストレージ。" }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "YubiKey、Duo などのプロプライエタリな2段階認証オプション。" }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "すべての Send", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "本当に「なし」オプションを使用しますか?ロックオプションを「なし」に設定すると、保管庫の暗号化キーがデバイスに保存されます。 このオプションを使用する場合は、デバイスを適切に保護する必要があります。" }, "vault": { - "message": "保管庫" + "message": "保管庫", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "マスターパスワードでログイン" diff --git a/apps/desktop/src/locales/ka/messages.json b/apps/desktop/src/locales/ka/messages.json index 9337286d3fd..cca3ab548cf 100644 --- a/apps/desktop/src/locales/ka/messages.json +++ b/apps/desktop/src/locales/ka/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Feature unavailable" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "All Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "საცავი" + "message": "საცავი", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/km/messages.json b/apps/desktop/src/locales/km/messages.json index d607bb8d097..0211550b08d 100644 --- a/apps/desktop/src/locales/km/messages.json +++ b/apps/desktop/src/locales/km/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Feature unavailable" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "All Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/kn/messages.json b/apps/desktop/src/locales/kn/messages.json index d1375efee8c..4ef1f0edd05 100644 --- a/apps/desktop/src/locales/kn/messages.json +++ b/apps/desktop/src/locales/kn/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "ವೈಶಿಷ್ಟ್ಯ ಲಭ್ಯವಿಲ್ಲ" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "ಫೈಲ್ ಲಗತ್ತುಗಳಿಗಾಗಿ 1 ಜಿಬಿ ಎನ್‌ಕ್ರಿಪ್ಟ್ ಮಾಡಿದ ಸಂಗ್ರಹ." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "ಎಲ್ಲಾ ಕಳುಹಿಸುತ್ತದೆ", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/ko/messages.json b/apps/desktop/src/locales/ko/messages.json index 2e40b8d7f23..9f6153cb314 100644 --- a/apps/desktop/src/locales/ko/messages.json +++ b/apps/desktop/src/locales/ko/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "기능 사용할 수 없음" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1GB의 암호화된 파일 저장소." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "모든 Send", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "정말 \"잠그지 않음\" 옵션을 사용하시겠습니까? 잠금 옵션을 \"잠그지 않음\"으로 설정하면 사용자 보관함의 암호화 키를 사용자의 기기에 보관합니다. 이 옵션을 사용하기 전에 사용자의 기기가 잘 보호되어 있는 상태인지 확인하십시오." }, "vault": { - "message": "보관함" + "message": "보관함", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "마스터 비밀번호로 로그인" diff --git a/apps/desktop/src/locales/lt/messages.json b/apps/desktop/src/locales/lt/messages.json index 16f328d6240..d7612765f1a 100644 --- a/apps/desktop/src/locales/lt/messages.json +++ b/apps/desktop/src/locales/lt/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Funkcija nepasiekiama" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB užšifruotos vietos diske failų prisegimams." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Patentuotos dviejų žingsnių prisijungimo parinktys, tokios kaip YubiKey ir Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Visi Sendai", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Ar jūs tikrai norite naudoti \"Niekada\" pasirinkimą? Nustačius savo užraktą į \"Niekada\", saugyklos šifravimo raktas bus laikomas jūsų įrenginyje. Jei norite naudotis šiuo pasirinkimu, užtikrinkite savo įrenginio saugą." }, "vault": { - "message": "Saugykla" + "message": "Saugykla", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Prisijungti su pagrindiniu slaptažodžiu" diff --git a/apps/desktop/src/locales/lv/messages.json b/apps/desktop/src/locales/lv/messages.json index 7800a4e9024..5624f89f5db 100644 --- a/apps/desktop/src/locales/lv/messages.json +++ b/apps/desktop/src/locales/lv/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Iespēja nav pieejama" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "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" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Tādas slēgtā pirmavota divpakāpju pieteikšanās iespējas kā YubiKey un Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Saziņas informācija" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Visi Send", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Vai tiešām izmantot uzstādījumu \"Nekad\"? Uzstādot aizslēgšanas iespēju uz \"Nekad\", šifrēšanas atslēga tiek glabāta ierīcē. Ja šī iespēja tiek izmantota, jāpārliecinās, ka ierīce tiek pienācīgi aizsargāta." }, "vault": { - "message": "Glabātava" + "message": "Glabātava", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Pieteikties ar galveno paroli" diff --git a/apps/desktop/src/locales/me/messages.json b/apps/desktop/src/locales/me/messages.json index 29e3cefee0c..684c4682aa0 100644 --- a/apps/desktop/src/locales/me/messages.json +++ b/apps/desktop/src/locales/me/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Funkcija nije dostupna" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB šifrovanog skladišta za priloge datoteka." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "All Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/ml/messages.json b/apps/desktop/src/locales/ml/messages.json index 662ce9a1fc6..e91ca21a686 100644 --- a/apps/desktop/src/locales/ml/messages.json +++ b/apps/desktop/src/locales/ml/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "സവിശേഷത ലഭ്യമല്ല" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "ഫയൽ അറ്റാച്ചുമെന്റുകൾക്കായി 1 GB എൻക്രിപ്റ്റുചെയ്‌ത സ്റ്റോറേജ്." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "എല്ലാം Send-കൾ", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/mr/messages.json b/apps/desktop/src/locales/mr/messages.json index d607bb8d097..0211550b08d 100644 --- a/apps/desktop/src/locales/mr/messages.json +++ b/apps/desktop/src/locales/mr/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Feature unavailable" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "All Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/my/messages.json b/apps/desktop/src/locales/my/messages.json index bcbd26cede3..969e67d3560 100644 --- a/apps/desktop/src/locales/my/messages.json +++ b/apps/desktop/src/locales/my/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Feature unavailable" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "All Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/nb/messages.json b/apps/desktop/src/locales/nb/messages.json index 42fb6d479c0..35bd7750481 100644 --- a/apps/desktop/src/locales/nb/messages.json +++ b/apps/desktop/src/locales/nb/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Funksjonen er utilgjengelig" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB med kryptert fillagring." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Kontaktinformasjon" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Alle Send-er", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Er du sikker på at du vil bruke alternativet «Aldri»? Ved å angi låsemulighetene til «Aldri» lagres hvelvets krypteringsnøkkel på enheten. Hvis du bruker dette alternativet, bør du sørge for at du holder enheten forsvarlig beskyttet." }, "vault": { - "message": "Hvelv" + "message": "Hvelv", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Logg inn med hovedpassord" diff --git a/apps/desktop/src/locales/ne/messages.json b/apps/desktop/src/locales/ne/messages.json index cce8f6a2ba5..b8038093f90 100644 --- a/apps/desktop/src/locales/ne/messages.json +++ b/apps/desktop/src/locales/ne/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Feature unavailable" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "All Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/nl/messages.json b/apps/desktop/src/locales/nl/messages.json index 82b51b018c5..8833f59489a 100644 --- a/apps/desktop/src/locales/nl/messages.json +++ b/apps/desktop/src/locales/nl/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Functionaliteit niet beschikbaar" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB versleutelde opslag voor bijlagen." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ versleutelde opslag voor bijlagen.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Eigen opties voor tweestapsaanmelding zoals YubiKey en Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact informatie" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Alle Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Weet je zeker dat je de optie \"Nooit\" wilt gebruiken? De vergrendelingsoptie \"Nooit\" bewaart de sleutel van je kluis op je apparaat. Als je deze optie gebruikt, moet je ervoor zorgen dat je je apparaat naar behoren beschermt." }, "vault": { - "message": "Kluis" + "message": "Kluis", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Inloggen met je hoofdwachtwoord" @@ -3631,7 +3663,7 @@ "message": "Import directly from browser" }, "browserProfile": { - "message": "Browser Profile" + "message": "Browserprofiel" }, "seeDetailedInstructions": { "message": "Zie gedetailleerde instructies op onze helpsite op", diff --git a/apps/desktop/src/locales/nn/messages.json b/apps/desktop/src/locales/nn/messages.json index 08567979e8b..5264ce8c561 100644 --- a/apps/desktop/src/locales/nn/messages.json +++ b/apps/desktop/src/locales/nn/messages.json @@ -1093,6 +1093,24 @@ "learnMore": { "message": "Lær meir" }, + "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" + }, "featureUnavailable": { "message": "Funksjon er utilgjengeleg" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Alle Send-ar", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/or/messages.json b/apps/desktop/src/locales/or/messages.json index 4ca05acaac5..b145306e14d 100644 --- a/apps/desktop/src/locales/or/messages.json +++ b/apps/desktop/src/locales/or/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Feature unavailable" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "All Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/pl/messages.json b/apps/desktop/src/locales/pl/messages.json index c05e7f05cb1..4cb6650e2c7 100644 --- a/apps/desktop/src/locales/pl/messages.json +++ b/apps/desktop/src/locales/pl/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Funkcja jest niedostępna" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB miejsca na zaszyfrowane załączniki." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Specjalne opcje logowania dwustopniowego, takie jak YubiKey i Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Informacje kontaktowe" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Wszystkie wysyłki", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Czy na pewno chcesz użyć opcji „Nigdy”? Ustawienie blokady na „Nigdy” spowoduje przechowywanie klucza szyfrowania sejfu na urządzeniu. Upewnij się, że urządzenie jest odpowiednio chronione." }, "vault": { - "message": "Sejf" + "message": "Sejf", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Logowanie hasłem głównym" diff --git a/apps/desktop/src/locales/pt_BR/messages.json b/apps/desktop/src/locales/pt_BR/messages.json index e351bfb7dba..dc64f1b701b 100644 --- a/apps/desktop/src/locales/pt_BR/messages.json +++ b/apps/desktop/src/locales/pt_BR/messages.json @@ -1093,6 +1093,24 @@ "learnMore": { "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" + }, "featureUnavailable": { "message": "Recurso indisponível" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB de armazenamento criptografado para anexos de arquivos." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ de armazenamento criptografado para anexos de arquivo.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Opções proprietárias de autenticação em duas etapas como YubiKey e Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Informações de contato" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Todos os Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Você tem certeza que deseja usar a opção \"Nunca\"? Ao usar o \"Nunca\", a chave de criptografia do seu cofre é armazenada no seu dispositivo. Se você usar esta opção, deve garantir que mantém seu dispositivo devidamente protegido." }, "vault": { - "message": "Cofre" + "message": "Cofre", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Conectar-se com senha principal" diff --git a/apps/desktop/src/locales/pt_PT/messages.json b/apps/desktop/src/locales/pt_PT/messages.json index de0427ddab0..c0e396c63c4 100644 --- a/apps/desktop/src/locales/pt_PT/messages.json +++ b/apps/desktop/src/locales/pt_PT/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Funcionalidade indisponível" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "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" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Opções proprietárias de verificação de dois passos, como YubiKey e Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Informações de contacto" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Todos os Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Tem a certeza de que deseja utilizar a opção \"Nunca\"? Ao definir as opções de bloqueio para \"Nunca\" armazena a chave de encriptação do seu cofre no seu dispositivo. Se utilizar esta opção deve assegurar-se de que mantém o seu dispositivo devidamente protegido." }, "vault": { - "message": "Cofre" + "message": "Cofre", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Iniciar sessão com a palavra-passe mestra" @@ -3048,7 +3080,7 @@ "message": "Será notificado quando o pedido for aprovado" }, "needAnotherOption": { - "message": "O início de sessão com o dispositivo deve ser ativado nas definições da aplicação Bitwarden. Precisa de outra opção?" + "message": "O início de sessão com o dispositivo deve ser ativado nas definições da app Bitwarden. Precisa de outra opção?" }, "viewAllLogInOptions": { "message": "Ver todas as opções de início de sessão" diff --git a/apps/desktop/src/locales/ro/messages.json b/apps/desktop/src/locales/ro/messages.json index a72ce3547e9..d2e589836e0 100644 --- a/apps/desktop/src/locales/ro/messages.json +++ b/apps/desktop/src/locales/ro/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Funcție indisponibilă" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "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" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Toate Send-urile", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Sunteți sigur că doriți să folosiți opțiunea „Niciodată”? Setarea opțiunilor de blocare la „Niciodată” stochează cheia de criptare a seifului pe dispozitivul dumneavoastră. Dacă utilizați această opțiune, trebuie să vă asigurați că vă păstrați dispozitivul protejat corespunzător." }, "vault": { - "message": "Seif" + "message": "Seif", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Autentificați-vă cu parola principală" diff --git a/apps/desktop/src/locales/ru/messages.json b/apps/desktop/src/locales/ru/messages.json index 914bb603630..a59ae2282d5 100644 --- a/apps/desktop/src/locales/ru/messages.json +++ b/apps/desktop/src/locales/ru/messages.json @@ -1093,6 +1093,24 @@ "learnMore": { "message": "Подробнее" }, + "migrationsFailed": { + "message": "Произошла ошибка при обновлении настроек шифрования." + }, + "updateEncryptionSettingsTitle": { + "message": "Обновите настройки шифрования" + }, + "updateEncryptionSettingsDesc": { + "message": "Новые рекомендуемые настройки шифрования повысят безопасность вашего аккаунта. Введите мастер-пароль, чтобы обновить сейчас." + }, + "confirmIdentityToContinue": { + "message": "Подтвердите вашу личность, чтобы продолжить" + }, + "enterYourMasterPassword": { + "message": "Введите ваш мастер-пароль" + }, + "updateSettings": { + "message": "Обновить настройки" + }, "featureUnavailable": { "message": "Функция недоступна" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 ГБ зашифрованного хранилища для вложенных файлов." }, + "premiumSignUpStorageV2": { + "message": "Зашифрованного хранилища для вложенных файлов: $SIZE$", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Проприетарные варианты двухэтапной аутентификации, такие как YubiKey или Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Контактная информация" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Все Send’ы", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Вы действительно хотите отключить блокировку хранилища? В этом случае ключ шифрования вашего хранилища будет сохранен на вашем устройстве. Отключая блокировку, вы должны убедиться, что ваше устройство надежно защищено." }, "vault": { - "message": "Хранилище" + "message": "Хранилище", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Войти с мастер-паролем" diff --git a/apps/desktop/src/locales/si/messages.json b/apps/desktop/src/locales/si/messages.json index a83b2cbf536..51333386f8a 100644 --- a/apps/desktop/src/locales/si/messages.json +++ b/apps/desktop/src/locales/si/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Feature unavailable" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "All Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/sk/messages.json b/apps/desktop/src/locales/sk/messages.json index 0b14b961bbb..0e62a8ee8b0 100644 --- a/apps/desktop/src/locales/sk/messages.json +++ b/apps/desktop/src/locales/sk/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Funkcia nie je k dispozícii" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB šifrovaného úložiska." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ šifrovaného úložiska na prílohy.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietárne možnosti dvojstupňového prihlásenia ako napríklad YubiKey a Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Kontaktné informácie" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Všetky Sendy", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Ste si istí, že chcete použiť možnosť \"Nikdy\"? Táto predvoľba ukladá šifrovací kľúč od trezora priamo na zariadení. Ak použijete túto možnosť, mali by ste svoje zariadenie náležite zabezpečiť." }, "vault": { - "message": "Trezor" + "message": "Trezor", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Prihlásenie pomocou hlavného hesla" diff --git a/apps/desktop/src/locales/sl/messages.json b/apps/desktop/src/locales/sl/messages.json index 353c6858afa..c640320ab1a 100644 --- a/apps/desktop/src/locales/sl/messages.json +++ b/apps/desktop/src/locales/sl/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Funkcija ni na voljo" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Vsi Sendsi", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/sr/messages.json b/apps/desktop/src/locales/sr/messages.json index 1bc4a0ed016..4e69efe726f 100644 --- a/apps/desktop/src/locales/sr/messages.json +++ b/apps/desktop/src/locales/sr/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Функција је недоступна" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1ГБ шифровано складиште за прилоге." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Приоритарне опције пријаве у два корака као што су YubiKey и Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Контакт подаци" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Сва слања", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Да ли сте сигурни да желите да користите опцију „Никад“? Ако поставите опције закључавања на „Никада“, на вашем уређају се чува кључ за шифровање сефа. Ако користите ову опцију, осигурајте да је уређај правилно заштићен." }, "vault": { - "message": "Сеф" + "message": "Сеф", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Пријавите се са главном лозинком" diff --git a/apps/desktop/src/locales/sv/messages.json b/apps/desktop/src/locales/sv/messages.json index 93d56419ae3..6f3e68c8959 100644 --- a/apps/desktop/src/locales/sv/messages.json +++ b/apps/desktop/src/locales/sv/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Funktion ej tillgänglig" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB krypterad lagring." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ krypterad lagring för filbilagor.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Premium-alternativ för tvåstegsverifiering, såsom YubiKey och Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Kontaktinformation" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Alla Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Är du säker på att du vill använda alternativet ”Aldrig”? Att ställa in låsnings-alternativet till ”Aldrig” lagrar valvets krypteringsnyckel på datorn. Om du använder det här alternativet bör du se till att du håller datorn ordentligt skyddad." }, "vault": { - "message": "Valv" + "message": "Valv", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Logga in med huvudlösenord" diff --git a/apps/desktop/src/locales/ta/messages.json b/apps/desktop/src/locales/ta/messages.json index 2f9d12917d6..a83867a9eff 100644 --- a/apps/desktop/src/locales/ta/messages.json +++ b/apps/desktop/src/locales/ta/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "வசதி கிடைக்கவில்லை" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "கோப்பு இணைப்புகளுக்கு 1 GB குறியாக்கப்பட்ட சேமிப்பகம்." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "YubiKey மற்றும் Duo போன்ற பிரத்யேக இரண்டு-படி உள்நுழைவு விருப்பங்கள்." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "தொடர்புத் தகவல்" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "அனைத்து அனுப்புதல்களும்", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "\"ஒருபோதும் இல்லை\" விருப்பத்தைப் பயன்படுத்த விரும்புகிறீர்களா? உங்கள் பூட்டு விருப்பங்களை \"ஒருபோதும் இல்லை\" என அமைப்பது உங்கள் வால்ட்டின் குறியாக்க சாவியை உங்கள் சாதனத்தில் சேமிக்கிறது. இந்த விருப்பத்தைப் பயன்படுத்தினால், உங்கள் சாதனத்தை நீங்கள் சரியாகப் பாதுகாப்பதை உறுதி செய்ய வேண்டும்." }, "vault": { - "message": "வால்ட்" + "message": "வால்ட்", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "முதன்மை கடவுச்சொல்லுடன் உள்நுழைக" diff --git a/apps/desktop/src/locales/te/messages.json b/apps/desktop/src/locales/te/messages.json index d607bb8d097..0211550b08d 100644 --- a/apps/desktop/src/locales/te/messages.json +++ b/apps/desktop/src/locales/te/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Feature unavailable" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "All Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/th/messages.json b/apps/desktop/src/locales/th/messages.json index d794ace629c..f5bbde79a86 100644 --- a/apps/desktop/src/locales/th/messages.json +++ b/apps/desktop/src/locales/th/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Feature Unavailable" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 GB of encrypted file storage." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Contact information" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "ส่งทั้งหมด", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." }, "vault": { - "message": "Vault" + "message": "Vault", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Log in with master password" diff --git a/apps/desktop/src/locales/tr/messages.json b/apps/desktop/src/locales/tr/messages.json index ac67b177cbf..bb0b6f2fd51 100644 --- a/apps/desktop/src/locales/tr/messages.json +++ b/apps/desktop/src/locales/tr/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Özellik kullanılamıyor" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "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" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "YubiKey ve Duo gibi marka bazlı iki aşamalı giriş seçenekleri." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "İletişim bilgileri" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Tüm Send'ler", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "\"Asla\" seçeneğini kullanmak istediğinizden emin misiniz? Kilit seçeneklerinizi \"Asla\" olarak ayarlarsanız kasanızın şifreleme anahtarı cihazınızda saklanacaktır. Bu seçeneği kullanırsanız cihazınızı çok iyi korumalısınız." }, "vault": { - "message": "Kasa" + "message": "Kasa", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Ana parola ile giriş yap" diff --git a/apps/desktop/src/locales/uk/messages.json b/apps/desktop/src/locales/uk/messages.json index 7ed0710ca74..bed09352f03 100644 --- a/apps/desktop/src/locales/uk/messages.json +++ b/apps/desktop/src/locales/uk/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Функція недоступна" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1 ГБ зашифрованого сховища для файлів." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Додаткові можливості двоетапної авторизації, як-от YubiKey та Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Контактна інформація" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Усі відправлення", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Ви впевнені, що ніколи не хочете блокувати? Встановивши параметр блокування \"Ніколи\", ключ шифрування сховища зберігатиметься на вашому пристрої. Користуючись цим параметром, ви маєте бути впевнені в тому, що ваш пристрій надійно захищений." }, "vault": { - "message": "Сховище" + "message": "Сховище", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Увійти з головним паролем" diff --git a/apps/desktop/src/locales/vi/messages.json b/apps/desktop/src/locales/vi/messages.json index 8bf88aba458..30038f046db 100644 --- a/apps/desktop/src/locales/vi/messages.json +++ b/apps/desktop/src/locales/vi/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "Tính năng không có sẵn" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "1GB bộ nhớ lưu trữ được mã hóa cho các tệp đính kèm." }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "Các tùy chọn xác minh hai bước như YubiKey và Duo." }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "Thông tin liên hệ" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "Tất cả Send", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "Bạn có chắc chắn muốn chọn \"Không bao giờ\" không? Lựa chọn này sẽ lưu khóa mã hóa kho của bạn trực tiếp trên thiết bị. Hãy nhớ bảo vệ thiết bị của bạn thật cẩn thận nếu bạn chọn tùy chọn này." }, "vault": { - "message": "Kho lưu trữ" + "message": "Kho lưu trữ", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "Đăng nhập bằng mật khẩu chính" diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index 70711c7aa4e..80965415475 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "功能不可用" }, @@ -1488,7 +1506,16 @@ "message": "注册高级会员将获得:" }, "premiumSignUpStorage": { - "message": "1 GB 文件附件加密存储。" + "message": "1 GB 文件附件加密存储空间。" + }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ 文件附件加密存储空间。", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } }, "premiumSignUpTwoStepOptions": { "message": "专有的两步登录选项,如 YubiKey 和 Duo。" @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "联系信息" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "所有的 Send", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "确定要使用「从不」选项吗?将锁定选项设置为「从不」会将密码库的加密密钥存储在您的设备上。如果使用此选项,您必须确保您的设备安全。" }, "vault": { - "message": "密码库" + "message": "密码库", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "使用主密码登录" diff --git a/apps/desktop/src/locales/zh_TW/messages.json b/apps/desktop/src/locales/zh_TW/messages.json index 61fc00543ed..e412adf9e5b 100644 --- a/apps/desktop/src/locales/zh_TW/messages.json +++ b/apps/desktop/src/locales/zh_TW/messages.json @@ -1093,6 +1093,24 @@ "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" + }, "featureUnavailable": { "message": "功能不可用" }, @@ -1490,6 +1508,15 @@ "premiumSignUpStorage": { "message": "用於檔案附件的 1 GB 的加密檔案儲存空間。" }, + "premiumSignUpStorageV2": { + "message": "$SIZE$ encrypted storage for file attachments.", + "placeholders": { + "size": { + "content": "$1", + "example": "1 GB" + } + } + }, "premiumSignUpTwoStepOptions": { "message": "專有的兩步驟登入選項,例如 YubiKey 和 Duo。" }, @@ -2228,6 +2255,10 @@ "contactInfo": { "message": "聯絡資訊" }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "allSends": { "message": "所有 Send", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2991,7 +3022,8 @@ "message": "您確定要使用「永不」選項嗎?將鎖定選項設定為「永不」會將密碼庫的加密金鑰儲存在您的裝置上。如果使用此選項,應確保您的裝置是安全的。" }, "vault": { - "message": "密碼庫" + "message": "密碼庫", + "description": "'Vault' is a noun and refers to the Bitwarden Vault feature." }, "loginWithMasterPassword": { "message": "使用主密碼登入" diff --git a/apps/desktop/src/main/native-messaging.main.ts b/apps/desktop/src/main/native-messaging.main.ts index ba5d8616752..a0c17a115e0 100644 --- a/apps/desktop/src/main/native-messaging.main.ts +++ b/apps/desktop/src/main/native-messaging.main.ts @@ -14,7 +14,7 @@ import { isDev } from "../utils"; import { WindowMain } from "./window.main"; export class NativeMessagingMain { - private ipcServer: ipc.IpcServer | null; + private ipcServer: ipc.NativeIpcServer | null; private connected: number[] = []; constructor( @@ -78,7 +78,7 @@ export class NativeMessagingMain { this.ipcServer.stop(); } - this.ipcServer = await ipc.IpcServer.listen("bw", (error, msg) => { + this.ipcServer = await ipc.NativeIpcServer.listen("bw", (error, msg) => { switch (msg.kind) { case ipc.IpcMessageType.Connected: { this.connected.push(msg.clientId); @@ -314,6 +314,7 @@ export class NativeMessagingMain { "Microsoft Edge Canary": `${this.homedir()}/Library/Application\ Support/Microsoft\ Edge\ Canary/`, Vivaldi: `${this.homedir()}/Library/Application\ Support/Vivaldi/`, Zen: `${this.homedir()}/Library/Application\ Support/Zen/`, + Helium: `${this.homedir()}/Library/Application\ Support/net.imput.helium/`, }; /* eslint-enable no-useless-escape */ } diff --git a/apps/desktop/src/main/tray.main.ts b/apps/desktop/src/main/tray.main.ts index b7ddefe6e1b..81df6497ca8 100644 --- a/apps/desktop/src/main/tray.main.ts +++ b/apps/desktop/src/main/tray.main.ts @@ -53,9 +53,14 @@ export class TrayMain { }, { visible: isDev(), - label: "Fake Popup", + label: "Fake Popup Select", click: () => this.fakePopup(), }, + { + visible: isDev(), + label: "Fake Popup Create", + click: () => this.fakePopupCreate(), + }, { type: "separator" }, { label: this.i18nService.t("exit"), @@ -218,4 +223,8 @@ export class TrayMain { private async fakePopup() { await this.messagingService.send("loadurl", { url: "/passkeys", modal: true }); } + + private async fakePopupCreate() { + await this.messagingService.send("loadurl", { url: "/create-passkey", modal: true }); + } } diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index 0e234126ea3..bbdd2ad0a0f 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -100,10 +100,10 @@ export class WindowMain { applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]); // Because modal is used in front of another app, UX wise it makes sense to hide the main window when leaving modal mode. this.win.hide(); - } else if (!lastValue.isModalModeActive && newValue.isModalModeActive) { + } else if (newValue.isModalModeActive) { // Apply the popup modal styles this.logService.info("Applying popup modal styles", newValue.modalPosition); - applyPopupModalStyles(this.win, newValue.modalPosition); + applyPopupModalStyles(this.win, newValue.showTrafficButtons, newValue.modalPosition); this.win.show(); } }), @@ -273,7 +273,7 @@ export class WindowMain { this.win = new BrowserWindow({ width: this.windowStates[mainWindowSizeKey].width, height: this.windowStates[mainWindowSizeKey].height, - minWidth: 680, + minWidth: 600, minHeight: 500, x: this.windowStates[mainWindowSizeKey].x, y: this.windowStates[mainWindowSizeKey].y, diff --git a/apps/desktop/src/platform/components/approve-ssh-request.html b/apps/desktop/src/platform/components/approve-ssh-request.html index 55092788079..c691891487e 100644 --- a/apps/desktop/src/platform/components/approve-ssh-request.html +++ b/apps/desktop/src/platform/components/approve-ssh-request.html @@ -2,13 +2,11 @@
    {{ "sshkeyApprovalTitle" | i18n }}
    - + @if (params.isAgentForwarding) { + {{ 'agentForwardingWarningText' | i18n }} - + + } {{params.applicationName}} {{ "sshkeyApprovalMessageInfix" | i18n }} {{params.cipherName}} diff --git a/apps/desktop/src/platform/components/approve-ssh-request.ts b/apps/desktop/src/platform/components/approve-ssh-request.ts index 1741124774d..a2cae3d59e7 100644 --- a/apps/desktop/src/platform/components/approve-ssh-request.ts +++ b/apps/desktop/src/platform/components/approve-ssh-request.ts @@ -12,6 +12,7 @@ import { FormFieldModule, IconButtonModule, DialogService, + CalloutModule, } from "@bitwarden/components"; export interface ApproveSshRequestParams { @@ -35,6 +36,7 @@ export interface ApproveSshRequestParams { ReactiveFormsModule, AsyncActionsModule, FormFieldModule, + CalloutModule, ], }) export class ApproveSshRequestComponent { diff --git a/apps/desktop/src/platform/main/autofill/native-autofill.main.ts b/apps/desktop/src/platform/main/autofill/native-autofill.main.ts index 71cfcab84ba..c0d860d74db 100644 --- a/apps/desktop/src/platform/main/autofill/native-autofill.main.ts +++ b/apps/desktop/src/platform/main/autofill/native-autofill.main.ts @@ -7,6 +7,11 @@ import { WindowMain } from "../../../main/window.main"; import { CommandDefinition } from "./command"; +type BufferedMessage = { + channel: string; + data: any; +}; + export type RunCommandParams = { namespace: C["namespace"]; command: C["name"]; @@ -16,13 +21,44 @@ export type RunCommandParams = { export type RunCommandResult = C["output"]; export class NativeAutofillMain { - private ipcServer: autofill.IpcServer | null; + private ipcServer?: autofill.AutofillIpcServer; + private messageBuffer: BufferedMessage[] = []; + private listenerReady = false; constructor( private logService: LogService, private windowMain: WindowMain, ) {} + /** + * Safely sends a message to the renderer, buffering it if the server isn't ready yet + */ + private safeSend(channel: string, data: any) { + if (this.listenerReady && this.windowMain.win?.webContents) { + this.windowMain.win.webContents.send(channel, data); + } else { + this.messageBuffer.push({ channel, data }); + } + } + + /** + * Flushes all buffered messages to the renderer + */ + private flushMessageBuffer() { + if (!this.windowMain.win?.webContents) { + this.logService.error("Cannot flush message buffer - window not available"); + return; + } + + this.logService.info(`Flushing ${this.messageBuffer.length} buffered messages`); + + for (const { channel, data } of this.messageBuffer) { + this.windowMain.win.webContents.send(channel, data); + } + + this.messageBuffer = []; + } + async init() { ipcMain.handle( "autofill.runCommand", @@ -34,16 +70,16 @@ export class NativeAutofillMain { }, ); - this.ipcServer = await autofill.IpcServer.listen( + this.ipcServer = await autofill.AutofillIpcServer.listen( "af", // RegistrationCallback (error, clientId, sequenceNumber, request) => { if (error) { this.logService.error("autofill.IpcServer.registration", error); - this.ipcServer.completeError(clientId, sequenceNumber, String(error)); + this.ipcServer?.completeError(clientId, sequenceNumber, String(error)); return; } - this.windowMain.win.webContents.send("autofill.passkeyRegistration", { + this.safeSend("autofill.passkeyRegistration", { clientId, sequenceNumber, request, @@ -53,10 +89,10 @@ export class NativeAutofillMain { (error, clientId, sequenceNumber, request) => { if (error) { this.logService.error("autofill.IpcServer.assertion", error); - this.ipcServer.completeError(clientId, sequenceNumber, String(error)); + this.ipcServer?.completeError(clientId, sequenceNumber, String(error)); return; } - this.windowMain.win.webContents.send("autofill.passkeyAssertion", { + this.safeSend("autofill.passkeyAssertion", { clientId, sequenceNumber, request, @@ -66,33 +102,54 @@ export class NativeAutofillMain { (error, clientId, sequenceNumber, request) => { if (error) { this.logService.error("autofill.IpcServer.assertion", error); - this.ipcServer.completeError(clientId, sequenceNumber, String(error)); + this.ipcServer?.completeError(clientId, sequenceNumber, String(error)); return; } - this.windowMain.win.webContents.send("autofill.passkeyAssertionWithoutUserInterface", { + this.safeSend("autofill.passkeyAssertionWithoutUserInterface", { clientId, sequenceNumber, request, }); }, + // NativeStatusCallback + (error, clientId, sequenceNumber, status) => { + if (error) { + this.logService.error("autofill.IpcServer.nativeStatus", error); + this.ipcServer?.completeError(clientId, sequenceNumber, String(error)); + return; + } + this.safeSend("autofill.nativeStatus", { + clientId, + sequenceNumber, + status, + }); + }, ); + ipcMain.on("autofill.listenerReady", () => { + this.listenerReady = true; + this.logService.info( + `Listener is ready, flushing ${this.messageBuffer.length} buffered messages`, + ); + this.flushMessageBuffer(); + }); + ipcMain.on("autofill.completePasskeyRegistration", (event, data) => { - this.logService.warning("autofill.completePasskeyRegistration", data); + this.logService.debug("autofill.completePasskeyRegistration", data); const { clientId, sequenceNumber, response } = data; - this.ipcServer.completeRegistration(clientId, sequenceNumber, response); + this.ipcServer?.completeRegistration(clientId, sequenceNumber, response); }); ipcMain.on("autofill.completePasskeyAssertion", (event, data) => { - this.logService.warning("autofill.completePasskeyAssertion", data); + this.logService.debug("autofill.completePasskeyAssertion", data); const { clientId, sequenceNumber, response } = data; - this.ipcServer.completeAssertion(clientId, sequenceNumber, response); + this.ipcServer?.completeAssertion(clientId, sequenceNumber, response); }); ipcMain.on("autofill.completeError", (event, data) => { - this.logService.warning("autofill.completeError", data); + this.logService.debug("autofill.completeError", data); const { clientId, sequenceNumber, error } = data; - this.ipcServer.completeError(clientId, sequenceNumber, String(error)); + this.ipcServer?.completeError(clientId, sequenceNumber, String(error)); }); } diff --git a/apps/desktop/src/platform/models/domain/window-state.ts b/apps/desktop/src/platform/models/domain/window-state.ts index 0efc9a1efab..ab52531bb5d 100644 --- a/apps/desktop/src/platform/models/domain/window-state.ts +++ b/apps/desktop/src/platform/models/domain/window-state.ts @@ -14,5 +14,6 @@ export class WindowState { export class ModalModeState { isModalModeActive: boolean; + showTrafficButtons?: boolean; modalPosition?: { x: number; y: number }; // Modal position is often passed from the native UI } diff --git a/apps/desktop/src/platform/popup-modal-styles.ts b/apps/desktop/src/platform/popup-modal-styles.ts index 5c5619bd463..6ad00b44171 100644 --- a/apps/desktop/src/platform/popup-modal-styles.ts +++ b/apps/desktop/src/platform/popup-modal-styles.ts @@ -3,15 +3,19 @@ import { BrowserWindow } from "electron"; import { WindowState } from "./models/domain/window-state"; // change as needed, however limited by mainwindow minimum size -const popupWidth = 680; -const popupHeight = 500; +const popupWidth = 600; +const popupHeight = 600; type Position = { x: number; y: number }; -export function applyPopupModalStyles(window: BrowserWindow, position?: Position) { +export function applyPopupModalStyles( + window: BrowserWindow, + showTrafficButtons: boolean = true, + position?: Position, +) { window.unmaximize(); window.setSize(popupWidth, popupHeight); - window.setWindowButtonVisibility?.(false); + window.setWindowButtonVisibility?.(showTrafficButtons); window.setMenuBarVisibility?.(false); window.setResizable(false); window.setAlwaysOnTop(true); @@ -40,7 +44,7 @@ function positionWindow(window: BrowserWindow, position?: Position) { } export function applyMainWindowStyles(window: BrowserWindow, existingWindowState: WindowState) { - window.setMinimumSize(680, 500); + window.setMinimumSize(popupWidth, popupHeight); // need to guard against null/undefined values diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index 5af2fa571ec..a45ac753b3f 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -108,8 +108,13 @@ const ephemeralStore = { }; const localhostCallbackService = { - openSsoPrompt: (codeChallenge: string, state: string, email: string): Promise => { - return ipcRenderer.invoke("openSsoPrompt", { codeChallenge, state, email }); + openSsoPrompt: ( + codeChallenge: string, + state: string, + email: string, + orgSsoIdentifier?: string, + ): Promise => { + return ipcRenderer.invoke("openSsoPrompt", { codeChallenge, state, email, orgSsoIdentifier }); }, }; diff --git a/apps/desktop/src/platform/services/desktop-settings.service.ts b/apps/desktop/src/platform/services/desktop-settings.service.ts index c11f10646d7..d7c17433471 100644 --- a/apps/desktop/src/platform/services/desktop-settings.service.ts +++ b/apps/desktop/src/platform/services/desktop-settings.service.ts @@ -335,9 +335,14 @@ export class DesktopSettingsService { * Sets the modal mode of the application. Setting this changes the windows-size and other properties. * @param value `true` if the application is in modal mode, `false` if it is not. */ - async setModalMode(value: boolean, modalPosition?: { x: number; y: number }) { + async setModalMode( + value: boolean, + showTrafficButtons?: boolean, + modalPosition?: { x: number; y: number }, + ) { await this.modalModeState.update(() => ({ isModalModeActive: value, + showTrafficButtons, modalPosition, })); } diff --git a/apps/desktop/src/platform/services/sso-localhost-callback.service.ts b/apps/desktop/src/platform/services/sso-localhost-callback.service.ts index 75a84919b07..fdd9bc29237 100644 --- a/apps/desktop/src/platform/services/sso-localhost-callback.service.ts +++ b/apps/desktop/src/platform/services/sso-localhost-callback.service.ts @@ -25,20 +25,25 @@ export class SSOLocalhostCallbackService { private messagingService: MessageSender, private ssoUrlService: SsoUrlService, ) { - ipcMain.handle("openSsoPrompt", async (event, { codeChallenge, state, email }) => { - // Close any existing server before starting new one - if (this.currentServer) { - await this.closeCurrentServer(); - } + ipcMain.handle( + "openSsoPrompt", + async (event, { codeChallenge, state, email, orgSsoIdentifier }) => { + // Close any existing server before starting new one + if (this.currentServer) { + await this.closeCurrentServer(); + } - return this.openSsoPrompt(codeChallenge, state, email).then(({ ssoCode, recvState }) => { - this.messagingService.send("ssoCallback", { - code: ssoCode, - state: recvState, - redirectUri: this.ssoRedirectUri, - }); - }); - }); + return this.openSsoPrompt(codeChallenge, state, email, orgSsoIdentifier).then( + ({ ssoCode, recvState }) => { + this.messagingService.send("ssoCallback", { + code: ssoCode, + state: recvState, + redirectUri: this.ssoRedirectUri, + }); + }, + ); + }, + ); } private async closeCurrentServer(): Promise { @@ -58,6 +63,7 @@ export class SSOLocalhostCallbackService { codeChallenge: string, state: string, email: string, + orgSsoIdentifier?: string, ): Promise<{ ssoCode: string; recvState: string }> { const env = await firstValueFrom(this.environmentService.environment$); @@ -121,6 +127,7 @@ export class SSOLocalhostCallbackService { state, codeChallenge, email, + orgSsoIdentifier, ); // Set up error handler before attempting to listen diff --git a/apps/desktop/src/scss/migration.scss b/apps/desktop/src/scss/migration.scss new file mode 100644 index 00000000000..e3078158283 --- /dev/null +++ b/apps/desktop/src/scss/migration.scss @@ -0,0 +1,15 @@ +/** + * Desktop UI Migration + * + * These are temporary styles during the desktop ui migration. + **/ + +/** + * This removes any padding applied by the bit-layout to content. + * This should be revisited once the table is migrated, and again once drawers are migrated. + **/ +bit-layout { + #main-content { + padding: 0 0 0 0; + } +} diff --git a/apps/desktop/src/scss/styles.scss b/apps/desktop/src/scss/styles.scss index c579e6acdc0..b4082afd38c 100644 --- a/apps/desktop/src/scss/styles.scss +++ b/apps/desktop/src/scss/styles.scss @@ -15,5 +15,6 @@ @import "left-nav.scss"; @import "loading.scss"; @import "plugins.scss"; +@import "migration.scss"; @import "../../../../libs/angular/src/scss/icons.scss"; @import "../../../../libs/components/src/multi-select/scss/bw.theme"; diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.ts b/apps/desktop/src/vault/app/vault-v3/vault.component.ts index 8f1214c2338..d92c3080b07 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts @@ -273,10 +273,6 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { break; case "syncCompleted": if (this.vaultItemsComponent) { - // const filterFn = this.wrapFilterForCipherListView( - // this.activeFilter.buildFilter(), - // ); - // await this.vaultItemsComponent.reload(filterFn).catch(() => {}); const filter = this.activeFilter.buildFilter(); await this.vaultItemsComponent.reload(filter).catch(() => {}); } diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.ts b/apps/desktop/src/vault/app/vault/item-footer.component.ts index 0034bd9a43c..0ac12c928f2 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.ts +++ b/apps/desktop/src/vault/app/vault/item-footer.component.ts @@ -225,7 +225,7 @@ export class ItemFooterComponent implements OnInit, OnChanges { switchMap((id) => combineLatest([ this.cipherArchiveService.userCanArchive$(id), - this.cipherArchiveService.hasArchiveFlagEnabled$(), + this.cipherArchiveService.hasArchiveFlagEnabled$, ]), ), ), diff --git a/apps/web/package.json b/apps/web/package.json index a5399de920e..344a78f2a2c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2025.12.1", + "version": "2025.12.0", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", diff --git a/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts b/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts new file mode 100644 index 00000000000..e9cf87a114d --- /dev/null +++ b/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts @@ -0,0 +1,130 @@ +import { TestBed } from "@angular/core/testing"; +import { ReplaySubject } from "rxjs"; + +import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { + Environment, + EnvironmentService, +} from "@bitwarden/common/platform/abstractions/environment.service"; + +import { PeopleTableDataSource } from "./people-table-data-source"; + +interface MockUser { + id: string; + name: string; + email: string; + status: OrganizationUserStatusType; + checked?: boolean; +} + +class TestPeopleTableDataSource extends PeopleTableDataSource { + protected statusType = OrganizationUserStatusType; +} + +describe("PeopleTableDataSource", () => { + let dataSource: TestPeopleTableDataSource; + + const createMockUser = (id: string, checked: boolean = false): MockUser => ({ + id, + name: `User ${id}`, + email: `user${id}@example.com`, + status: OrganizationUserStatusType.Confirmed, + checked, + }); + + const createMockUsers = (count: number, checked: boolean = false): MockUser[] => { + return Array.from({ length: count }, (_, i) => createMockUser(`${i + 1}`, checked)); + }; + + beforeEach(() => { + const featureFlagSubject = new ReplaySubject(1); + featureFlagSubject.next(false); + + const environmentSubject = new ReplaySubject(1); + environmentSubject.next({ + isCloud: () => false, + } as Environment); + + const mockConfigService = { + getFeatureFlag$: jest.fn(() => featureFlagSubject.asObservable()), + } as any; + + const mockEnvironmentService = { + environment$: environmentSubject.asObservable(), + } as any; + + TestBed.configureTestingModule({ + providers: [ + { provide: ConfigService, useValue: mockConfigService }, + { provide: EnvironmentService, useValue: mockEnvironmentService }, + ], + }); + + dataSource = TestBed.runInInjectionContext( + () => new TestPeopleTableDataSource(mockConfigService, mockEnvironmentService), + ); + }); + + describe("limitAndUncheckExcess", () => { + it("should return all users when under limit", () => { + const users = createMockUsers(10, true); + dataSource.data = users; + + const result = dataSource.limitAndUncheckExcess(users, 500); + + expect(result).toHaveLength(10); + expect(result).toEqual(users); + expect(users.every((u) => u.checked)).toBe(true); + }); + + it("should limit users and uncheck excess", () => { + const users = createMockUsers(600, true); + dataSource.data = users; + + const result = dataSource.limitAndUncheckExcess(users, 500); + + expect(result).toHaveLength(500); + expect(result).toEqual(users.slice(0, 500)); + expect(users.slice(0, 500).every((u) => u.checked)).toBe(true); + expect(users.slice(500).every((u) => u.checked)).toBe(false); + }); + + it("should only affect users in the provided array", () => { + const allUsers = createMockUsers(1000, true); + dataSource.data = allUsers; + + // Pass only a subset (simulates filtering by status) + const subset = allUsers.slice(0, 600); + + const result = dataSource.limitAndUncheckExcess(subset, 500); + + expect(result).toHaveLength(500); + expect(subset.slice(0, 500).every((u) => u.checked)).toBe(true); + expect(subset.slice(500).every((u) => u.checked)).toBe(false); + // Users outside subset remain checked + expect(allUsers.slice(600).every((u) => u.checked)).toBe(true); + }); + }); + + describe("status counts", () => { + it("should correctly count users by status", () => { + const users: MockUser[] = [ + { ...createMockUser("1"), status: OrganizationUserStatusType.Invited }, + { ...createMockUser("2"), status: OrganizationUserStatusType.Invited }, + { ...createMockUser("3"), status: OrganizationUserStatusType.Accepted }, + { ...createMockUser("4"), status: OrganizationUserStatusType.Confirmed }, + { ...createMockUser("5"), status: OrganizationUserStatusType.Confirmed }, + { ...createMockUser("6"), status: OrganizationUserStatusType.Confirmed }, + { ...createMockUser("7"), status: OrganizationUserStatusType.Revoked }, + ]; + dataSource.data = users; + + expect(dataSource.invitedUserCount).toBe(2); + expect(dataSource.acceptedUserCount).toBe(1); + expect(dataSource.confirmedUserCount).toBe(3); + expect(dataSource.revokedUserCount).toBe(1); + expect(dataSource.activeUserCount).toBe(6); // All except revoked + }); + }); +}); diff --git a/apps/web/src/app/admin-console/common/people-table-data-source.ts b/apps/web/src/app/admin-console/common/people-table-data-source.ts index 4696f8a6738..9ac370d8c0d 100644 --- a/apps/web/src/app/admin-console/common/people-table-data-source.ts +++ b/apps/web/src/app/admin-console/common/people-table-data-source.ts @@ -1,14 +1,30 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { computed, Signal } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { map } from "rxjs"; + import { OrganizationUserStatusType, ProviderUserStatusType, } from "@bitwarden/common/admin-console/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { TableDataSource } from "@bitwarden/components"; import { StatusType, UserViewTypes } from "./base-members.component"; -const MaxCheckedCount = 500; +/** + * Default maximum for most bulk operations (confirm, remove, delete, etc.) + */ +export const MaxCheckedCount = 500; + +/** + * Maximum for bulk reinvite operations when the IncreaseBulkReinviteLimitForCloud + * feature flag is enabled on cloud environments. + */ +export const CloudBulkReinviteLimit = 8000; /** * Returns true if the user matches the status, or where the status is `null`, if the user is active (not revoked). @@ -56,6 +72,20 @@ export abstract class PeopleTableDataSource extends Tab confirmedUserCount: number; revokedUserCount: number; + /** True when increased bulk limit feature is enabled (feature flag + cloud environment) */ + readonly isIncreasedBulkLimitEnabled: Signal; + + constructor(configService: ConfigService, environmentService: EnvironmentService) { + super(); + + const featureFlagEnabled = toSignal( + configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud), + ); + const isCloud = toSignal(environmentService.environment$.pipe(map((env) => env.isCloud()))); + + this.isIncreasedBulkLimitEnabled = computed(() => featureFlagEnabled() && isCloud()); + } + override set data(data: T[]) { super.data = data; @@ -89,6 +119,14 @@ export abstract class PeopleTableDataSource extends Tab return this.data.filter((u) => (u as any).checked); } + /** + * Gets checked users in the order they appear in the filtered/sorted table view. + * Use this when enforcing limits to ensure visual consistency (top N visible rows stay checked). + */ + getCheckedUsersInVisibleOrder() { + return this.filteredData.filter((u) => (u as any).checked); + } + /** * Check all filtered users (i.e. those rows that are currently visible) * @param select check the filtered users (true) or uncheck the filtered users (false) @@ -101,8 +139,13 @@ export abstract class PeopleTableDataSource extends Tab const filteredUsers = this.filteredData; - const selectCount = - filteredUsers.length > MaxCheckedCount ? MaxCheckedCount : filteredUsers.length; + // When the increased bulk limit feature is enabled, allow checking all users. + // Individual bulk operations will enforce their specific limits. + // When disabled, enforce the legacy limit at check time. + const selectCount = this.isIncreasedBulkLimitEnabled() + ? filteredUsers.length + : Math.min(filteredUsers.length, MaxCheckedCount); + for (let i = 0; i < selectCount; i++) { this.checkUser(filteredUsers[i], select); } @@ -132,4 +175,41 @@ export abstract class PeopleTableDataSource extends Tab this.data = updatedData; } } + + /** + * Limits an array of users and unchecks those beyond the limit. + * Returns the limited array. + * + * @param users The array of users to limit + * @param limit The maximum number of users to keep + * @returns The users array limited to the specified count + */ + limitAndUncheckExcess(users: T[], limit: number): T[] { + if (users.length <= limit) { + return users; + } + + // Uncheck users beyond the limit + users.slice(limit).forEach((user) => this.checkUser(user, false)); + + return users.slice(0, limit); + } + + /** + * Gets checked users with optional limiting based on the IncreaseBulkReinviteLimitForCloud feature flag. + * + * When the feature flag is enabled: Returns checked users in visible order, limited to the specified count. + * When the feature flag is disabled: Returns all checked users without applying any limit. + * + * @param limit The maximum number of users to return (only applied when feature flag is enabled) + * @returns The checked users array + */ + getCheckedUsersWithLimit(limit: number): T[] { + if (this.isIncreasedBulkLimitEnabled()) { + const allUsers = this.getCheckedUsersInVisibleOrder(); + return this.limitAndUncheckExcess(allUsers, limit); + } else { + return this.getCheckedUsers(); + } + } } diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts index 6db8fa5e4d6..67d19b3bcee 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts @@ -11,6 +11,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -59,6 +60,7 @@ export class VaultFilterComponent protected restrictedItemTypesService: RestrictedItemTypesService, protected cipherService: CipherService, protected cipherArchiveService: CipherArchiveService, + premiumUpgradePromptService: PremiumUpgradePromptService, ) { super( vaultFilterService, @@ -72,6 +74,7 @@ export class VaultFilterComponent restrictedItemTypesService, cipherService, cipherArchiveService, + premiumUpgradePromptService, ); } diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index e84f78458d6..59bc03babd4 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -2,12 +2,15 @@ - + + @if (canShowAccessIntelligenceTab(organization)) { + + } + userType = OrganizationUserType; userStatusType = OrganizationUserStatusType; memberTab = MemberDialogTab; - protected dataSource = new MembersTableDataSource(); + protected dataSource: MembersTableDataSource; readonly organization: Signal; status: OrganizationUserStatusType | undefined; @@ -113,6 +119,8 @@ export class MembersComponent extends BaseMembersComponent private policyService: PolicyService, private policyApiService: PolicyApiServiceAbstraction, private organizationMetadataService: OrganizationMetadataServiceAbstraction, + private configService: ConfigService, + private environmentService: EnvironmentService, ) { super( apiService, @@ -126,6 +134,8 @@ export class MembersComponent extends BaseMembersComponent toastService, ); + this.dataSource = new MembersTableDataSource(this.configService, this.environmentService); + const organization$ = this.route.params.pipe( concatMap((params) => this.userId$.pipe( @@ -356,10 +366,9 @@ export class MembersComponent extends BaseMembersComponent return; } - await this.memberDialogManager.openBulkRemoveDialog( - organization, - this.dataSource.getCheckedUsers(), - ); + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + await this.memberDialogManager.openBulkRemoveDialog(organization, users); this.organizationMetadataService.refreshMetadataCache(); await this.load(organization); } @@ -369,10 +378,9 @@ export class MembersComponent extends BaseMembersComponent return; } - await this.memberDialogManager.openBulkDeleteDialog( - organization, - this.dataSource.getCheckedUsers(), - ); + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + await this.memberDialogManager.openBulkDeleteDialog(organization, users); await this.load(organization); } @@ -389,11 +397,9 @@ export class MembersComponent extends BaseMembersComponent return; } - await this.memberDialogManager.openBulkRestoreRevokeDialog( - organization, - this.dataSource.getCheckedUsers(), - isRevoking, - ); + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + await this.memberDialogManager.openBulkRestoreRevokeDialog(organization, users, isRevoking); await this.load(organization); } @@ -402,8 +408,28 @@ export class MembersComponent extends BaseMembersComponent return; } - const users = this.dataSource.getCheckedUsers(); - const filteredUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited); + let users: OrganizationUserView[]; + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + users = this.dataSource.getCheckedUsersInVisibleOrder(); + } else { + users = this.dataSource.getCheckedUsers(); + } + + const allInvitedUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited); + + // Capture the original count BEFORE enforcing the limit + const originalInvitedCount = allInvitedUsers.length; + + // When feature flag is enabled, limit invited users and uncheck the excess + let filteredUsers: OrganizationUserView[]; + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + filteredUsers = this.dataSource.limitAndUncheckExcess( + allInvitedUsers, + CloudBulkReinviteLimit, + ); + } else { + filteredUsers = allInvitedUsers; + } if (filteredUsers.length <= 0) { this.toastService.showToast({ @@ -417,20 +443,44 @@ export class MembersComponent extends BaseMembersComponent try { const result = await this.memberActionsService.bulkReinvite( organization, - filteredUsers.map((user) => user.id), + filteredUsers.map((user) => user.id as UserId), ); if (!result.successful) { throw new Error(); } - // Bulk Status component open - await this.memberDialogManager.openBulkStatusDialog( - users, - filteredUsers, - Promise.resolve(result.successful), - this.i18nService.t("bulkReinviteMessage"), - ); + // When feature flag is enabled, show toast instead of dialog + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + const selectedCount = originalInvitedCount; + const invitedCount = filteredUsers.length; + + if (selectedCount > CloudBulkReinviteLimit) { + const excludedCount = selectedCount - CloudBulkReinviteLimit; + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t( + "bulkReinviteLimitedSuccessToast", + CloudBulkReinviteLimit.toLocaleString(), + selectedCount.toLocaleString(), + excludedCount.toLocaleString(), + ), + }); + } else { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), + }); + } + } else { + // Feature flag disabled - show legacy dialog + await this.memberDialogManager.openBulkStatusDialog( + users, + filteredUsers, + Promise.resolve(result.successful), + this.i18nService.t("bulkReinviteMessage"), + ); + } } catch (e) { this.validationService.showError(e); } @@ -442,15 +492,14 @@ export class MembersComponent extends BaseMembersComponent return; } - await this.memberDialogManager.openBulkConfirmDialog( - organization, - this.dataSource.getCheckedUsers(), - ); + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + await this.memberDialogManager.openBulkConfirmDialog(organization, users); await this.load(organization); } async bulkEnableSM(organization: Organization) { - const users = this.dataSource.getCheckedUsers(); + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users); diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts index e856ab7afd1..80a330b0db1 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts @@ -4,6 +4,7 @@ import { of } from "rxjs"; import { OrganizationUserApiService, OrganizationUserBulkResponse, + OrganizationUserService, } from "@bitwarden/admin-console/common"; import { OrganizationUserType, @@ -22,9 +23,8 @@ import { newGuid } from "@bitwarden/guid"; import { KeyService } from "@bitwarden/key-management"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; -import { OrganizationUserService } from "../organization-user/organization-user.service"; -import { MemberActionsService } from "./member-actions.service"; +import { REQUESTS_PER_BATCH, MemberActionsService } from "./member-actions.service"; describe("MemberActionsService", () => { let service: MemberActionsService; @@ -308,41 +308,308 @@ describe("MemberActionsService", () => { }); describe("bulkReinvite", () => { - const userIds = [newGuid(), newGuid(), newGuid()]; + const userIds = [newGuid() as UserId, newGuid() as UserId, newGuid() as UserId]; - it("should successfully reinvite multiple users", async () => { - const mockResponse = { - data: userIds.map((id) => ({ - id, - error: null, - })), - continuationToken: null, - } as ListResponse; - organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); - - const result = await service.bulkReinvite(mockOrganization, userIds); - - expect(result).toEqual({ - successful: mockResponse, - failed: [], + describe("when feature flag is false", () => { + beforeEach(() => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + }); + + it("should successfully reinvite multiple users", async () => { + const mockResponse = new ListResponse( + { + data: userIds.map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); + + const result = await service.bulkReinvite(mockOrganization, userIds); + + expect(result.failed).toEqual([]); + expect(result.successful).toBeDefined(); + expect(result.successful).toEqual(mockResponse); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( + organizationId, + userIds, + ); + }); + + it("should handle bulk reinvite errors", async () => { + const errorMessage = "Bulk reinvite failed"; + organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( + new Error(errorMessage), + ); + + const result = await service.bulkReinvite(mockOrganization, userIds); + + expect(result.successful).toBeUndefined(); + expect(result.failed).toHaveLength(3); + expect(result.failed[0]).toEqual({ id: userIds[0], error: errorMessage }); }); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( - organizationId, - userIds, - ); }); - it("should handle bulk reinvite errors", async () => { - const errorMessage = "Bulk reinvite failed"; - organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( - new Error(errorMessage), - ); + describe("when feature flag is true (batching behavior)", () => { + beforeEach(() => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + }); + it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => { + const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId); + const mockResponse = new ListResponse( + { + data: userIdsBatch.map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIds); + organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); - expect(result.successful).toBeUndefined(); - expect(result.failed).toHaveLength(3); - expect(result.failed[0]).toEqual({ id: userIds[0], error: errorMessage }); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); + expect(result.failed).toHaveLength(0); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( + 1, + ); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( + organizationId, + userIdsBatch, + ); + }); + + it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => { + const totalUsers = REQUESTS_PER_BATCH + 100; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + const mockResponse2 = new ListResponse( + { + data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(totalUsers); + expect(result.failed).toHaveLength(0); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( + 2, + ); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( + 1, + organizationId, + userIdsBatch.slice(0, REQUESTS_PER_BATCH), + ); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( + 2, + organizationId, + userIdsBatch.slice(REQUESTS_PER_BATCH), + ); + }); + + it("should aggregate results across multiple successful batches", async () => { + const totalUsers = REQUESTS_PER_BATCH + 50; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + const mockResponse2 = new ListResponse( + { + data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(totalUsers); + expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual( + mockResponse1.data, + ); + expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data); + expect(result.failed).toHaveLength(0); + }); + + it("should handle mixed individual errors across multiple batches", async () => { + const totalUsers = REQUESTS_PER_BATCH + 4; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id, index) => ({ + id, + error: index % 10 === 0 ? "Rate limit exceeded" : null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + const mockResponse2 = new ListResponse( + { + data: [ + { id: userIdsBatch[REQUESTS_PER_BATCH], error: null }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 1], error: "Invalid email" }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 2], error: null }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 3], error: "User suspended" }, + ], + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + + // Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch + // Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values + const expectedFailuresInBatch1 = Math.floor((REQUESTS_PER_BATCH - 1) / 10) + 1; + const expectedFailuresInBatch2 = 2; + const expectedTotalFailures = expectedFailuresInBatch1 + expectedFailuresInBatch2; + const expectedSuccesses = totalUsers - expectedTotalFailures; + + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(expectedSuccesses); + expect(result.failed).toHaveLength(expectedTotalFailures); + expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true); + expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true); + expect(result.failed.some((f) => f.error === "User suspended")).toBe(true); + }); + + it("should aggregate all failures when all batches fail", async () => { + const totalUsers = REQUESTS_PER_BATCH + 100; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const errorMessage = "All batches failed"; + + organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( + new Error(errorMessage), + ); + + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + + expect(result.successful).toBeUndefined(); + expect(result.failed).toHaveLength(totalUsers); + expect(result.failed.every((f) => f.error === errorMessage)).toBe(true); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( + 2, + ); + }); + + it("should handle empty data in batch response", async () => { + const totalUsers = REQUESTS_PER_BATCH + 50; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + const mockResponse2 = new ListResponse( + { + data: [], + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); + expect(result.failed).toHaveLength(0); + }); + + it("should process batches sequentially in order", async () => { + const totalUsers = REQUESTS_PER_BATCH * 2; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const callOrder: number[] = []; + + organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation( + async (orgId, ids) => { + const batchIndex = ids.includes(userIdsBatch[0]) ? 1 : 2; + callOrder.push(batchIndex); + + return new ListResponse( + { + data: ids.map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + }, + ); + + await service.bulkReinvite(mockOrganization, userIdsBatch); + + expect(callOrder).toEqual([1, 2]); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( + 2, + ); + }); }); }); @@ -427,14 +694,6 @@ describe("MemberActionsService", () => { expect(result).toBe(false); }); - it("should not allow reset password when organization lacks public and private keys", () => { - const org = { ...mockOrganization, hasPublicAndPrivateKeys: false } as Organization; - - const result = service.allowResetPassword(mockOrgUser, org, resetPasswordEnabled); - - expect(result).toBe(false); - }); - it("should not allow reset password when user is not enrolled in reset password", () => { const user = { ...mockOrgUser, resetPasswordEnrolled: false } as OrganizationUserView; @@ -443,12 +702,6 @@ describe("MemberActionsService", () => { expect(result).toBe(false); }); - it("should not allow reset password when reset password is disabled", () => { - const result = service.allowResetPassword(mockOrgUser, mockOrganization, false); - - expect(result).toBe(false); - }); - it("should not allow reset password when user status is not confirmed", () => { const user = { ...mockOrgUser, diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts index 5e19e26954e..f3774e3cb25 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts @@ -20,9 +20,12 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; +export const REQUESTS_PER_BATCH = 500; + export interface MemberActionResult { success: boolean; error?: string; @@ -162,20 +165,36 @@ export class MemberActionsService { } } - async bulkReinvite(organization: Organization, userIds: string[]): Promise { - try { - const result = await this.organizationUserApiService.postManyOrganizationUserReinvite( - organization.id, - userIds, - ); - return { successful: result, failed: [] }; - } catch (error) { - return { - failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })), - }; + async bulkReinvite(organization: Organization, userIds: UserId[]): Promise { + const increaseBulkReinviteLimitForCloud = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud), + ); + if (increaseBulkReinviteLimitForCloud) { + return await this.vNextBulkReinvite(organization, userIds); + } else { + try { + const result = await this.organizationUserApiService.postManyOrganizationUserReinvite( + organization.id, + userIds, + ); + return { successful: result, failed: [] }; + } catch (error) { + return { + failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })), + }; + } } } + async vNextBulkReinvite( + organization: Organization, + userIds: UserId[], + ): Promise { + return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) => + this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch), + ); + } + allowResetPassword( orgUser: OrganizationUserView, organization: Organization, @@ -207,4 +226,52 @@ export class MemberActionsService { orgUser.status === OrganizationUserStatusType.Confirmed ); } + + /** + * Processes user IDs in sequential batches and aggregates results. + * @param userIds - Array of user IDs to process + * @param batchSize - Number of IDs to process per batch + * @param processBatch - Async function that processes a single batch and returns the result + * @returns Aggregated bulk action result + */ + private async processBatchedOperation( + userIds: UserId[], + batchSize: number, + processBatch: (batch: string[]) => Promise>, + ): Promise { + const allSuccessful: OrganizationUserBulkResponse[] = []; + const allFailed: { id: string; error: string }[] = []; + + for (let i = 0; i < userIds.length; i += batchSize) { + const batch = userIds.slice(i, i + batchSize); + + try { + const result = await processBatch(batch); + + if (result?.data) { + for (const response of result.data) { + if (response.error) { + allFailed.push({ id: response.id, error: response.error }); + } else { + allSuccessful.push(response); + } + } + } + } catch (error) { + allFailed.push( + ...batch.map((id) => ({ id, error: (error as Error).message ?? String(error) })), + ); + } + } + + const successful = + allSuccessful.length > 0 + ? new ListResponse(allSuccessful, OrganizationUserBulkResponse) + : undefined; + + return { + successful, + failed: allFailed, + }; + } } diff --git a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts index 5bea0908b0a..8c1bc4bd080 100644 --- a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts +++ b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts @@ -61,8 +61,11 @@ export class WebLoginComponentService email: string, state: string, codeChallenge: string, + orgSsoIdentifier?: string, ): Promise { - await this.router.navigate(["/sso"]); + await this.router.navigate(["/sso"], { + queryParams: { identifier: orgSsoIdentifier }, + }); return; } diff --git a/apps/web/src/app/auth/recover-two-factor.component.ts b/apps/web/src/app/auth/recover-two-factor.component.ts index 9c033b88a75..20f40b5319a 100644 --- a/apps/web/src/app/auth/recover-two-factor.component.ts +++ b/apps/web/src/app/auth/recover-two-factor.component.ts @@ -108,7 +108,7 @@ export class RecoverTwoFactorComponent implements OnInit { message: this.i18nService.t("twoStepRecoverDisabled"), }); - await this.loginSuccessHandlerService.run(authResult.userId); + await this.loginSuccessHandlerService.run(authResult.userId, this.masterPassword); await this.router.navigate(["/settings/security/two-factor"]); } catch (error: unknown) { diff --git a/apps/web/src/app/auth/verify-recover-delete.component.html b/apps/web/src/app/auth/verify-recover-delete.component.html index 02581b21418..27eda24a118 100644 --- a/apps/web/src/app/auth/verify-recover-delete.component.html +++ b/apps/web/src/app/auth/verify-recover-delete.component.html @@ -1,5 +1,5 @@
    - {{ "deleteAccountWarning" | i18n }} + {{ "deleteAccountWarning" | i18n }}

    {{ email }}

    diff --git a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.html b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.html index 6b168901b2e..e182659acbb 100644 --- a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.html +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.html @@ -21,7 +21,7 @@
    @if (premiumCardData$ | async; as premiumData) { f.value) || [], @@ -172,7 +172,7 @@ export class CloudHostedPremiumVNextComponent { return { tier, price: - tier?.passwordManager.type === "packaged" + tier?.passwordManager.type === "packaged" && tier.passwordManager.annualPrice ? Number((tier.passwordManager.annualPrice / 12).toFixed(2)) : 0, features: tier?.passwordManager.features.map((f) => f.value) || [], diff --git a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html index 63c26bd61f1..33e89f21fc0 100644 --- a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.html @@ -24,7 +24,7 @@
    • - {{ "premiumSignUpStorage" | i18n }} + {{ "premiumSignUpStorageV2" | i18n: `${(providedStorageGb$ | async)} GB` }}
    • @@ -82,7 +82,10 @@ /> {{ "additionalStorageIntervalDesc" - | i18n: "1 GB" : (storagePrice$ | async | currency: "$") : ("year" | i18n) + | i18n + : `${(providedStorageGb$ | async)} GB` + : (storagePrice$ | async | currency: "$") + : ("year" | i18n) }}
    diff --git a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts index fceeeedf170..86a508d2701 100644 --- a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium.component.ts @@ -22,8 +22,8 @@ import { debounceTime } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service"; import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -75,6 +75,7 @@ export class CloudHostedPremiumComponent { return { seat: premiumPlan.passwordManager.annualPrice, storage: premiumPlan.passwordManager.annualPricePerAdditionalStorageGB, + providedStorageGb: premiumPlan.passwordManager.providedStorageGB, }; }), shareReplay({ bufferSize: 1, refCount: true }), @@ -84,6 +85,8 @@ export class CloudHostedPremiumComponent { storagePrice$ = this.premiumPrices$.pipe(map((prices) => prices.storage)); + providedStorageGb$ = this.premiumPrices$.pipe(map((prices) => prices.providedStorageGb)); + protected isLoadingPrices$ = this.premiumPrices$.pipe( map(() => false), startWith(true), @@ -134,7 +137,7 @@ export class CloudHostedPremiumComponent { private accountService: AccountService, private subscriberBillingClient: SubscriberBillingClient, private taxClient: TaxClient, - private subscriptionPricingService: DefaultSubscriptionPricingService, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, ) { this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe( switchMap((account) => diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts index 7f698ae50d1..b28a7b8c4a2 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts @@ -1,4 +1,4 @@ -import { Component, input, output } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { Router } from "@angular/router"; @@ -28,12 +28,11 @@ import { UnifiedUpgradeDialogStep, } from "./unified-upgrade-dialog.component"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-upgrade-account", template: "", standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) class MockUpgradeAccountComponent { readonly dialogTitleMessageOverride = input(null); @@ -42,12 +41,11 @@ class MockUpgradeAccountComponent { closeClicked = output(); } -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-upgrade-payment", template: "", standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) class MockUpgradePaymentComponent { readonly selectedPlanId = input(null); @@ -77,10 +75,56 @@ describe("UnifiedUpgradeDialogComponent", () => { planSelectionStepTitleOverride: null, }; + /** + * Helper function to create and configure a fresh component instance with custom dialog data + */ + async function createComponentWithDialogData( + dialogData: UnifiedUpgradeDialogParams, + waitForStable = false, + ): Promise<{ + fixture: ComponentFixture; + component: UnifiedUpgradeDialogComponent; + }> { + TestBed.resetTestingModule(); + jest.clearAllMocks(); + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: dialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, + ], + }) + .overrideComponent(UnifiedUpgradeDialogComponent, { + remove: { + imports: [UpgradeAccountComponent, UpgradePaymentComponent], + }, + add: { + imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + }, + }) + .compileComponents(); + + const newFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); + const newComponent = newFixture.componentInstance; + newFixture.detectChanges(); + + if (waitForStable) { + await newFixture.whenStable(); + } + + return { fixture: newFixture, component: newComponent }; + } + beforeEach(async () => { // Reset mocks jest.clearAllMocks(); + // Default mock: no premium interest + mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false); + await TestBed.configureTestingModule({ imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], providers: [ @@ -117,49 +161,63 @@ describe("UnifiedUpgradeDialogComponent", () => { }); it("should initialize with custom initial step", async () => { - TestBed.resetTestingModule(); - const customDialogData: UnifiedUpgradeDialogParams = { account: mockAccount, initialStep: UnifiedUpgradeDialogStep.Payment, selectedPlan: PersonalSubscriptionPricingTierIds.Premium, }; - await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], - providers: [ - { provide: DialogRef, useValue: mockDialogRef }, - { provide: DIALOG_DATA, useValue: customDialogData }, - { provide: Router, useValue: mockRouter }, - { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, - ], - }) - .overrideComponent(UnifiedUpgradeDialogComponent, { - remove: { - imports: [UpgradeAccountComponent, UpgradePaymentComponent], - }, - add: { - imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], - }, - }) - .compileComponents(); - - const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); - const customComponent = customFixture.componentInstance; - customFixture.detectChanges(); + const { component: customComponent } = await createComponentWithDialogData(customDialogData); expect(customComponent["step"]()).toBe(UnifiedUpgradeDialogStep.Payment); expect(customComponent["selectedPlan"]()).toBe(PersonalSubscriptionPricingTierIds.Premium); }); + describe("ngOnInit premium interest handling", () => { + it("should check premium interest on initialization", async () => { + // Component already initialized in beforeEach + expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + }); + + it("should set hasPremiumInterest signal and clear premium interest when it exists", async () => { + mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(true); + mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(undefined); + + const { component: customComponent } = await createComponentWithDialogData( + defaultDialogData, + true, + ); + + expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(customComponent["hasPremiumInterest"]()).toBe(true); + }); + + it("should not set hasPremiumInterest signal or clear when premium interest does not exist", async () => { + mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false); + + const { component: customComponent } = await createComponentWithDialogData(defaultDialogData); + + expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled(); + expect(customComponent["hasPremiumInterest"]()).toBe(false); + }); + }); + describe("custom dialog title", () => { it("should use null as default when no override is provided", () => { expect(component["planSelectionStepTitleOverride"]()).toBeNull(); }); it("should use custom title when provided in dialog config", async () => { - TestBed.resetTestingModule(); - const customDialogData: UnifiedUpgradeDialogParams = { account: mockAccount, initialStep: UnifiedUpgradeDialogStep.PlanSelection, @@ -167,28 +225,7 @@ describe("UnifiedUpgradeDialogComponent", () => { planSelectionStepTitleOverride: "upgradeYourPlan", }; - await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], - providers: [ - { provide: DialogRef, useValue: mockDialogRef }, - { provide: DIALOG_DATA, useValue: customDialogData }, - { provide: Router, useValue: mockRouter }, - { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, - ], - }) - .overrideComponent(UnifiedUpgradeDialogComponent, { - remove: { - imports: [UpgradeAccountComponent, UpgradePaymentComponent], - }, - add: { - imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], - }, - }) - .compileComponents(); - - const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); - const customComponent = customFixture.componentInstance; - customFixture.detectChanges(); + const { component: customComponent } = await createComponentWithDialogData(customDialogData); expect(customComponent["planSelectionStepTitleOverride"]()).toBe("upgradeYourPlan"); }); @@ -221,8 +258,6 @@ describe("UnifiedUpgradeDialogComponent", () => { }); it("should be set to true when provided in dialog config", async () => { - TestBed.resetTestingModule(); - const customDialogData: UnifiedUpgradeDialogParams = { account: mockAccount, initialStep: null, @@ -230,108 +265,32 @@ describe("UnifiedUpgradeDialogComponent", () => { hideContinueWithoutUpgradingButton: true, }; - await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], - providers: [ - { provide: DialogRef, useValue: mockDialogRef }, - { provide: DIALOG_DATA, useValue: customDialogData }, - { provide: Router, useValue: mockRouter }, - { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, - ], - }) - .overrideComponent(UnifiedUpgradeDialogComponent, { - remove: { - imports: [UpgradeAccountComponent, UpgradePaymentComponent], - }, - add: { - imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], - }, - }) - .compileComponents(); - - const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); - const customComponent = customFixture.componentInstance; - customFixture.detectChanges(); + const { component: customComponent } = await createComponentWithDialogData(customDialogData); expect(customComponent["hideContinueWithoutUpgradingButton"]()).toBe(true); }); }); - describe("onComplete with premium interest", () => { - it("should check premium interest, clear it, and route to /vault when premium interest exists", async () => { + describe("onComplete", () => { + it("should route to /vault when upgrading to premium with premium interest", async () => { + // Set up component with premium interest mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(true); - mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(); + mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(undefined); mockRouter.navigate.mockResolvedValue(true); - const result: UpgradePaymentResult = { - status: "upgradedToPremium", - organizationId: null, - }; - - await component["onComplete"](result); + const { component: customComponent } = await createComponentWithDialogData( + defaultDialogData, + true, + ); + // Premium interest should be set and cleared during ngOnInit expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith( mockAccount.id, ); expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith( mockAccount.id, ); - expect(mockRouter.navigate).toHaveBeenCalledWith(["/vault"]); - expect(mockDialogRef.close).toHaveBeenCalledWith({ - status: "upgradedToPremium", - organizationId: null, - }); - }); - - it("should not clear premium interest when upgrading to families", async () => { - const result: UpgradePaymentResult = { - status: "upgradedToFamilies", - organizationId: "org-123", - }; - - await component["onComplete"](result); - - expect(mockPremiumInterestStateService.getPremiumInterest).not.toHaveBeenCalled(); - expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled(); - expect(mockDialogRef.close).toHaveBeenCalledWith({ - status: "upgradedToFamilies", - organizationId: "org-123", - }); - }); - - it("should use standard redirect when no premium interest exists", async () => { - TestBed.resetTestingModule(); - - const customDialogData: UnifiedUpgradeDialogParams = { - account: mockAccount, - redirectOnCompletion: true, - }; - - mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false); - mockRouter.navigate.mockResolvedValue(true); - - await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], - providers: [ - { provide: DialogRef, useValue: mockDialogRef }, - { provide: DIALOG_DATA, useValue: customDialogData }, - { provide: Router, useValue: mockRouter }, - { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, - ], - }) - .overrideComponent(UnifiedUpgradeDialogComponent, { - remove: { - imports: [UpgradeAccountComponent, UpgradePaymentComponent], - }, - add: { - imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], - }, - }) - .compileComponents(); - - const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); - const customComponent = customFixture.componentInstance; - customFixture.detectChanges(); + expect(customComponent["hasPremiumInterest"]()).toBe(true); const result: UpgradePaymentResult = { status: "upgradedToPremium", @@ -340,10 +299,55 @@ describe("UnifiedUpgradeDialogComponent", () => { await customComponent["onComplete"](result); - expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith( - mockAccount.id, - ); - expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled(); + // Should route to /vault because hasPremiumInterest signal is true + // No additional service calls should be made in onComplete + expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledTimes(1); // Only from ngOnInit + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledTimes(1); // Only from ngOnInit + expect(mockRouter.navigate).toHaveBeenCalledWith(["/vault"]); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToPremium", + organizationId: null, + }); + }); + + it("should close dialog when upgrading to families (premium interest not relevant)", async () => { + const result: UpgradePaymentResult = { + status: "upgradedToFamilies", + organizationId: "org-123", + }; + + await component["onComplete"](result); + + // Premium interest logic only runs for premium upgrades, not families + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToFamilies", + organizationId: "org-123", + }); + }); + + it("should use standard redirect when upgrading to premium without premium interest", async () => { + const customDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + redirectOnCompletion: true, + }; + + // No premium interest + mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false); + mockRouter.navigate.mockResolvedValue(true); + + const { component: customComponent } = await createComponentWithDialogData(customDialogData); + + // Verify no premium interest was set during ngOnInit + expect(customComponent["hasPremiumInterest"]()).toBe(false); + + const result: UpgradePaymentResult = { + status: "upgradedToPremium", + organizationId: null, + }; + + await customComponent["onComplete"](result); + + // Should use standard redirect because hasPremiumInterest signal is false expect(mockRouter.navigate).toHaveBeenCalledWith([ "/settings/subscription/user-subscription", ]); @@ -354,70 +358,44 @@ describe("UnifiedUpgradeDialogComponent", () => { }); }); - describe("onCloseClicked with premium interest", () => { - it("should clear premium interest when modal is closed", async () => { - mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(); - + describe("onCloseClicked", () => { + it("should close dialog without clearing premium interest (cleared in ngOnInit)", async () => { await component["onCloseClicked"](); - expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith( - mockAccount.id, - ); + // Premium interest should have been cleared only once during ngOnInit, not again here + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledTimes(0); expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); }); }); - describe("previousStep with premium interest", () => { - it("should NOT clear premium interest when navigating between steps", async () => { + describe("previousStep", () => { + it("should go back to plan selection when on payment step", async () => { component["step"].set(UnifiedUpgradeDialogStep.Payment); component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium); await component["previousStep"](); - expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled(); expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection); expect(component["selectedPlan"]()).toBeNull(); + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledTimes(0); }); - it("should clear premium interest when backing out of dialog completely", async () => { - TestBed.resetTestingModule(); - + it("should close dialog when backing out from plan selection step (no premium interest cleared)", async () => { const customDialogData: UnifiedUpgradeDialogParams = { account: mockAccount, initialStep: UnifiedUpgradeDialogStep.Payment, selectedPlan: PersonalSubscriptionPricingTierIds.Premium, }; - mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(); + mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false); - await TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], - providers: [ - { provide: DialogRef, useValue: mockDialogRef }, - { provide: DIALOG_DATA, useValue: customDialogData }, - { provide: Router, useValue: mockRouter }, - { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, - ], - }) - .overrideComponent(UnifiedUpgradeDialogComponent, { - remove: { - imports: [UpgradeAccountComponent, UpgradePaymentComponent], - }, - add: { - imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], - }, - }) - .compileComponents(); - - const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); - const customComponent = customFixture.componentInstance; - customFixture.detectChanges(); + const { component: customComponent } = await createComponentWithDialogData(customDialogData); + // Start at payment step, go back once to reach plan selection, then go back again to close await customComponent["previousStep"](); - expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith( - mockAccount.id, - ); + // Premium interest cleared only in ngOnInit, not in previousStep + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledTimes(0); expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); }); }); diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts index 02d48e8d8f4..222bf77715c 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts @@ -1,6 +1,6 @@ import { DIALOG_DATA } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; -import { Component, Inject, OnInit, signal } from "@angular/core"; +import { ChangeDetectionStrategy, Component, Inject, OnInit, signal } from "@angular/core"; import { Router } from "@angular/router"; import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction"; @@ -63,10 +63,9 @@ export type UnifiedUpgradeDialogParams = { redirectOnCompletion?: boolean; }; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-unified-upgrade-dialog", + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, DialogModule, @@ -87,6 +86,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit { protected readonly account = signal(null); protected readonly planSelectionStepTitleOverride = signal(null); protected readonly hideContinueWithoutUpgradingButton = signal(false); + protected readonly hasPremiumInterest = signal(false); protected readonly PaymentStep = UnifiedUpgradeDialogStep.Payment; protected readonly PlanSelectionStep = UnifiedUpgradeDialogStep.PlanSelection; @@ -98,7 +98,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit { private premiumInterestStateService: PremiumInterestStateService, ) {} - ngOnInit(): void { + async ngOnInit(): Promise { this.account.set(this.params.account); this.step.set(this.params.initialStep ?? UnifiedUpgradeDialogStep.PlanSelection); this.selectedPlan.set(this.params.selectedPlan ?? null); @@ -106,6 +106,19 @@ export class UnifiedUpgradeDialogComponent implements OnInit { this.hideContinueWithoutUpgradingButton.set( this.params.hideContinueWithoutUpgradingButton ?? false, ); + + /* + * Check if the user has premium interest at the point we open the dialog. + * If they do, record it on a component-level signal and clear the user's premium interest. + * This prevents us from having to clear it at every dialog conclusion point. + * */ + const hasPremiumInterest = await this.premiumInterestStateService.getPremiumInterest( + this.params.account.id, + ); + if (hasPremiumInterest) { + this.hasPremiumInterest.set(true); + await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id); + } } protected onPlanSelected(planId: PersonalSubscriptionPricingTierId): void { @@ -113,8 +126,6 @@ export class UnifiedUpgradeDialogComponent implements OnInit { this.nextStep(); } protected async onCloseClicked(): Promise { - // Clear premium interest when user closes/abandons modal - await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id); this.close({ status: UnifiedUpgradeDialogStatus.Closed }); } @@ -135,8 +146,6 @@ export class UnifiedUpgradeDialogComponent implements OnInit { this.step.set(UnifiedUpgradeDialogStep.PlanSelection); this.selectedPlan.set(null); } else { - // Clear premium interest when backing out of dialog completely - await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id); this.close({ status: UnifiedUpgradeDialogStatus.Closed }); } } @@ -161,11 +170,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit { // Check premium interest and route to vault for marketing-initiated premium upgrades if (status === UnifiedUpgradeDialogStatus.UpgradedToPremium) { - const hasPremiumInterest = await this.premiumInterestStateService.getPremiumInterest( - this.params.account.id, - ); - if (hasPremiumInterest) { - await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id); + if (this.hasPremiumInterest()) { await this.router.navigate(["/vault"]); return; // Exit early, don't use redirectOnCompletion } diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts index a4089d7a47a..2ac44ff72db 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts @@ -1,15 +1,15 @@ import { CdkTrapFocus } from "@angular/cdk/a11y"; import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core"; +import { Component, computed, DestroyRef, input, OnInit, output, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { catchError, of } from "rxjs"; +import { SubscriptionPricingCardDetails } from "@bitwarden/angular/billing/types/subscription-pricing-card-details"; import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTierId, PersonalSubscriptionPricingTierIds, - SubscriptionCadence, SubscriptionCadenceIds, } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -32,14 +32,6 @@ export type UpgradeAccountResult = { plan: PersonalSubscriptionPricingTierId | null; }; -type CardDetails = { - title: string; - tagline: string; - price: { amount: number; cadence: SubscriptionCadence }; - button: { text: string; type: ButtonType }; - features: string[]; -}; - // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -60,8 +52,8 @@ export class UpgradeAccountComponent implements OnInit { planSelected = output(); closeClicked = output(); protected readonly loading = signal(true); - protected premiumCardDetails!: CardDetails; - protected familiesCardDetails!: CardDetails; + protected premiumCardDetails!: SubscriptionPricingCardDetails; + protected familiesCardDetails!: SubscriptionPricingCardDetails; protected familiesPlanType = PersonalSubscriptionPricingTierIds.Families; protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium; @@ -122,14 +114,16 @@ export class UpgradeAccountComponent implements OnInit { private createCardDetails( tier: PersonalSubscriptionPricingTier, buttonType: ButtonType, - ): CardDetails { + ): SubscriptionPricingCardDetails { return { title: tier.name, tagline: tier.description, - price: { - amount: tier.passwordManager.annualPrice / 12, - cadence: SubscriptionCadenceIds.Monthly, - }, + price: tier.passwordManager.annualPrice + ? { + amount: tier.passwordManager.annualPrice / 12, + cadence: SubscriptionCadenceIds.Monthly, + } + : undefined, button: { text: this.i18nService.t( this.isFamiliesPlan(tier.id) ? "startFreeFamiliesTrial" : "upgradeToPremium", diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts index 94f1c816168..ae18ab4c629 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -200,7 +200,8 @@ export class UpgradePaymentService { } private getPasswordManagerSeats(planDetails: PlanDetails): number { - return "users" in planDetails.details.passwordManager + return "users" in planDetails.details.passwordManager && + planDetails.details.passwordManager.users ? planDetails.details.passwordManager.users : 0; } diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index 0fd7746fc9d..978bb35c5c7 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -620,7 +620,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } get storageGb() { - return this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0; + return Math.max( + 0, + (this.sub?.maxStorageGb ?? 0) - this.selectedPlan.PasswordManager.baseStorageGb, + ); } passwordManagerSeatTotal(plan: PlanResponse): number { @@ -644,12 +647,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return 0; } - return ( - plan.PasswordManager.additionalStoragePricePerGb * - // TODO: Eslint upgrade. Please resolve this since the null check does nothing - // eslint-disable-next-line no-constant-binary-expression - Math.abs(this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0 || 0) - ); + return plan.PasswordManager.additionalStoragePricePerGb * this.storageGb; } additionalStoragePriceMonthly(selectedPlan: PlanResponse) { diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.html b/apps/web/src/app/billing/organizations/organization-plans.component.html index 6234fc6e6e3..d06604ba29e 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.html +++ b/apps/web/src/app/billing/organizations/organization-plans.component.html @@ -104,7 +104,7 @@
  • {{ "gbEncryptedFileStorage" - | i18n: selectableProduct.PasswordManager.baseStorageGb + "GB" + | i18n: selectableProduct.PasswordManager.baseStorageGb + " GB" }}
  • @@ -239,7 +239,7 @@ {{ "additionalStorageIntervalDesc" | i18n - : "1 GB" + : `${selectedPlan.PasswordManager.baseStorageGb} GB` : (additionalStoragePriceMonthly(selectedPlan) | currency: "$") : ("month" | i18n) }} diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 561a3e03deb..67f6f9b0a6b 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -654,6 +654,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { if (this.singleOrgPolicyBlock) { return; } + + // Validate billing form for paid plans during creation + if (this.createOrganization && this.selectedPlan.type !== PlanType.Free) { + this.billingFormGroup.markAllAsTouched(); + if (this.billingFormGroup.invalid) { + return; + } + } const doSubmit = async (): Promise => { let orgId: string; if (this.createOrganization) { @@ -703,11 +711,18 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { return orgId; }; - this.formPromise = doSubmit(); - const organizationId = await this.formPromise; - this.onSuccess.emit({ organizationId: organizationId }); - // TODO: No one actually listening to this message? - this.messagingService.send("organizationCreated", { organizationId }); + try { + this.formPromise = doSubmit(); + const organizationId = await this.formPromise; + this.onSuccess.emit({ organizationId: organizationId }); + // TODO: No one actually listening to this message? + this.messagingService.send("organizationCreated", { organizationId }); + } catch (error: unknown) { + if (error instanceof Error && error.message === "Payment method validation failed") { + return; + } + throw error; + } }; protected get showTaxIdField(): boolean { @@ -826,6 +841,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { return; } const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + if (!paymentMethod) { + throw new Error("Payment method validation failed"); + } await this.subscriberBillingClient.updatePaymentMethod( { type: "organization", data: this.organization }, paymentMethod, @@ -877,6 +895,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + if (!paymentMethod) { + throw new Error("Payment method validation failed"); + } const billingAddress = getBillingAddressFromForm( this.billingFormGroup.controls.billingAddress, diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index 0666cca2c4b..8b9b98dc390 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -38,12 +38,7 @@ {{ i.amount | currency: "$" }} - + {{ "freeForOneYear" | i18n }} @@ -52,7 +47,7 @@ {{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }} {{ calculateTotalAppliedDiscount(i.quantity * i.amount) | currency: "$" diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index e0c1a12a80f..de5d71cce5e 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -403,11 +403,13 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy } isSecretsManagerTrial(): boolean { - return ( + const isSmStandalone = this.sub?.customerDiscount?.id === "sm-standalone"; + const appliesToProduct = this.sub?.subscription?.items?.some((item) => this.sub?.customerDiscount?.appliesTo?.includes(item.productId), - ) ?? false - ); + ) ?? false; + + return isSmStandalone && appliesToProduct; } closeChangePlan() { diff --git a/apps/web/src/app/billing/services/pricing-summary.service.spec.ts b/apps/web/src/app/billing/services/pricing-summary.service.spec.ts new file mode 100644 index 00000000000..4e15d318a03 --- /dev/null +++ b/apps/web/src/app/billing/services/pricing-summary.service.spec.ts @@ -0,0 +1,232 @@ +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums"; +import { + BillingCustomerDiscount, + OrganizationSubscriptionResponse, +} from "@bitwarden/common/billing/models/response/organization-subscription.response"; +import { + PasswordManagerPlanFeaturesResponse, + PlanResponse, + SecretsManagerPlanFeaturesResponse, +} from "@bitwarden/common/billing/models/response/plan.response"; + +import { PricingSummaryData } from "../shared/pricing-summary/pricing-summary.component"; + +import { PricingSummaryService } from "./pricing-summary.service"; + +describe("PricingSummaryService", () => { + let service: PricingSummaryService; + + beforeEach(() => { + service = new PricingSummaryService(); + }); + + describe("getPricingSummaryData", () => { + let mockPlan: PlanResponse; + let mockSub: OrganizationSubscriptionResponse; + let mockOrganization: Organization; + + beforeEach(() => { + // Create mock plan with password manager features + mockPlan = { + productTier: ProductTierType.Teams, + PasswordManager: { + basePrice: 0, + seatPrice: 48, + baseSeats: 0, + hasAdditionalSeatsOption: true, + hasPremiumAccessOption: false, + premiumAccessOptionPrice: 0, + hasAdditionalStorageOption: true, + additionalStoragePricePerGb: 6, + baseStorageGb: 1, + } as PasswordManagerPlanFeaturesResponse, + SecretsManager: { + basePrice: 0, + seatPrice: 72, + baseSeats: 3, + hasAdditionalSeatsOption: true, + hasAdditionalServiceAccountOption: true, + additionalPricePerServiceAccount: 6, + baseServiceAccount: 50, + } as SecretsManagerPlanFeaturesResponse, + } as PlanResponse; + + // Create mock subscription + mockSub = { + seats: 5, + smSeats: 5, + smServiceAccounts: 5, + maxStorageGb: 2, + customerDiscount: null, + } as OrganizationSubscriptionResponse; + + // Create mock organization + mockOrganization = { + useSecretsManager: false, + } as Organization; + }); + + it("should calculate pricing data correctly for password manager only", async () => { + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + false, + 50, // estimatedTax + ); + + expect(result).toEqual({ + selectedPlanInterval: "month", + passwordManagerSeats: 5, + passwordManagerSeatTotal: 240, // 48 * 5 + secretsManagerSeatTotal: 360, // 72 * 5 + additionalStorageTotal: 6, // 6 * (2 - 1) + additionalStoragePriceMonthly: 6, + additionalServiceAccountTotal: 0, // No additional service accounts (50 base vs 5 used) + totalAppliedDiscount: 0, + secretsManagerSubtotal: 360, // 0 + 360 + 0 + passwordManagerSubtotal: 246, // 0 + 240 + 6 + total: 296, // 246 + 50 (tax) - organization doesn't use secrets manager + organization: mockOrganization, + sub: mockSub, + selectedPlan: mockPlan, + selectedInterval: PlanInterval.Monthly, + discountPercentageFromSub: 0, + discountPercentage: 20, + acceptingSponsorship: false, + additionalServiceAccount: 0, // 50 - 5 = 45, which is > 0, so return 0 + storageGb: 1, + isSecretsManagerTrial: false, + estimatedTax: 50, + }); + }); + + it("should calculate pricing data correctly with secrets manager enabled", async () => { + mockOrganization.useSecretsManager = true; + + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + false, + 50, + ); + + expect(result.total).toBe(656); // passwordManagerSubtotal (246) + secretsManagerSubtotal (360) + tax (50) + }); + + it("should handle secrets manager trial", async () => { + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + true, // isSecretsManagerTrial + 50, + ); + + expect(result.passwordManagerSeatTotal).toBe(0); // Should be 0 during trial + expect(result.discountPercentageFromSub).toBe(0); // Should be 0 during trial + }); + + it("should handle premium access option", async () => { + mockPlan.PasswordManager.hasPremiumAccessOption = true; + mockPlan.PasswordManager.premiumAccessOptionPrice = 25; + + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + false, + 50, + ); + + expect(result.passwordManagerSubtotal).toBe(271); // 0 + 240 + 6 + 25 + }); + + it("should handle customer discount", async () => { + mockSub.customerDiscount = { + id: "discount1", + active: true, + percentOff: 10, + appliesTo: ["subscription"], + } as BillingCustomerDiscount; + + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + false, + 50, + ); + + expect(result.discountPercentageFromSub).toBe(10); + }); + + it("should handle zero storage calculation", async () => { + mockSub.maxStorageGb = 1; // Same as base storage + + const result = await service.getPricingSummaryData( + mockPlan, + mockSub, + mockOrganization, + PlanInterval.Monthly, + false, + 50, + ); + + expect(result.additionalStorageTotal).toBe(0); + expect(result.storageGb).toBe(0); + }); + }); + + describe("getAdditionalServiceAccount", () => { + let mockPlan: PlanResponse; + let mockSub: OrganizationSubscriptionResponse; + + beforeEach(() => { + mockPlan = { + SecretsManager: { + baseServiceAccount: 50, + } as SecretsManagerPlanFeaturesResponse, + } as PlanResponse; + + mockSub = { + smServiceAccounts: 55, + } as OrganizationSubscriptionResponse; + }); + + it("should return additional service accounts when used exceeds base", () => { + const result = service.getAdditionalServiceAccount(mockPlan, mockSub); + expect(result).toBe(5); // Math.abs(50 - 55) = 5 + }); + + it("should return 0 when used is less than or equal to base", () => { + mockSub.smServiceAccounts = 40; + const result = service.getAdditionalServiceAccount(mockPlan, mockSub); + expect(result).toBe(0); + }); + + it("should return 0 when used equals base", () => { + mockSub.smServiceAccounts = 50; + const result = service.getAdditionalServiceAccount(mockPlan, mockSub); + expect(result).toBe(0); + }); + + it("should return 0 when plan is null", () => { + const result = service.getAdditionalServiceAccount(null, mockSub); + expect(result).toBe(0); + }); + + it("should return 0 when plan has no SecretsManager", () => { + mockPlan.SecretsManager = null; + const result = service.getAdditionalServiceAccount(mockPlan, mockSub); + expect(result).toBe(0); + }); + }); +}); diff --git a/apps/web/src/app/billing/services/pricing-summary.service.ts b/apps/web/src/app/billing/services/pricing-summary.service.ts index b3c071a8b88..da2fe0e8dbb 100644 --- a/apps/web/src/app/billing/services/pricing-summary.service.ts +++ b/apps/web/src/app/billing/services/pricing-summary.service.ts @@ -31,9 +31,10 @@ export class PricingSummaryService { const additionalServiceAccount = this.getAdditionalServiceAccount(plan, sub); + const storageGb = Math.max(0, (sub?.maxStorageGb ?? 0) - plan.PasswordManager.baseStorageGb); + const additionalStorageTotal = plan.PasswordManager?.hasAdditionalStorageOption - ? plan.PasswordManager.additionalStoragePricePerGb * - (sub?.maxStorageGb ? sub.maxStorageGb - 1 : 0) + ? plan.PasswordManager.additionalStoragePricePerGb * storageGb : 0; const additionalStoragePriceMonthly = plan.PasswordManager?.additionalStoragePricePerGb || 0; @@ -66,7 +67,6 @@ export class PricingSummaryService { : (sub?.customerDiscount?.percentOff ?? 0); const discountPercentage = 20; const acceptingSponsorship = false; - const storageGb = sub?.maxStorageGb ? sub?.maxStorageGb - 1 : 0; const total = organization?.useSecretsManager ? passwordManagerSubtotal + secretsManagerSubtotal + estimatedTax diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index c0716d99716..fb42e19f863 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -69,6 +69,7 @@ import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-managemen import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { VaultTimeout, VaultTimeoutStringType, @@ -111,6 +112,7 @@ import { } from "@bitwarden/common/platform/theming/theme-state.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { GeneratorServicesModule } from "@bitwarden/generator-components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { KdfConfigService, @@ -124,7 +126,6 @@ import { import { SerializedMemoryStorageService } from "@bitwarden/storage-core"; import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; import { WebOrganizationInviteService } from "@bitwarden/web-vault/app/auth/core/services/organization-invite/web-organization-invite.service"; -import { WebSessionTimeoutSettingsComponentService } from "@bitwarden/web-vault/app/key-management/session-timeout/services/web-session-timeout-settings-component.service"; import { WebVaultPremiumUpgradePromptService } from "@bitwarden/web-vault/app/vault/services/web-premium-upgrade-prompt.service"; import { flagEnabled } from "../../utils/flags"; @@ -149,6 +150,7 @@ import { WebFileDownloadService } from "../core/web-file-download.service"; import { UserKeyRotationService } from "../key-management/key-rotation/user-key-rotation.service"; import { WebLockComponentService } from "../key-management/lock/services/web-lock-component.service"; import { WebProcessReloadService } from "../key-management/services/web-process-reload.service"; +import { WebSessionTimeoutTypeService } from "../key-management/session-timeout/services/web-session-timeout-type.service"; import { WebBiometricsService } from "../key-management/web-biometric.service"; import { WebIpcService } from "../platform/ipc/web-ipc.service"; import { WebEnvironmentService } from "../platform/web-environment.service"; @@ -469,16 +471,21 @@ const safeProviders: SafeProvider[] = [ useClass: WebSystemService, deps: [], }), + safeProvider({ + provide: SessionTimeoutTypeService, + useClass: WebSessionTimeoutTypeService, + deps: [PlatformUtilsService], + }), safeProvider({ provide: SessionTimeoutSettingsComponentService, - useClass: WebSessionTimeoutSettingsComponentService, - deps: [I18nServiceAbstraction, PlatformUtilsService], + useClass: SessionTimeoutSettingsComponentService, + deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyService], }), ]; @NgModule({ declarations: [], - imports: [CommonModule, JslibServicesModule], + imports: [CommonModule, JslibServicesModule, GeneratorServicesModule], // Do not register your dependency here! Add it to the typesafeProviders array using the helper function providers: safeProviders, }) diff --git a/apps/web/src/app/core/event.service.ts b/apps/web/src/app/core/event.service.ts index 05a7f5aa64c..55d5524c2fa 100644 --- a/apps/web/src/app/core/event.service.ts +++ b/apps/web/src/app/core/event.service.ts @@ -372,7 +372,7 @@ export class EventService { msg = humanReadableMsg = this.i18nService.t("enabledSso"); break; case EventType.Organization_DisabledSso: - msg = humanReadableMsg = this.i18nService.t("disabledSso"); + msg = humanReadableMsg = this.i18nService.t("ssoTurnedOff"); break; case EventType.Organization_EnabledKeyConnector: msg = humanReadableMsg = this.i18nService.t("enabledKeyConnector"); diff --git a/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.spec.ts b/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.spec.ts index 525ddd89675..2c2caba7b3a 100644 --- a/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.spec.ts +++ b/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.spec.ts @@ -4,7 +4,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { of } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction"; +import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; diff --git a/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.ts b/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.ts index b730a3597ba..ffeabffa019 100644 --- a/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.ts +++ b/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.ts @@ -5,7 +5,7 @@ import { firstValueFrom, Observable } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction"; +import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; diff --git a/apps/web/src/app/key-management/data-recovery/data-recovery.component.html b/apps/web/src/app/key-management/data-recovery/data-recovery.component.html new file mode 100644 index 00000000000..f357e516115 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/data-recovery.component.html @@ -0,0 +1,75 @@ +

    {{ "dataRecoveryTitle" | i18n }}

    + +
    +

    + {{ "dataRecoveryDescription" | i18n }} +

    + + @if (!diagnosticsCompleted() && !recoveryCompleted()) { + + } + +
    + @for (step of steps(); track $index) { + @if ( + ($index === 0 && hasStarted()) || + ($index > 0 && + (steps()[$index - 1].status === StepStatus.Completed || + steps()[$index - 1].status === StepStatus.Failed)) + ) { +
    +
    + @if (step.status === StepStatus.Failed) { + + } @else if (step.status === StepStatus.Completed) { + + } @else if (step.status === StepStatus.InProgress) { + + } @else { + + } +
    +
    + + {{ step.title }} + +
    +
    + } + } +
    + + @if (diagnosticsCompleted()) { +
    + @if (hasIssues() && !recoveryCompleted()) { + + } + +
    + } +
    diff --git a/apps/web/src/app/key-management/data-recovery/data-recovery.component.spec.ts b/apps/web/src/app/key-management/data-recovery/data-recovery.component.spec.ts new file mode 100644 index 00000000000..1976a8dfe27 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/data-recovery.component.spec.ts @@ -0,0 +1,348 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; +import { DialogService } from "@bitwarden/components"; +import { KeyService, UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; + +import { DataRecoveryComponent, StepStatus } from "./data-recovery.component"; +import { RecoveryStep, RecoveryWorkingData } from "./steps"; + +// Mock SdkLoadService +jest.mock("@bitwarden/common/platform/abstractions/sdk/sdk-load.service", () => ({ + SdkLoadService: { + Ready: Promise.resolve(), + }, +})); + +describe("DataRecoveryComponent", () => { + let component: DataRecoveryComponent; + let fixture: ComponentFixture; + + // Mock Services + let mockI18nService: MockProxy; + let mockApiService: MockProxy; + let mockAccountService: FakeAccountService; + let mockKeyService: MockProxy; + let mockFolderApiService: MockProxy; + let mockCipherEncryptService: MockProxy; + let mockDialogService: MockProxy; + let mockPrivateKeyRegenerationService: MockProxy; + let mockLogService: MockProxy; + let mockCryptoFunctionService: MockProxy; + let mockFileDownloadService: MockProxy; + + const mockUserId = "user-id" as UserId; + + beforeEach(async () => { + mockI18nService = mock(); + mockApiService = mock(); + mockAccountService = mockAccountServiceWith(mockUserId); + mockKeyService = mock(); + mockFolderApiService = mock(); + mockCipherEncryptService = mock(); + mockDialogService = mock(); + mockPrivateKeyRegenerationService = mock(); + mockLogService = mock(); + mockCryptoFunctionService = mock(); + mockFileDownloadService = mock(); + + mockI18nService.t.mockImplementation((key) => `${key}_used-i18n`); + + await TestBed.configureTestingModule({ + imports: [DataRecoveryComponent], + providers: [ + { provide: I18nService, useValue: mockI18nService }, + { provide: ApiService, useValue: mockApiService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: KeyService, useValue: mockKeyService }, + { provide: FolderApiServiceAbstraction, useValue: mockFolderApiService }, + { provide: CipherEncryptionService, useValue: mockCipherEncryptService }, + { provide: DialogService, useValue: mockDialogService }, + { + provide: UserAsymmetricKeysRegenerationService, + useValue: mockPrivateKeyRegenerationService, + }, + { provide: LogService, useValue: mockLogService }, + { provide: CryptoFunctionService, useValue: mockCryptoFunctionService }, + { provide: FileDownloadService, useValue: mockFileDownloadService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DataRecoveryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe("Component Initialization", () => { + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize with default signal values", () => { + expect(component.status()).toBe(StepStatus.NotStarted); + expect(component.hasStarted()).toBe(false); + expect(component.diagnosticsCompleted()).toBe(false); + expect(component.recoveryCompleted()).toBe(false); + expect(component.hasIssues()).toBe(false); + }); + + it("should initialize steps in correct order", () => { + const steps = component.steps(); + expect(steps.length).toBe(5); + expect(steps[0].title).toBe("recoveryStepUserInfoTitle_used-i18n"); + expect(steps[1].title).toBe("recoveryStepSyncTitle_used-i18n"); + expect(steps[2].title).toBe("recoveryStepPrivateKeyTitle_used-i18n"); + expect(steps[3].title).toBe("recoveryStepFoldersTitle_used-i18n"); + expect(steps[4].title).toBe("recoveryStepCipherTitle_used-i18n"); + }); + }); + + describe("runDiagnostics", () => { + let mockSteps: MockProxy[]; + + beforeEach(() => { + // Create mock steps + mockSteps = Array(5) + .fill(null) + .map(() => { + const mockStep = mock(); + mockStep.title = "mockStep"; + mockStep.runDiagnostics.mockResolvedValue(true); + mockStep.canRecover.mockReturnValue(false); + return mockStep; + }); + + // Replace recovery steps with mocks + component["recoverySteps"] = mockSteps; + }); + + it("should not run if already running", async () => { + component["status"].set(StepStatus.InProgress); + await component.runDiagnostics(); + + expect(mockSteps[0].runDiagnostics).not.toHaveBeenCalled(); + }); + + it("should set hasStarted, isRunning and initialize workingData", async () => { + await component.runDiagnostics(); + + expect(component.hasStarted()).toBe(true); + expect(component["workingData"]).toBeDefined(); + expect(component["workingData"]?.userId).toBeNull(); + expect(component["workingData"]?.userKey).toBeNull(); + }); + + it("should run diagnostics for all steps", async () => { + await component.runDiagnostics(); + + mockSteps.forEach((step) => { + expect(step.runDiagnostics).toHaveBeenCalledWith( + component["workingData"], + expect.anything(), + ); + }); + }); + + it("should mark steps as completed when diagnostics succeed", async () => { + await component.runDiagnostics(); + + const steps = component.steps(); + steps.forEach((step) => { + expect(step.status).toBe(StepStatus.Completed); + }); + }); + + it("should mark steps as failed when diagnostics return false", async () => { + mockSteps[2].runDiagnostics.mockResolvedValue(false); + + await component.runDiagnostics(); + + const steps = component.steps(); + expect(steps[2].status).toBe(StepStatus.Failed); + }); + + it("should mark steps as failed when diagnostics throw error", async () => { + mockSteps[3].runDiagnostics.mockRejectedValue(new Error("Test error")); + + await component.runDiagnostics(); + + const steps = component.steps(); + expect(steps[3].status).toBe(StepStatus.Failed); + expect(steps[3].message).toBe("Test error"); + }); + + it("should continue diagnostics even if a step fails", async () => { + mockSteps[1].runDiagnostics.mockRejectedValue(new Error("Step 1 failed")); + mockSteps[3].runDiagnostics.mockResolvedValue(false); + + await component.runDiagnostics(); + + // All steps should have been called despite failures + mockSteps.forEach((step) => { + expect(step.runDiagnostics).toHaveBeenCalled(); + }); + }); + + it("should set hasIssues to true when a step can recover", async () => { + mockSteps[2].runDiagnostics.mockResolvedValue(false); + mockSteps[2].canRecover.mockReturnValue(true); + + await component.runDiagnostics(); + + expect(component.hasIssues()).toBe(true); + }); + + it("should set hasIssues to false when no step can recover", async () => { + mockSteps.forEach((step) => { + step.runDiagnostics.mockResolvedValue(true); + step.canRecover.mockReturnValue(false); + }); + + await component.runDiagnostics(); + + expect(component.hasIssues()).toBe(false); + }); + + it("should set diagnosticsCompleted and status to completed when complete", async () => { + await component.runDiagnostics(); + + expect(component.diagnosticsCompleted()).toBe(true); + expect(component.status()).toBe(StepStatus.Completed); + }); + }); + + describe("runRecovery", () => { + let mockSteps: MockProxy[]; + let mockWorkingData: RecoveryWorkingData; + + beforeEach(() => { + mockWorkingData = { + userId: mockUserId, + userKey: null as any, + isPrivateKeyCorrupt: false, + encryptedPrivateKey: null, + ciphers: [], + folders: [], + }; + + mockSteps = Array(5) + .fill(null) + .map(() => { + const mockStep = mock(); + mockStep.title = "mockStep"; + mockStep.canRecover.mockReturnValue(false); + mockStep.runRecovery.mockResolvedValue(); + mockStep.runDiagnostics.mockResolvedValue(true); + return mockStep; + }); + + component["recoverySteps"] = mockSteps; + component["workingData"] = mockWorkingData; + }); + + it("should not run if already running", async () => { + component["status"].set(StepStatus.InProgress); + await component.runRecovery(); + + expect(mockSteps[0].runRecovery).not.toHaveBeenCalled(); + }); + + it("should not run if workingData is null", async () => { + component["workingData"] = null; + await component.runRecovery(); + + expect(mockSteps[0].runRecovery).not.toHaveBeenCalled(); + }); + + it("should only run recovery for steps that can recover", async () => { + mockSteps[1].canRecover.mockReturnValue(true); + mockSteps[3].canRecover.mockReturnValue(true); + + await component.runRecovery(); + + expect(mockSteps[0].runRecovery).not.toHaveBeenCalled(); + expect(mockSteps[1].runRecovery).toHaveBeenCalled(); + expect(mockSteps[2].runRecovery).not.toHaveBeenCalled(); + expect(mockSteps[3].runRecovery).toHaveBeenCalled(); + expect(mockSteps[4].runRecovery).not.toHaveBeenCalled(); + }); + + it("should set recoveryCompleted and status when successful", async () => { + mockSteps[1].canRecover.mockReturnValue(true); + + await component.runRecovery(); + + expect(component.recoveryCompleted()).toBe(true); + expect(component.status()).toBe(StepStatus.Completed); + }); + + it("should set status to failed if recovery is cancelled", async () => { + mockSteps[1].canRecover.mockReturnValue(true); + mockSteps[1].runRecovery.mockRejectedValue(new Error("User cancelled")); + + await component.runRecovery(); + + expect(component.status()).toBe(StepStatus.Failed); + expect(component.recoveryCompleted()).toBe(false); + }); + + it("should re-run diagnostics after recovery completes", async () => { + mockSteps[1].canRecover.mockReturnValue(true); + + await component.runRecovery(); + + // Diagnostics should be called twice: once for initial diagnostic scan + mockSteps.forEach((step) => { + expect(step.runDiagnostics).toHaveBeenCalledWith(mockWorkingData, expect.anything()); + }); + }); + + it("should update hasIssues after re-running diagnostics", async () => { + // Setup initial state with an issue + mockSteps[1].canRecover.mockReturnValue(true); + mockSteps[1].runDiagnostics.mockResolvedValue(false); + + // After recovery completes, the issue should be fixed + mockSteps[1].runRecovery.mockImplementation(() => { + // Simulate recovery fixing the issue + mockSteps[1].canRecover.mockReturnValue(false); + mockSteps[1].runDiagnostics.mockResolvedValue(true); + return Promise.resolve(); + }); + + await component.runRecovery(); + + // Verify hasIssues is updated after re-running diagnostics + expect(component.hasIssues()).toBe(false); + }); + }); + + describe("saveDiagnosticLogs", () => { + it("should call fileDownloadService with log content", () => { + component.saveDiagnosticLogs(); + + expect(mockFileDownloadService.download).toHaveBeenCalledWith({ + fileName: expect.stringContaining("data-recovery-logs-"), + blobData: expect.any(String), + blobOptions: { type: "text/plain" }, + }); + }); + + it("should include timestamp in filename", () => { + component.saveDiagnosticLogs(); + + const downloadCall = mockFileDownloadService.download.mock.calls[0][0]; + expect(downloadCall.fileName).toMatch(/data-recovery-logs-\d{4}-\d{2}-\d{2}T.*\.txt/); + }); + }); +}); diff --git a/apps/web/src/app/key-management/data-recovery/data-recovery.component.ts b/apps/web/src/app/key-management/data-recovery/data-recovery.component.ts new file mode 100644 index 00000000000..31179dfb062 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/data-recovery.component.ts @@ -0,0 +1,208 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, signal } from "@angular/core"; + +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 { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; +import { ButtonModule, DialogService } from "@bitwarden/components"; +import { KeyService, UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; + +import { SharedModule } from "../../shared"; + +import { LogRecorder } from "./log-recorder"; +import { + SyncStep, + UserInfoStep, + RecoveryStep, + PrivateKeyStep, + RecoveryWorkingData, + FolderStep, + CipherStep, +} from "./steps"; + +export const StepStatus = Object.freeze({ + NotStarted: 0, + InProgress: 1, + Completed: 2, + Failed: 3, +} as const); +export type StepStatus = (typeof StepStatus)[keyof typeof StepStatus]; + +interface StepState { + title: string; + status: StepStatus; + message?: string; +} + +@Component({ + selector: "app-data-recovery", + templateUrl: "data-recovery.component.html", + standalone: true, + imports: [JslibModule, ButtonModule, CommonModule, SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DataRecoveryComponent { + protected readonly StepStatus = StepStatus; + + private i18nService = inject(I18nService); + private apiService = inject(ApiService); + private accountService = inject(AccountService); + private keyService = inject(KeyService); + private folderApiService = inject(FolderApiServiceAbstraction); + private cipherEncryptService = inject(CipherEncryptionService); + private dialogService = inject(DialogService); + private privateKeyRegenerationService = inject(UserAsymmetricKeysRegenerationService); + private cryptoFunctionService = inject(CryptoFunctionService); + private logService = inject(LogService); + private fileDownloadService = inject(FileDownloadService); + + private logger: LogRecorder = new LogRecorder(this.logService); + private recoverySteps: RecoveryStep[] = [ + new UserInfoStep(this.accountService, this.keyService), + new SyncStep(this.apiService), + new PrivateKeyStep( + this.privateKeyRegenerationService, + this.dialogService, + this.cryptoFunctionService, + ), + new FolderStep(this.folderApiService, this.dialogService), + new CipherStep(this.apiService, this.cipherEncryptService, this.dialogService), + ]; + private workingData: RecoveryWorkingData | null = null; + + readonly status = signal(StepStatus.NotStarted); + readonly hasStarted = signal(false); + readonly diagnosticsCompleted = signal(false); + readonly recoveryCompleted = signal(false); + readonly steps = signal( + this.recoverySteps.map((step) => ({ + title: this.i18nService.t(step.title), + status: StepStatus.NotStarted, + })), + ); + readonly hasIssues = signal(false); + + runDiagnostics = async () => { + if (this.status() === StepStatus.InProgress) { + return; + } + + this.hasStarted.set(true); + this.status.set(StepStatus.InProgress); + this.diagnosticsCompleted.set(false); + + this.logger.record("Starting diagnostics..."); + this.workingData = { + userId: null, + userKey: null, + isPrivateKeyCorrupt: false, + encryptedPrivateKey: null, + ciphers: [], + folders: [], + }; + + await this.runDiagnosticsInternal(); + + this.status.set(StepStatus.Completed); + this.diagnosticsCompleted.set(true); + }; + + private async runDiagnosticsInternal() { + if (!this.workingData) { + this.logger.record("No working data available"); + return; + } + + const currentSteps = this.steps(); + let hasAnyFailures = false; + + for (let i = 0; i < this.recoverySteps.length; i++) { + const step = this.recoverySteps[i]; + currentSteps[i].status = StepStatus.InProgress; + this.steps.set([...currentSteps]); + + this.logger.record(`Running diagnostics for step: ${step.title}`); + try { + const success = await step.runDiagnostics(this.workingData, this.logger); + currentSteps[i].status = success ? StepStatus.Completed : StepStatus.Failed; + if (!success) { + hasAnyFailures = true; + } + this.steps.set([...currentSteps]); + this.logger.record(`Diagnostics completed for step: ${step.title}`); + } catch (error) { + currentSteps[i].status = StepStatus.Failed; + currentSteps[i].message = (error as Error).message; + this.steps.set([...currentSteps]); + this.logger.record( + `Diagnostics failed for step: ${step.title} with error: ${(error as Error).message}`, + ); + hasAnyFailures = true; + } + } + + if (hasAnyFailures) { + this.logger.record("Diagnostics completed with errors"); + } else { + this.logger.record("Diagnostics completed successfully"); + } + + // Check if any recovery can be performed + const canRecoverAnyStep = this.recoverySteps.some((step) => step.canRecover(this.workingData!)); + this.hasIssues.set(canRecoverAnyStep); + } + + runRecovery = async () => { + if (this.status() === StepStatus.InProgress || !this.workingData) { + return; + } + + this.status.set(StepStatus.InProgress); + this.recoveryCompleted.set(false); + + this.logger.record("Starting recovery process..."); + + try { + for (let i = 0; i < this.recoverySteps.length; i++) { + const step = this.recoverySteps[i]; + if (step.canRecover(this.workingData)) { + this.logger.record(`Running recovery for step: ${step.title}`); + await step.runRecovery(this.workingData, this.logger); + } + } + + this.logger.record("Recovery process completed"); + this.recoveryCompleted.set(true); + + // Re-run diagnostics after recovery + this.logger.record("Re-running diagnostics to verify recovery..."); + await this.runDiagnosticsInternal(); + + this.status.set(StepStatus.Completed); + } catch (error) { + this.logger.record(`Recovery process cancelled or failed: ${(error as Error).message}`); + this.status.set(StepStatus.Failed); + } + }; + + saveDiagnosticLogs = () => { + const logs = this.logger.getLogs(); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filename = `data-recovery-logs-${timestamp}.txt`; + + const logContent = logs.join("\n"); + this.fileDownloadService.download({ + fileName: filename, + blobData: logContent, + blobOptions: { type: "text/plain" }, + }); + + this.logger.record("Diagnostic logs saved"); + }; +} diff --git a/apps/web/src/app/key-management/data-recovery/log-recorder.ts b/apps/web/src/app/key-management/data-recovery/log-recorder.ts new file mode 100644 index 00000000000..1bca90de48d --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/log-recorder.ts @@ -0,0 +1,19 @@ +import { LogService } from "@bitwarden/logging"; + +/** + * Record logs during the data recovery process. This only keeps them in memory and does not persist them anywhere. + */ +export class LogRecorder { + private logs: string[] = []; + + constructor(private logService: LogService) {} + + record(message: string) { + this.logs.push(message); + this.logService.info(`[DataRecovery] ${message}`); + } + + getLogs(): string[] { + return [...this.logs]; + } +} diff --git a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts new file mode 100644 index 00000000000..34e8cbdc9f3 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts @@ -0,0 +1,81 @@ +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +import { DialogService } from "@bitwarden/components"; + +import { LogRecorder } from "../log-recorder"; + +import { RecoveryStep, RecoveryWorkingData } from "./recovery-step"; + +export class CipherStep implements RecoveryStep { + title = "recoveryStepCipherTitle"; + + private undecryptableCipherIds: string[] = []; + + constructor( + private apiService: ApiService, + private cipherService: CipherEncryptionService, + private dialogService: DialogService, + ) {} + + async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + if (!workingData.userId) { + logger.record("Missing user ID"); + return false; + } + + this.undecryptableCipherIds = []; + for (const cipher of workingData.ciphers) { + try { + await this.cipherService.decrypt(cipher, workingData.userId); + } catch { + logger.record(`Cipher ID ${cipher.id} was undecryptable`); + this.undecryptableCipherIds.push(cipher.id); + } + } + logger.record(`Found ${this.undecryptableCipherIds.length} undecryptable ciphers`); + + return this.undecryptableCipherIds.length == 0; + } + + canRecover(workingData: RecoveryWorkingData): boolean { + return this.undecryptableCipherIds.length > 0; + } + + async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + // Recovery means deleting the broken ciphers. + if (this.undecryptableCipherIds.length === 0) { + logger.record("No undecryptable ciphers to recover"); + return; + } + + logger.record(`Showing confirmation dialog for ${this.undecryptableCipherIds.length} ciphers`); + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "recoveryDeleteCiphersTitle" }, + content: { key: "recoveryDeleteCiphersDesc" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: { key: "cancel" }, + type: "danger", + }); + + if (!confirmed) { + logger.record("User cancelled cipher deletion"); + throw new Error("Cipher recovery cancelled by user"); + } + + logger.record(`Deleting ${this.undecryptableCipherIds.length} ciphers`); + + for (const cipherId of this.undecryptableCipherIds) { + try { + await this.apiService.deleteCipher(cipherId); + logger.record(`Deleted cipher ${cipherId}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.record(`Failed to delete cipher ${cipherId}: ${errorMessage}`); + throw error; + } + } + + logger.record(`Successfully deleted ${this.undecryptableCipherIds.length} ciphers`); + } +} diff --git a/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts b/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts new file mode 100644 index 00000000000..bc0ae31efba --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts @@ -0,0 +1,97 @@ +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; +import { DialogService } from "@bitwarden/components"; +import { PureCrypto } from "@bitwarden/sdk-internal"; + +import { LogRecorder } from "../log-recorder"; + +import { RecoveryStep, RecoveryWorkingData } from "./recovery-step"; + +export class FolderStep implements RecoveryStep { + title = "recoveryStepFoldersTitle"; + + private undecryptableFolderIds: string[] = []; + + constructor( + private folderService: FolderApiServiceAbstraction, + private dialogService: DialogService, + ) {} + + async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + if (!workingData.userKey) { + logger.record("Missing user key"); + return false; + } + + this.undecryptableFolderIds = []; + for (const folder of workingData.folders) { + if (!folder.name?.encryptedString) { + logger.record(`Folder ID ${folder.id} has no name`); + this.undecryptableFolderIds.push(folder.id); + continue; + } + try { + await SdkLoadService.Ready; + PureCrypto.symmetric_decrypt_string( + folder.name.encryptedString, + workingData.userKey.toEncoded(), + ); + } catch { + logger.record(`Folder name for folder ID ${folder.id} was undecryptable`); + this.undecryptableFolderIds.push(folder.id); + } + } + logger.record(`Found ${this.undecryptableFolderIds.length} undecryptable folders`); + + return this.undecryptableFolderIds.length == 0; + } + + canRecover(workingData: RecoveryWorkingData): boolean { + return this.undecryptableFolderIds.length > 0; + } + + async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + // Recovery means deleting the broken folders. + if (this.undecryptableFolderIds.length === 0) { + logger.record("No undecryptable folders to recover"); + return; + } + + if (!workingData.userId) { + logger.record("Missing user ID"); + throw new Error("Missing user ID"); + } + + logger.record(`Showing confirmation dialog for ${this.undecryptableFolderIds.length} folders`); + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "recoveryDeleteFoldersTitle" }, + content: { key: "recoveryDeleteFoldersDesc" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: { key: "cancel" }, + type: "danger", + }); + + if (!confirmed) { + logger.record("User cancelled folder deletion"); + throw new Error("Folder recovery cancelled by user"); + } + + logger.record(`Deleting ${this.undecryptableFolderIds.length} folders`); + + for (const folderId of this.undecryptableFolderIds) { + try { + await this.folderService.delete(folderId, workingData.userId); + logger.record(`Deleted folder ${folderId}`); + } catch (error) { + logger.record(`Failed to delete folder ${folderId}: ${error}`); + } + } + + logger.record(`Successfully deleted ${this.undecryptableFolderIds.length} folders`); + } + + getUndecryptableFolderIds(): string[] { + return this.undecryptableFolderIds; + } +} diff --git a/apps/web/src/app/key-management/data-recovery/steps/index.ts b/apps/web/src/app/key-management/data-recovery/steps/index.ts new file mode 100644 index 00000000000..caf3cdb34ef --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/index.ts @@ -0,0 +1,6 @@ +export * from "./sync-step"; +export * from "./user-info-step"; +export * from "./recovery-step"; +export * from "./private-key-step"; +export * from "./folder-step"; +export * from "./cipher-step"; diff --git a/apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts b/apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts new file mode 100644 index 00000000000..82c20c466b8 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts @@ -0,0 +1,93 @@ +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { EncryptionType } from "@bitwarden/common/platform/enums"; +import { DialogService } from "@bitwarden/components"; +import { UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management"; +import { PureCrypto } from "@bitwarden/sdk-internal"; + +import { LogRecorder } from "../log-recorder"; + +import { RecoveryStep, RecoveryWorkingData } from "./recovery-step"; + +export class PrivateKeyStep implements RecoveryStep { + title = "recoveryStepPrivateKeyTitle"; + + constructor( + private privateKeyRegenerationService: UserAsymmetricKeysRegenerationService, + private dialogService: DialogService, + private cryptoFunctionService: CryptoFunctionService, + ) {} + + async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + if (!workingData.userId || !workingData.userKey) { + logger.record("Missing user ID or user key"); + return false; + } + + // Make sure the private key decrypts properly and is not somehow encrypted by a different user key / broken during key rotation. + const encryptedPrivateKey = workingData.encryptedPrivateKey; + if (!encryptedPrivateKey) { + logger.record("No encrypted private key found"); + return false; + } + logger.record("Private key length: " + encryptedPrivateKey.length); + let privateKey: Uint8Array; + try { + await SdkLoadService.Ready; + privateKey = PureCrypto.unwrap_decapsulation_key( + encryptedPrivateKey, + workingData.userKey.toEncoded(), + ); + } catch { + logger.record("Private key was un-decryptable"); + workingData.isPrivateKeyCorrupt = true; + return false; + } + + // Make sure the contained private key can be parsed and the public key can be derived. If not, then the private key may be corrupt / generated with an incompatible ASN.1 representation / with incompatible padding. + try { + const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey); + logger.record("Public key length: " + publicKey.length); + } catch { + logger.record("Public key could not be derived; private key is corrupt"); + workingData.isPrivateKeyCorrupt = true; + return false; + } + + return true; + } + + canRecover(workingData: RecoveryWorkingData): boolean { + // Only support recovery on V1 users. + return ( + workingData.isPrivateKeyCorrupt && + workingData.userKey !== null && + workingData.userKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64 + ); + } + + async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + // The recovery step is to replace the key pair. Currently, this only works if the user is not using emergency access or is part of an organization. + // This is because this will break emergency access enrollments / organization memberships / provider memberships. + logger.record("Showing confirmation dialog for private key replacement"); + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "recoveryReplacePrivateKeyTitle" }, + content: { key: "recoveryReplacePrivateKeyDesc" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: { key: "cancel" }, + type: "danger", + }); + + if (!confirmed) { + logger.record("User cancelled private key replacement"); + throw new Error("Private key recovery cancelled by user"); + } + + logger.record("Replacing private key"); + await this.privateKeyRegenerationService.regenerateUserPublicKeyEncryptionKeyPair( + workingData.userId!, + ); + logger.record("Private key replaced successfully"); + } +} diff --git a/apps/web/src/app/key-management/data-recovery/steps/recovery-step.ts b/apps/web/src/app/key-management/data-recovery/steps/recovery-step.ts new file mode 100644 index 00000000000..265d7c68284 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/recovery-step.ts @@ -0,0 +1,43 @@ +import { WrappedPrivateKey } from "@bitwarden/common/key-management/types"; +import { UserKey } from "@bitwarden/common/types/key"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { Folder } from "@bitwarden/common/vault/models/domain/folder"; +import { UserId } from "@bitwarden/user-core"; + +import { LogRecorder } from "../log-recorder"; + +/** + * A recovery step performs diagnostics and recovery actions on a specific domain, such as ciphers. + */ +export abstract class RecoveryStep { + /** Title of the recovery step, as an i18n key. */ + abstract title: string; + + /** + * Runs diagnostics on the provided working data. + * Returns true if no issues were found, false otherwise. + */ + abstract runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise; + + /** + * Returns whether recovery can be performed + */ + abstract canRecover(workingData: RecoveryWorkingData): boolean; + + /** + * Performs recovery on the provided working data. + */ + abstract runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise; +} + +/** + * Data used during the recovery process, passed between steps. + */ +export type RecoveryWorkingData = { + userId: UserId | null; + userKey: UserKey | null; + encryptedPrivateKey: WrappedPrivateKey | null; + isPrivateKeyCorrupt: boolean; + ciphers: Cipher[]; + folders: Folder[]; +}; diff --git a/apps/web/src/app/key-management/data-recovery/steps/sync-step.ts b/apps/web/src/app/key-management/data-recovery/steps/sync-step.ts new file mode 100644 index 00000000000..f0adb1e0b46 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/sync-step.ts @@ -0,0 +1,43 @@ +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; +import { FolderData } from "@bitwarden/common/vault/models/data/folder.data"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { Folder } from "@bitwarden/common/vault/models/domain/folder"; + +import { LogRecorder } from "../log-recorder"; + +import { RecoveryStep, RecoveryWorkingData } from "./recovery-step"; + +export class SyncStep implements RecoveryStep { + title = "recoveryStepSyncTitle"; + + constructor(private apiService: ApiService) {} + + async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + // The intent of this step is to fetch the latest data from the server. Diagnostics does not + // ever run on local data but only remote data that is recent. + const response = await this.apiService.getSync(); + + workingData.ciphers = response.ciphers.map((c) => new Cipher(new CipherData(c))); + logger.record(`Fetched ${workingData.ciphers.length} ciphers from server`); + + workingData.folders = response.folders.map((f) => new Folder(new FolderData(f))); + logger.record(`Fetched ${workingData.folders.length} folders from server`); + + workingData.encryptedPrivateKey = + response.profile?.accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey ?? null; + logger.record( + `Fetched encrypted private key of length ${workingData.encryptedPrivateKey?.length ?? 0} from server`, + ); + + return true; + } + + canRecover(workingData: RecoveryWorkingData): boolean { + return false; + } + + runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + return Promise.resolve(); + } +} diff --git a/apps/web/src/app/key-management/data-recovery/steps/user-info-step.ts b/apps/web/src/app/key-management/data-recovery/steps/user-info-step.ts new file mode 100644 index 00000000000..9565b1da73b --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/user-info-step.ts @@ -0,0 +1,49 @@ +import { firstValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { EncryptionType } from "@bitwarden/common/platform/enums"; +import { KeyService } from "@bitwarden/key-management"; + +import { LogRecorder } from "../log-recorder"; + +import { RecoveryStep, RecoveryWorkingData } from "./recovery-step"; + +export class UserInfoStep implements RecoveryStep { + title = "recoveryStepUserInfoTitle"; + + constructor( + private accountService: AccountService, + private keyService: KeyService, + ) {} + + async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + if (!activeAccount) { + logger.record("No active account found"); + return false; + } + const userId = activeAccount.id; + workingData.userId = userId; + logger.record(`User ID: ${userId}`); + + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + if (!userKey) { + logger.record("No user key found"); + return false; + } + workingData.userKey = userKey; + logger.record( + `User encryption type: ${userKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64 ? "V1" : userKey.inner().type === EncryptionType.CoseEncrypt0 ? "Cose" : "Unknown"}`, + ); + + return true; + } + + canRecover(workingData: RecoveryWorkingData): boolean { + return false; + } + + runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + return Promise.resolve(); + } +} diff --git a/apps/web/src/app/key-management/key-connector/remove-password.component.html b/apps/web/src/app/key-management/key-connector/remove-password.component.html deleted file mode 100644 index aae660ce504..00000000000 --- a/apps/web/src/app/key-management/key-connector/remove-password.component.html +++ /dev/null @@ -1,37 +0,0 @@ -
    - - {{ "loading" | i18n }} -
    - -
    -

    {{ "removeMasterPasswordForOrganizationUserKeyConnector" | i18n }}

    -

    {{ "organizationName" | i18n }}:

    -

    {{ organization.name }}

    -

    {{ "keyConnectorDomain" | i18n }}:

    -

    {{ organization.keyConnectorUrl }}

    - - - -
    diff --git a/apps/web/src/app/key-management/key-connector/remove-password.component.ts b/apps/web/src/app/key-management/key-connector/remove-password.component.ts deleted file mode 100644 index d9fea9409f8..00000000000 --- a/apps/web/src/app/key-management/key-connector/remove-password.component.ts +++ /dev/null @@ -1,12 +0,0 @@ -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/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts index b790fb8409a..f4b50b4a772 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts @@ -1096,6 +1096,9 @@ describe("KeyRotationService", () => { mockKeyService.userSigningKey$.mockReturnValue( new BehaviorSubject(TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey), ); + mockKeyService.userSignedPublicKey$.mockReturnValue( + new BehaviorSubject(TEST_VECTOR_SIGNED_PUBLIC_KEY_V2 as SignedPublicKey), + ); mockSecurityStateService.accountSecurityState$.mockReturnValue( new BehaviorSubject(TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState), ); @@ -1140,6 +1143,7 @@ describe("KeyRotationService", () => { publicKeyEncryptionKeyPair: { wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2 as SignedPublicKey, }, signingKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts index 168dbe7442e..b9bd23b12de 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts @@ -10,6 +10,7 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-st import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; import { + SignedPublicKey, SignedSecurityState, UnsignedPublicKey, WrappedPrivateKey, @@ -308,9 +309,11 @@ export class UserKeyRotationService { userId: asUuid(userId), kdfParams: kdfConfig.toSdkConfig(), email: email, - privateKey: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey, - signingKey: undefined, - securityState: undefined, + accountCryptographicState: { + V1: { + private_key: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey, + }, + }, method: { decryptedKey: { decrypted_user_key: cryptographicStateParameters.userKey.toBase64() }, }, @@ -334,9 +337,15 @@ export class UserKeyRotationService { userId: asUuid(userId), kdfParams: kdfConfig.toSdkConfig(), email: email, - privateKey: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey, - signingKey: cryptographicStateParameters.signingKey, - securityState: cryptographicStateParameters.securityState, + accountCryptographicState: { + V2: { + private_key: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey, + signing_key: cryptographicStateParameters.signingKey, + security_state: cryptographicStateParameters.securityState, + signed_public_key: + cryptographicStateParameters.publicKeyEncryptionKeyPair.signedPublicKey, + }, + }, method: { decryptedKey: { decrypted_user_key: cryptographicStateParameters.userKey.toBase64() }, }, @@ -632,6 +641,10 @@ export class UserKeyRotationService { this.securityStateService.accountSecurityState$(user.id), "User security state", ); + const signedPublicKey = await this.firstValueFromOrThrow( + this.keyService.userSignedPublicKey$(user.id), + "User signed public key", + ); return { masterKeyKdfConfig, @@ -642,6 +655,7 @@ export class UserKeyRotationService { publicKeyEncryptionKeyPair: { wrappedPrivateKey: currentUserKeyWrappedPrivateKey, publicKey: publicKey, + signedPublicKey: signedPublicKey!, }, signingKey: signingKey!, securityState: securityState!, @@ -679,6 +693,7 @@ export type V2CryptographicStateParameters = { publicKeyEncryptionKeyPair: { wrappedPrivateKey: WrappedPrivateKey; publicKey: UnsignedPublicKey; + signedPublicKey: SignedPublicKey; }; signingKey: WrappedSigningKey; securityState: SignedSecurityState; diff --git a/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-settings-component.service.ts b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-settings-component.service.ts deleted file mode 100644 index 61836c98252..00000000000 --- a/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-settings-component.service.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { defer, Observable, of } from "rxjs"; - -import { - VaultTimeout, - VaultTimeoutOption, - VaultTimeoutStringType, -} from "@bitwarden/common/key-management/vault-timeout"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui"; - -export class WebSessionTimeoutSettingsComponentService - implements SessionTimeoutSettingsComponentService -{ - availableTimeoutOptions$: Observable = defer(() => { - const options: VaultTimeoutOption[] = [ - { name: this.i18nService.t("oneMinute"), value: 1 }, - { name: this.i18nService.t("fiveMinutes"), value: 5 }, - { name: this.i18nService.t("fifteenMinutes"), value: 15 }, - { name: this.i18nService.t("thirtyMinutes"), value: 30 }, - { name: this.i18nService.t("oneHour"), value: 60 }, - { name: this.i18nService.t("fourHours"), value: 240 }, - { name: this.i18nService.t("onRefresh"), value: VaultTimeoutStringType.OnRestart }, - ]; - - if (this.platformUtilsService.isDev()) { - options.push({ name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never }); - } - - return of(options); - }); - - constructor( - private readonly i18nService: I18nService, - private readonly platformUtilsService: PlatformUtilsService, - ) {} - - onTimeoutSave(_: VaultTimeout): void {} -} diff --git a/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.spec.ts b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.spec.ts new file mode 100644 index 00000000000..40eb3e77d43 --- /dev/null +++ b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.spec.ts @@ -0,0 +1,115 @@ +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 { WebSessionTimeoutTypeService } from "./web-session-timeout-type.service"; + +describe("WebSessionTimeoutTypeService", () => { + let service: WebSessionTimeoutTypeService; + let mockPlatformUtilsService: jest.Mocked; + + beforeEach(() => { + mockPlatformUtilsService = mock(); + service = new WebSessionTimeoutTypeService(mockPlatformUtilsService); + }); + + describe("isAvailable", () => { + it("should return false for Immediately", async () => { + const result = await service.isAvailable(VaultTimeoutNumberType.Immediately); + + expect(result).toBe(false); + }); + + it.each([VaultTimeoutStringType.OnRestart, 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); + }, + ); + + it.each([ + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnLocked, + ])("should return false for unavailable timeout type: %s", async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(false); + }); + + describe("Never availability", () => { + it("should return true when in dev mode", async () => { + mockPlatformUtilsService.isDev.mockReturnValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.Never); + + expect(result).toBe(true); + expect(mockPlatformUtilsService.isDev).toHaveBeenCalled(); + }); + + it("should return false when not in dev mode", async () => { + mockPlatformUtilsService.isDev.mockReturnValue(false); + + const result = await service.isAvailable(VaultTimeoutStringType.Never); + + expect(result).toBe(false); + expect(mockPlatformUtilsService.isDev).toHaveBeenCalled(); + }); + }); + }); + + describe("getOrPromoteToAvailable", () => { + it.each([ + VaultTimeoutNumberType.OnMinute, + VaultTimeoutNumberType.EightHours, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.Never, + 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("should return OnMinute when Immediately is not available", async () => { + jest.spyOn(service, "isAvailable").mockResolvedValue(false); + + const result = await service.getOrPromoteToAvailable(VaultTimeoutNumberType.Immediately); + + expect(result).toBe(VaultTimeoutNumberType.OnMinute); + expect(service.isAvailable).toHaveBeenCalledWith(VaultTimeoutNumberType.Immediately); + }); + + it.each([ + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.Never, + ])("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/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.ts b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.ts new file mode 100644 index 00000000000..458befc29a7 --- /dev/null +++ b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.ts @@ -0,0 +1,44 @@ +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 WebSessionTimeoutTypeService implements SessionTimeoutTypeService { + constructor(private readonly platformUtilsService: PlatformUtilsService) {} + + async isAvailable(type: VaultTimeout): Promise { + switch (type) { + case VaultTimeoutNumberType.Immediately: + return false; + case VaultTimeoutStringType.OnRestart: + case VaultTimeoutStringType.Custom: + return true; + case VaultTimeoutStringType.Never: + return this.platformUtilsService.isDev(); + default: + if (isVaultTimeoutTypeNumeric(type)) { + return true; + } + break; + } + + return false; + } + + async getOrPromoteToAvailable(type: VaultTimeout): Promise { + const available = await this.isAvailable(type); + if (!available) { + switch (type) { + case VaultTimeoutNumberType.Immediately: + return VaultTimeoutNumberType.OnMinute; + default: + return VaultTimeoutStringType.OnRestart; + } + } + return type; + } +} diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index b40b9143991..e3c9da635f9 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -51,7 +51,7 @@ import { import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components"; -import { LockComponent } from "@bitwarden/key-management-ui"; +import { LockComponent, RemovePasswordComponent } from "@bitwarden/key-management-ui"; import { premiumInterestRedirectGuard } from "@bitwarden/web-vault/app/vault/guards/premium-interest-redirect/premium-interest-redirect.guard"; import { flagEnabled, Flags } from "../utils/flags"; @@ -78,8 +78,8 @@ import { freeTrialTextResolver } from "./billing/trial-initiation/complete-trial import { EnvironmentSelectorComponent } from "./components/environment-selector/environment-selector.component"; import { RouteDataProperties } from "./core"; import { ReportsModule } from "./dirt/reports"; +import { DataRecoveryComponent } from "./key-management/data-recovery/data-recovery.component"; import { ConfirmKeyConnectorDomainComponent } from "./key-management/key-connector/confirm-key-connector-domain.component"; -import { RemovePasswordComponent } from "./key-management/key-connector/remove-password.component"; import { FrontendLayoutComponent } from "./layouts/frontend-layout.component"; import { UserLayoutComponent } from "./layouts/user-layout.component"; import { RequestSMAccessComponent } from "./secrets-manager/secrets-manager-landing/request-sm-access.component"; @@ -544,9 +544,9 @@ const routes: Routes = [ canActivate: [authGuard], data: { pageTitle: { - key: "removeMasterPassword", + key: "verifyYourOrganization", }, - titleId: "removeMasterPassword", + titleId: "verifyYourOrganization", pageIcon: LockIcon, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, @@ -556,9 +556,9 @@ const routes: Routes = [ canActivate: [], data: { pageTitle: { - key: "confirmKeyConnectorDomain", + key: "verifyYourOrganization", }, - titleId: "confirmKeyConnectorDomain", + titleId: "verifyYourOrganization", pageIcon: DomainIcon, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, @@ -696,6 +696,12 @@ const routes: Routes = [ path: "security", loadChildren: () => SecurityRoutingModule, }, + { + path: "data-recovery", + component: DataRecoveryComponent, + canActivate: [canAccessFeature(FeatureFlag.DataRecoveryTool)], + data: { titleId: "dataRecovery" } satisfies RouteDataProperties, + }, { path: "domain-rules", component: DomainRulesComponent, diff --git a/apps/web/src/app/settings/preferences.component.html b/apps/web/src/app/settings/preferences.component.html index a2e90dd5889..cdcb8973602 100644 --- a/apps/web/src/app/settings/preferences.component.html +++ b/apps/web/src/app/settings/preferences.component.html @@ -17,12 +17,12 @@ {{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }}
    - - + {{ send.file.fileName }}

    +

    {{ send.file.fileName }}

-
+
{{ "name" | i18n }} - {{ "deletionDate" | i18n }} + + {{ "deletionDate" | i18n }} + {{ "options" | i18n }} @@ -148,8 +150,14 @@
- - {{ s.deletionDate | date: "medium" }} + + + {{ s.deletionDate | date: "medium" }} + @if (showArchiveButton) { - + @if (userCanArchive) { + + } + @if (!userCanArchive) { + + } } @if (showUnArchiveButton) { diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts index d5f7b54f37a..9378ee54e51 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts @@ -72,6 +72,7 @@ describe("VaultCipherRowComponent", () => { fixture = TestBed.createComponent(VaultCipherRowComponent); component = fixture.componentInstance; + fixture.componentRef.setInput("archiveEnabled", false); overlayContainer = TestBed.inject(OverlayContainer); }); diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index 4ea062db8d1..92c49ac218a 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -8,6 +8,7 @@ import { OnInit, Output, ViewChild, + input, } from "@angular/core"; import { CollectionView } from "@bitwarden/admin-console/common"; @@ -101,8 +102,10 @@ export class VaultCipherRowComponent implements OnInit // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input() userCanArchive: boolean; + /** Archive feature is enabled */ + readonly archiveEnabled = input.required(); /** - * Enforge Org Data Ownership Policy Status + * Enforce Org Data Ownership Policy Status */ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @@ -142,16 +145,21 @@ export class VaultCipherRowComponent implements OnInit } protected get showArchiveButton() { + if (!this.archiveEnabled()) { + return false; + } + return ( - this.userCanArchive && - !CipherViewLikeUtils.isArchived(this.cipher) && - !CipherViewLikeUtils.isDeleted(this.cipher) && - !this.cipher.organizationId + !CipherViewLikeUtils.isArchived(this.cipher) && !CipherViewLikeUtils.isDeleted(this.cipher) ); } // If item is archived always show unarchive button, even if user is not premium protected get showUnArchiveButton() { + if (!this.archiveEnabled()) { + return false; + } + return CipherViewLikeUtils.isArchived(this.cipher); } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html index cb2af9a64e5..70c44e80a39 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -179,6 +179,7 @@ (onEvent)="event($event)" [userCanArchive]="userCanArchive" [enforceOrgDataOwnershipPolicy]="enforceOrgDataOwnershipPolicy" + [archiveEnabled]="archiveFeatureEnabled$ | async" > diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts index 902fc2eb5a2..1eccb4c49ce 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts @@ -4,6 +4,7 @@ import { of } from "rxjs"; import { CollectionView } from "@bitwarden/admin-console/common"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; @@ -54,6 +55,12 @@ describe("VaultItemsComponent", () => { t: (key: string) => key, }, }, + { + provide: CipherArchiveService, + useValue: { + hasArchiveFlagEnabled$: of(true), + }, + }, ], }); diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 3ab643927f1..a935314eb3a 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -7,6 +7,7 @@ import { Observable, combineLatest, map, of, startWith, switchMap } from "rxjs"; import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { RestrictedCipherType, @@ -145,9 +146,12 @@ export class VaultItemsComponent { protected disableMenu$: Observable; private restrictedTypes: RestrictedCipherType[] = []; + protected archiveFeatureEnabled$ = this.cipherArchiveService.hasArchiveFlagEnabled$; + constructor( protected cipherAuthorizationService: CipherAuthorizationService, protected restrictedItemTypesService: RestrictedItemTypesService, + protected cipherArchiveService: CipherArchiveService, ) { this.canDeleteSelected$ = this.selection.changed.pipe( startWith(null), diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.module.ts b/apps/web/src/app/vault/components/vault-items/vault-items.module.ts index a3a92559878..a7c264114b9 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.module.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.module.ts @@ -3,6 +3,7 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; import { RouterModule } from "@angular/router"; +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { ScrollLayoutDirective, TableModule } from "@bitwarden/components"; import { CopyCipherFieldDirective } from "@bitwarden/vault"; @@ -29,6 +30,7 @@ import { VaultItemsComponent } from "./vault-items.component"; PipesModule, CopyCipherFieldDirective, ScrollLayoutDirective, + PremiumBadgeComponent, ], declarations: [VaultItemsComponent, VaultCipherRowComponent, VaultCollectionRowComponent], exports: [VaultItemsComponent], diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index 043ae900b40..d973fbcbbc7 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -30,6 +30,7 @@ import { import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -143,6 +144,12 @@ export default { isCipherRestricted: () => false, // No restrictions for this story }, }, + { + provide: CipherArchiveService, + useValue: { + hasArchiveFlagEnabled$: of(true), + }, + }, ], }), applicationConfig({ diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index 82f71e50ce6..234b227c76f 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -19,8 +19,10 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UserId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; @@ -167,6 +169,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { protected restrictedItemTypesService: RestrictedItemTypesService, protected cipherService: CipherService, protected cipherArchiveService: CipherArchiveService, + private premiumUpgradePromptService: PremiumUpgradePromptService, ) {} async ngOnInit(): Promise { @@ -249,14 +252,20 @@ export class VaultFilterComponent implements OnInit, OnDestroy { }; async buildAllFilters(): Promise { - const hasArchiveFlag = await firstValueFrom(this.cipherArchiveService.hasArchiveFlagEnabled$()); + const [userId, showArchive] = await firstValueFrom( + combineLatest([ + this.accountService.activeAccount$.pipe(getUserId), + this.cipherArchiveService.hasArchiveFlagEnabled$, + ]), + ); + const builderFilter = {} as VaultFilterList; builderFilter.organizationFilter = await this.addOrganizationFilter(); builderFilter.typeFilter = await this.addTypeFilter(); builderFilter.folderFilter = await this.addFolderFilter(); builderFilter.collectionFilter = await this.addCollectionFilter(); - if (hasArchiveFlag) { - builderFilter.archiveFilter = await this.addArchiveFilter(); + if (showArchive) { + builderFilter.archiveFilter = await this.addArchiveFilter(userId); } builderFilter.trashFilter = await this.addTrashFilter(); return builderFilter; @@ -416,7 +425,18 @@ export class VaultFilterComponent implements OnInit, OnDestroy { return trashFilterSection; } - protected async addArchiveFilter(): Promise { + protected async addArchiveFilter(userId: UserId): Promise { + const [hasArchivedCiphers, userHasPremium] = await firstValueFrom( + combineLatest([ + this.cipherArchiveService + .archivedCiphers$(userId) + .pipe(map((archivedCiphers) => archivedCiphers.length > 0)), + this.cipherArchiveService.userHasPremium$(userId), + ]), + ); + + const promptForPremiumOnFilter = !userHasPremium && !hasArchivedCiphers; + const archiveFilterSection: VaultFilterSection = { data$: this.vaultFilterService.buildTypeTree( { @@ -439,6 +459,12 @@ export class VaultFilterComponent implements OnInit, OnDestroy { isSelectable: true, }, action: this.applyTypeFilter as (filterNode: TreeNode) => Promise, + premiumOptions: { + showBadgeForNonPremium: true, + blockFilterAction: promptForPremiumOnFilter + ? async () => await this.premiumUpgradePromptService.promptForPremium() + : undefined, + }, }; return archiveFilterSection; } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html index f7078d2a67a..66f14dcf2f6 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html @@ -105,6 +105,9 @@ *ngComponentOutlet="optionsInfo.component; injector: createInjector(f.node)" > + + +