-
-
-
-
-
-
-
-
-
-
collections: CollectionView[] | null = null;
config: CipherFormConfig | null = null;
readonly userHasPremium = signal(false);
+ protected itemTypesIcon = ItemTypes;
/** Tracks the disabled status of the edit cipher form */
protected formDisabled: boolean = false;
@@ -221,6 +225,12 @@ export class VaultV2Component
: this.i18nService.t("save");
});
+ protected hasArchivedCiphers$ = this.userId$.pipe(
+ switchMap((userId) =>
+ this.cipherArchiveService.archivedCiphers$(userId).pipe(map((ciphers) => ciphers.length > 0)),
+ ),
+ );
+
private componentIsDestroyed$ = new Subject();
private allOrganizations: Organization[] = [];
private allCollections: CollectionView[] = [];
From 21eb376b4146878d0d2275024ddab678537e0ffd Mon Sep 17 00:00:00 2001
From: Shane Melton
Date: Wed, 21 Jan 2026 15:32:58 -0800
Subject: [PATCH 08/24] [PM-30906] Auto confirm nudge service fix and better
nudge documentation (#18419)
* [PM-30906] Refactor AutoConfirmNudgeService to be Browser specific and add additional documentation detailing when this is necessary
* [PM-30906] Add README.md for custom nudge services
---
.../src/popup/services/services.module.ts | 17 +-
libs/angular/src/vault/index.ts | 6 +-
.../services/custom-nudges-services/README.md | 204 ++++++++++++++++++
.../auto-confirm-nudge.service.ts | 15 +-
.../new-account-nudge.service.ts | 12 +-
.../services/default-single-nudge.service.ts | 8 +-
.../vault/services/nudge-injection-tokens.ts | 19 ++
.../src/vault/services/nudges.service.ts | 16 +-
8 files changed, 280 insertions(+), 17 deletions(-)
create mode 100644 libs/angular/src/vault/services/custom-nudges-services/README.md
diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts
index 06a021085ea..7b207f0fac1 100644
--- a/apps/browser/src/popup/services/services.module.ts
+++ b/apps/browser/src/popup/services/services.module.ts
@@ -27,8 +27,12 @@ import {
WINDOW,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
-import { AUTOFILL_NUDGE_SERVICE } from "@bitwarden/angular/vault";
-import { SingleNudgeService } from "@bitwarden/angular/vault/services/default-single-nudge.service";
+import {
+ AUTOFILL_NUDGE_SERVICE,
+ AUTO_CONFIRM_NUDGE_SERVICE,
+ AutoConfirmNudgeService,
+} from "@bitwarden/angular/vault";
+import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import {
LoginComponentService,
TwoFactorAuthComponentService,
@@ -786,9 +790,14 @@ const safeProviders: SafeProvider[] = [
],
}),
safeProvider({
- provide: AUTOFILL_NUDGE_SERVICE as SafeInjectionToken,
+ provide: AUTOFILL_NUDGE_SERVICE as SafeInjectionToken,
useClass: BrowserAutofillNudgeService,
- deps: [],
+ deps: [StateProvider, VaultProfileService, LogService],
+ }),
+ safeProvider({
+ provide: AUTO_CONFIRM_NUDGE_SERVICE as SafeInjectionToken,
+ useClass: AutoConfirmNudgeService,
+ deps: [StateProvider, AutomaticUserConfirmationService],
}),
];
diff --git a/libs/angular/src/vault/index.ts b/libs/angular/src/vault/index.ts
index b9131338a45..a89be7bc2c4 100644
--- a/libs/angular/src/vault/index.ts
+++ b/libs/angular/src/vault/index.ts
@@ -1,4 +1,8 @@
// Note: Nudge related code is exported from `libs/angular` because it is consumed by multiple
// `libs/*` packages. Exporting from the `libs/vault` package creates circular dependencies.
export { NudgesService, NudgeStatus, NudgeType } from "./services/nudges.service";
-export { AUTOFILL_NUDGE_SERVICE } from "./services/nudge-injection-tokens";
+export {
+ AUTOFILL_NUDGE_SERVICE,
+ AUTO_CONFIRM_NUDGE_SERVICE,
+} from "./services/nudge-injection-tokens";
+export { AutoConfirmNudgeService } from "./services/custom-nudges-services";
diff --git a/libs/angular/src/vault/services/custom-nudges-services/README.md b/libs/angular/src/vault/services/custom-nudges-services/README.md
new file mode 100644
index 00000000000..f0759979de9
--- /dev/null
+++ b/libs/angular/src/vault/services/custom-nudges-services/README.md
@@ -0,0 +1,204 @@
+# Custom Nudge Services
+
+This folder contains custom implementations of `SingleNudgeService` that provide specialized logic for determining when nudges should be shown or dismissed.
+
+## Architecture Overview
+
+### Core Components
+
+- **`NudgesService`** (`../nudges.service.ts`) - The main service that components use to check nudge status and dismiss nudges
+- **`SingleNudgeService`** - Interface that all nudge services implement
+- **`DefaultSingleNudgeService`** - Base implementation that stores dismissed state in user state
+- **Custom nudge services** - Specialized implementations with additional logic
+
+### How It Works
+
+1. Components call `NudgesService.showNudgeSpotlight$()` or `showNudgeBadge$()` with a `NudgeType`
+2. `NudgesService` routes to the appropriate custom nudge service (or falls back to `DefaultSingleNudgeService`)
+3. The custom service returns a `NudgeStatus` indicating if the badge/spotlight should be shown
+4. Custom services can combine the persisted dismissed state with dynamic conditions (e.g., account age, vault contents)
+
+### NudgeStatus
+
+```typescript
+type NudgeStatus = {
+ hasBadgeDismissed: boolean; // True if the badge indicator should be hidden
+ hasSpotlightDismissed: boolean; // True if the spotlight/callout should be hidden
+};
+```
+
+## Service Categories
+
+### Universal Services
+
+These services work on **all clients** (browser, web, desktop) and use `@Injectable({ providedIn: "root" })`.
+
+| Service | Purpose |
+| --------------------------------- | ---------------------------------------------------------------------- |
+| `NewAccountNudgeService` | Auto-dismisses after account is 30 days old |
+| `NewItemNudgeService` | Checks cipher counts for "add first item" nudges |
+| `HasItemsNudgeService` | Checks if vault has items |
+| `EmptyVaultNudgeService` | Checks empty vault state |
+| `AccountSecurityNudgeService` | Checks security settings (PIN, biometrics) |
+| `VaultSettingsImportNudgeService` | Checks import status |
+| `NoOpNudgeService` | Always returns dismissed (used as fallback for client specific nudges) |
+
+### Client-Specific Services
+
+These services require **platform-specific features** and must be explicitly registered in each client that supports them.
+
+| Service | Clients | Requires |
+| ----------------------------- | ------------ | -------------------------------------- |
+| `AutoConfirmNudgeService` | Browser only | `AutomaticUserConfirmationService` |
+| `BrowserAutofillNudgeService` | Browser only | `BrowserApi` (lives in `apps/browser`) |
+
+## Adding a New Nudge Service
+
+### Step 1: Determine if Universal or Client-Specific
+
+**Universal** - If your service only depends on:
+
+- `StateProvider`
+- Services available in all clients (e.g., `CipherService`, `OrganizationService`)
+
+**Client-Specific** - If your service depends on:
+
+- Browser APIs (`BrowserApi`, autofill services)
+- Services only available in certain clients
+- Platform-specific features
+
+### Step 2: Create the Service
+
+#### For Universal Services
+
+```typescript
+// my-nudge.service.ts
+import { Injectable } from "@angular/core";
+import { combineLatest, map, Observable } from "rxjs";
+
+import { StateProvider } from "@bitwarden/common/platform/state";
+import { UserId } from "@bitwarden/common/types/guid";
+
+import { DefaultSingleNudgeService } from "../default-single-nudge.service";
+import { NudgeStatus, NudgeType } from "../nudges.service";
+
+@Injectable({ providedIn: "root" })
+export class MyNudgeService extends DefaultSingleNudgeService {
+ constructor(
+ stateProvider: StateProvider,
+ private myDependency: MyDependency, // Must be available in all clients
+ ) {
+ super(stateProvider);
+ }
+
+ nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable {
+ return combineLatest([
+ this.getNudgeStatus$(nudgeType, userId), // Gets persisted dismissed state
+ this.myDependency.someData$,
+ ]).pipe(
+ map(([persistedStatus, data]) => {
+ // Return dismissed if user already dismissed OR your condition is met
+ const autoDismiss = /* your logic */;
+ return {
+ hasBadgeDismissed: persistedStatus.hasBadgeDismissed || autoDismiss,
+ hasSpotlightDismissed: persistedStatus.hasSpotlightDismissed || autoDismiss,
+ };
+ }),
+ );
+ }
+}
+```
+
+#### For Client-Specific Services
+
+```typescript
+// my-client-specific-nudge.service.ts
+import { Injectable } from "@angular/core";
+import { combineLatest, map, Observable } from "rxjs";
+
+import { StateProvider } from "@bitwarden/common/platform/state";
+import { UserId } from "@bitwarden/common/types/guid";
+
+import { DefaultSingleNudgeService } from "../default-single-nudge.service";
+import { NudgeStatus, NudgeType } from "../nudges.service";
+
+@Injectable() // NO providedIn: "root"
+export class MyClientSpecificNudgeService extends DefaultSingleNudgeService {
+ constructor(
+ stateProvider: StateProvider,
+ private clientSpecificService: ClientSpecificService,
+ ) {
+ super(stateProvider);
+ }
+
+ nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable {
+ return combineLatest([
+ this.getNudgeStatus$(nudgeType, userId),
+ this.clientSpecificService.someData$,
+ ]).pipe(
+ map(([persistedStatus, data]) => {
+ const autoDismiss = /* your logic */;
+ return {
+ hasBadgeDismissed: persistedStatus.hasBadgeDismissed || autoDismiss,
+ hasSpotlightDismissed: persistedStatus.hasSpotlightDismissed || autoDismiss,
+ };
+ }),
+ );
+ }
+}
+```
+
+### Step 3: Add NudgeType
+
+Add your nudge type to `NudgeType` in `../nudges.service.ts`:
+
+```typescript
+export const NudgeType = {
+ // ... existing types
+ MyNewNudge: "my-new-nudge",
+} as const;
+```
+
+### Step 4: Register in NudgesService
+
+#### For Universal Services
+
+Add to `customNudgeServices` map in `../nudges.service.ts`:
+
+```typescript
+private customNudgeServices: Partial> = {
+ // ... existing
+ [NudgeType.MyNewNudge]: inject(MyNudgeService),
+};
+```
+
+#### For Client-Specific Services
+
+1. **Add injection token** in `../nudge-injection-tokens.ts`:
+
+```typescript
+export const MY_NUDGE_SERVICE = new InjectionToken("MyNudgeService");
+```
+
+2. **Inject with optional** in `../nudges.service.ts`:
+
+```typescript
+private myNudgeService = inject(MY_NUDGE_SERVICE, { optional: true });
+
+private customNudgeServices = {
+ // ... existing
+ [NudgeType.MyNewNudge]: this.myNudgeService ?? this.noOpNudgeService,
+};
+```
+
+3. **Register in each supporting client** (e.g., `apps/browser/src/popup/services/services.module.ts`):
+
+```typescript
+import { MY_NUDGE_SERVICE } from "@bitwarden/angular/vault";
+
+safeProvider({
+ provide: MY_NUDGE_SERVICE as SafeInjectionToken,
+ useClass: MyClientSpecificNudgeService,
+ deps: [StateProvider, ClientSpecificService],
+}),
+```
diff --git a/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.ts
index 52fc87d7604..9fe843e50e0 100644
--- a/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.ts
+++ b/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.ts
@@ -1,15 +1,24 @@
-import { inject, Injectable } from "@angular/core";
+import { Injectable } from "@angular/core";
import { combineLatest, map, Observable } from "rxjs";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
+import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/user-core";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeType, NudgeStatus } from "../nudges.service";
-@Injectable({ providedIn: "root" })
+/**
+ * Browser specific nudge service for auto-confirm nudge.
+ */
+@Injectable()
export class AutoConfirmNudgeService extends DefaultSingleNudgeService {
- autoConfirmService = inject(AutomaticUserConfirmationService);
+ constructor(
+ stateProvider: StateProvider,
+ private autoConfirmService: AutomaticUserConfirmationService,
+ ) {
+ super(stateProvider);
+ }
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable {
return combineLatest([
diff --git a/libs/angular/src/vault/services/custom-nudges-services/new-account-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/new-account-nudge.service.ts
index 39af9a2e4aa..8c18da8a103 100644
--- a/libs/angular/src/vault/services/custom-nudges-services/new-account-nudge.service.ts
+++ b/libs/angular/src/vault/services/custom-nudges-services/new-account-nudge.service.ts
@@ -1,10 +1,11 @@
-import { Injectable, inject } from "@angular/core";
+import { Injectable } from "@angular/core";
import { Observable, combineLatest, from, map, of } from "rxjs";
import { catchError } from "rxjs/operators";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
+import { StateProvider } from "@bitwarden/state";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
@@ -18,8 +19,13 @@ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
providedIn: "root",
})
export class NewAccountNudgeService extends DefaultSingleNudgeService {
- vaultProfileService = inject(VaultProfileService);
- logService = inject(LogService);
+ constructor(
+ stateProvider: StateProvider,
+ private vaultProfileService: VaultProfileService,
+ private logService: LogService,
+ ) {
+ super(stateProvider);
+ }
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable {
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(
diff --git a/libs/angular/src/vault/services/default-single-nudge.service.ts b/libs/angular/src/vault/services/default-single-nudge.service.ts
index 8abc344c4a0..06c08371f41 100644
--- a/libs/angular/src/vault/services/default-single-nudge.service.ts
+++ b/libs/angular/src/vault/services/default-single-nudge.service.ts
@@ -1,4 +1,4 @@
-import { inject, Injectable } from "@angular/core";
+import { Injectable } from "@angular/core";
import { map, Observable } from "rxjs";
import { StateProvider } from "@bitwarden/common/platform/state";
@@ -22,7 +22,11 @@ export interface SingleNudgeService {
providedIn: "root",
})
export class DefaultSingleNudgeService implements SingleNudgeService {
- stateProvider = inject(StateProvider);
+ protected stateProvider: StateProvider;
+
+ constructor(stateProvider: StateProvider) {
+ this.stateProvider = stateProvider;
+ }
protected getNudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable {
return this.stateProvider
diff --git a/libs/angular/src/vault/services/nudge-injection-tokens.ts b/libs/angular/src/vault/services/nudge-injection-tokens.ts
index 52a0838d356..43db5ec1dc6 100644
--- a/libs/angular/src/vault/services/nudge-injection-tokens.ts
+++ b/libs/angular/src/vault/services/nudge-injection-tokens.ts
@@ -2,6 +2,25 @@ import { InjectionToken } from "@angular/core";
import { SingleNudgeService } from "./default-single-nudge.service";
+/**
+ * Injection tokens for client specific nudge services.
+ *
+ * These services require platform-specific features and must be explicitly
+ * provided by each client that supports them. If not provided, NudgesService
+ * falls back to NoOpNudgeService.
+ *
+ * Client specific services should use constructor injection (not inject())
+ * to maintain safeProvider type safety.
+ *
+ * Universal services use @Injectable({ providedIn: "root" }) and can use inject().
+ */
+
+/** Browser: Requires BrowserApi */
export const AUTOFILL_NUDGE_SERVICE = new InjectionToken(
"AutofillNudgeService",
);
+
+/** Browser: Requires AutomaticUserConfirmationService */
+export const AUTO_CONFIRM_NUDGE_SERVICE = new InjectionToken(
+ "AutoConfirmNudgeService",
+);
diff --git a/libs/angular/src/vault/services/nudges.service.ts b/libs/angular/src/vault/services/nudges.service.ts
index afd0d184d6e..a8a8feb073f 100644
--- a/libs/angular/src/vault/services/nudges.service.ts
+++ b/libs/angular/src/vault/services/nudges.service.ts
@@ -12,11 +12,10 @@ import {
NewItemNudgeService,
AccountSecurityNudgeService,
VaultSettingsImportNudgeService,
- AutoConfirmNudgeService,
NoOpNudgeService,
} from "./custom-nudges-services";
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
-import { AUTOFILL_NUDGE_SERVICE } from "./nudge-injection-tokens";
+import { AUTOFILL_NUDGE_SERVICE, AUTO_CONFIRM_NUDGE_SERVICE } from "./nudge-injection-tokens";
export type NudgeStatus = {
hasBadgeDismissed: boolean;
@@ -63,12 +62,21 @@ export class NudgesService {
// NoOp service that always returns dismissed
private noOpNudgeService = inject(NoOpNudgeService);
- // Optional Browser-specific service provided via injection token (not all clients have autofill)
+ // Client specific services (optional, via injection tokens)
+ // These services require platform-specific features and fallback to NoOpNudgeService if not provided
+
private autofillNudgeService = inject(AUTOFILL_NUDGE_SERVICE, { optional: true });
+ private autoConfirmNudgeService = inject(AUTO_CONFIRM_NUDGE_SERVICE, { optional: true });
/**
* Custom nudge services to use for specific nudge types
* Each nudge type can have its own service to determine when to show the nudge
+ *
+ * NOTE: If a custom nudge service requires client specific services/features:
+ * 1. The custom nudge service must be provided via injection token and marked as optional.
+ * 2. The custom nudge service must be manually registered with that token in the client(s).
+ *
+ * See the README.md in the custom-nudge-services folder for more details on adding custom nudges.
* @private
*/
private customNudgeServices: Partial> = {
@@ -84,7 +92,7 @@ export class NudgesService {
[NudgeType.NewIdentityItemStatus]: this.newItemNudgeService,
[NudgeType.NewNoteItemStatus]: this.newItemNudgeService,
[NudgeType.NewSshItemStatus]: this.newItemNudgeService,
- [NudgeType.AutoConfirmNudge]: inject(AutoConfirmNudgeService),
+ [NudgeType.AutoConfirmNudge]: this.autoConfirmNudgeService ?? this.noOpNudgeService,
};
/**
From 464a0427bf41bdee96a44b918568c39d251e829c Mon Sep 17 00:00:00 2001
From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com>
Date: Wed, 21 Jan 2026 16:28:39 -0800
Subject: [PATCH 09/24] [PM-29816] - fix scroll position in browser vault
(#18449)
* fix scroll position in browser vault
* use bitScrollLayoutHost
---
.../popup/layout/popup-page.component.html | 1 -
.../popup/layout/popup-page.component.ts | 27 ++++++--
.../vault-v2/vault-v2.component.spec.ts | 28 +++++---
.../components/vault-v2/vault-v2.component.ts | 34 +++++-----
...ault-popup-scroll-position.service.spec.ts | 68 ++++++++++---------
.../vault-popup-scroll-position.service.ts | 22 +++---
6 files changed, 104 insertions(+), 76 deletions(-)
diff --git a/apps/browser/src/platform/popup/layout/popup-page.component.html b/apps/browser/src/platform/popup/layout/popup-page.component.html
index 828d9947373..bb24fb800aa 100644
--- a/apps/browser/src/platform/popup/layout/popup-page.component.html
+++ b/apps/browser/src/platform/popup/layout/popup-page.component.html
@@ -25,7 +25,6 @@
-
+ }
+ @if (action !== "add" && action !== "edit" && action !== "view" && action !== "clone") {
+
+ }
+
+
-
+
+ @if (activeFilter.status === "archive" && !(hasArchivedCiphers$ | async)) {
+
+
+ } @else {
+
+ }
+
+ {{ "noItemsInArchive" | i18n }}
+
+ + {{ "noItemsInArchiveDesc" | i18n }} +
+(false);
@@ -33,10 +39,21 @@ export class PopupPageComponent {
protected readonly scrolled = signal(false);
isScrolled = this.scrolled.asReadonly();
+ constructor() {
+ this.scrollLayout.scrollableRef$
+ .pipe(
+ filter((ref): ref is ElementRef => ref != null),
+ switchMap((ref) =>
+ fromEvent(ref.nativeElement, "scroll").pipe(
+ startWith(null),
+ map(() => ref.nativeElement.scrollTop !== 0),
+ ),
+ ),
+ takeUntilDestroyed(this.destroyRef),
+ )
+ .subscribe((isScrolled) => this.scrolled.set(isScrolled));
+ }
+
/** Accessible loading label for the spinner. Defaults to "loading" */
readonly loadingText = input(this.i18nService.t("loading"));
-
- handleScroll(event: Event) {
- this.scrolled.set((event.currentTarget as HTMLElement).scrollTop !== 0);
- }
}
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts
index e6dffdaff08..2c94d9c226b 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts
+++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts
@@ -1,4 +1,3 @@
-import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling";
import { ChangeDetectionStrategy, Component, input, NO_ERRORS_SCHEMA } from "@angular/core";
import { TestBed, fakeAsync, flush, tick } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
@@ -394,21 +393,28 @@ describe("VaultV2Component", () => {
expect(values[values.length - 1]).toBe(false);
});
- it("ngAfterViewInit waits for allFilters$ then starts scroll position service", fakeAsync(() => {
+ it("passes popup-page scroll region element to scroll position service", fakeAsync(() => {
+ const fixture = TestBed.createComponent(VaultV2Component);
+ const component = fixture.componentInstance;
+
+ const readySubject$ = component["readySubject"] as unknown as BehaviorSubject;
+ const itemsLoading$ = itemsSvc.loading$ as unknown as BehaviorSubject;
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject;
- (component as any).virtualScrollElement = {} as CdkVirtualScrollableElement;
-
- component.ngAfterViewInit();
- expect(scrollSvc.start).not.toHaveBeenCalled();
-
- allFilters$.next({ any: true });
+ fixture.detectChanges();
tick();
- expect(scrollSvc.start).toHaveBeenCalledTimes(1);
- expect(scrollSvc.start).toHaveBeenCalledWith((component as any).virtualScrollElement);
+ const scrollRegion = fixture.nativeElement.querySelector(
+ '[data-testid="popup-layout-scroll-region"]',
+ ) as HTMLElement;
- flush();
+ // Unblock loading
+ itemsLoading$.next(false);
+ readySubject$.next(true);
+ allFilters$.next({});
+ tick();
+
+ expect(scrollSvc.start).toHaveBeenCalledWith(scrollRegion);
}));
it("showPremiumDialog opens PremiumUpgradeDialogComponent", () => {
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts
index 761b366bcd2..4678e2733eb 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts
+++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts
@@ -1,7 +1,7 @@
import { LiveAnnouncer } from "@angular/cdk/a11y";
-import { CdkVirtualScrollableElement, ScrollingModule } from "@angular/cdk/scrolling";
+import { ScrollingModule } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
-import { AfterViewInit, Component, DestroyRef, OnDestroy, OnInit, ViewChild } from "@angular/core";
+import { Component, DestroyRef, effect, inject, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router, RouterModule } from "@angular/router";
import {
@@ -47,6 +47,7 @@ import {
ButtonModule,
DialogService,
NoItemsModule,
+ ScrollLayoutService,
ToastService,
TypographyModule,
} from "@bitwarden/components";
@@ -119,11 +120,7 @@ type VaultState = UnionOfValues;
],
providers: [{ provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }],
})
-export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
- // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
- // eslint-disable-next-line @angular-eslint/prefer-signals
- @ViewChild(CdkVirtualScrollableElement) virtualScrollElement?: CdkVirtualScrollableElement;
-
+export class VaultV2Component implements OnInit, OnDestroy {
NudgeType = NudgeType;
cipherType = CipherType;
private activeUserId$ = this.accountService.activeAccount$.pipe(getUserId);
@@ -308,16 +305,21 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
});
}
- ngAfterViewInit(): void {
- if (this.virtualScrollElement) {
- // The filters component can cause the size of the virtual scroll element to change,
- // which can cause the scroll position to be land in the wrong spot. To fix this,
- // wait until all filters are populated before restoring the scroll position.
- this.allFilters$.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
- this.vaultScrollPositionService.start(this.virtualScrollElement!);
+ private readonly scrollLayout = inject(ScrollLayoutService);
+
+ private readonly _scrollPositionEffect = effect((onCleanup) => {
+ const sub = combineLatest([this.scrollLayout.scrollableRef$, this.allFilters$, this.loading$])
+ .pipe(
+ filter(([ref, _filters, loading]) => !!ref && !loading),
+ take(1),
+ takeUntilDestroyed(this.destroyRef),
+ )
+ .subscribe(([ref]) => {
+ this.vaultScrollPositionService.start(ref!.nativeElement);
});
- }
- }
+
+ onCleanup(() => sub.unsubscribe());
+ });
async ngOnInit() {
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
diff --git a/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.spec.ts
index 562375f8f85..af21f664f2d 100644
--- a/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.spec.ts
+++ b/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.spec.ts
@@ -1,4 +1,3 @@
-import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling";
import { fakeAsync, TestBed, tick } from "@angular/core/testing";
import { NavigationEnd, Router } from "@angular/router";
import { Subject, Subscription } from "rxjs";
@@ -66,21 +65,18 @@ describe("VaultPopupScrollPositionService", () => {
});
describe("start", () => {
- const elementScrolled$ = new Subject();
- const focus = jest.fn();
- const nativeElement = {
- scrollTop: 0,
- querySelector: jest.fn(() => ({ focus })),
- addEventListener: jest.fn(),
- style: {
- visibility: "",
- },
- };
- const virtualElement = {
- elementScrolled: () => elementScrolled$,
- getElementRef: () => ({ nativeElement }),
- scrollTo: jest.fn(),
- } as unknown as CdkVirtualScrollableElement;
+ let scrollElement: HTMLElement;
+
+ beforeEach(() => {
+ scrollElement = document.createElement("div");
+
+ (scrollElement as any).scrollTo = jest.fn(function scrollTo(opts: { top?: number }) {
+ if (opts?.top != null) {
+ (scrollElement as any).scrollTop = opts.top;
+ }
+ });
+ (scrollElement as any).scrollTop = 0;
+ });
afterEach(() => {
// remove the actual subscription created by `.subscribe`
@@ -89,47 +85,55 @@ describe("VaultPopupScrollPositionService", () => {
describe("initial scroll position", () => {
beforeEach(() => {
- (virtualElement.scrollTo as jest.Mock).mockClear();
- nativeElement.querySelector.mockClear();
+ ((scrollElement as any).scrollTo as jest.Mock).mockClear();
});
it("does not scroll when `scrollPosition` is null", () => {
service["scrollPosition"] = null;
- service.start(virtualElement);
+ service.start(scrollElement);
- expect(virtualElement.scrollTo).not.toHaveBeenCalled();
+ expect((scrollElement as any).scrollTo).not.toHaveBeenCalled();
});
- it("scrolls the virtual element to `scrollPosition`", fakeAsync(() => {
+ it("scrolls the element to `scrollPosition` (async via setTimeout)", fakeAsync(() => {
service["scrollPosition"] = 500;
- nativeElement.scrollTop = 500;
- service.start(virtualElement);
+ service.start(scrollElement);
tick();
- expect(virtualElement.scrollTo).toHaveBeenCalledWith({ behavior: "instant", top: 500 });
+ expect((scrollElement as any).scrollTo).toHaveBeenCalledWith({
+ behavior: "instant",
+ top: 500,
+ });
+ expect((scrollElement as any).scrollTop).toBe(500);
}));
});
describe("scroll listener", () => {
it("unsubscribes from any existing subscription", () => {
- service.start(virtualElement);
+ service.start(scrollElement);
expect(unsubscribe).toHaveBeenCalled();
});
- it("subscribes to `elementScrolled`", fakeAsync(() => {
- virtualElement.measureScrollOffset = jest.fn(() => 455);
+ it("stores scrollTop on subsequent scroll events (skips first)", fakeAsync(() => {
+ service["scrollPosition"] = null;
- service.start(virtualElement);
+ service.start(scrollElement);
- elementScrolled$.next(null); // first subscription is skipped by `skip(1)`
- elementScrolled$.next(null);
+ // First scroll event is intentionally ignored (equivalent to old skip(1)).
+ (scrollElement as any).scrollTop = 111;
+ scrollElement.dispatchEvent(new Event("scroll"));
+ tick();
+
+ expect(service["scrollPosition"]).toBeNull();
+
+ // Second scroll event should persist.
+ (scrollElement as any).scrollTop = 455;
+ scrollElement.dispatchEvent(new Event("scroll"));
tick();
- expect(virtualElement.measureScrollOffset).toHaveBeenCalledTimes(1);
- expect(virtualElement.measureScrollOffset).toHaveBeenCalledWith("top");
expect(service["scrollPosition"]).toBe(455);
}));
});
diff --git a/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.ts b/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.ts
index 5bfe0ec9331..7261fdd6633 100644
--- a/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.ts
+++ b/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.ts
@@ -1,8 +1,7 @@
-import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling";
import { inject, Injectable } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router } from "@angular/router";
-import { filter, skip, Subscription } from "rxjs";
+import { filter, fromEvent, Subscription } from "rxjs";
@Injectable({
providedIn: "root",
@@ -31,24 +30,25 @@ export class VaultPopupScrollPositionService {
}
/** Scrolls the user to the stored scroll position and starts tracking scroll of the page. */
- start(virtualScrollElement: CdkVirtualScrollableElement) {
+ start(scrollElement: HTMLElement) {
if (this.hasScrollPosition()) {
// Use `setTimeout` to scroll after rendering is complete
setTimeout(() => {
- virtualScrollElement.scrollTo({ top: this.scrollPosition!, behavior: "instant" });
+ scrollElement.scrollTo({ top: this.scrollPosition!, behavior: "instant" });
});
}
this.scrollSubscription?.unsubscribe();
// Skip the first scroll event to avoid settings the scroll from the above `scrollTo` call
- this.scrollSubscription = virtualScrollElement
- ?.elementScrolled()
- .pipe(skip(1))
- .subscribe(() => {
- const offset = virtualScrollElement.measureScrollOffset("top");
- this.scrollPosition = offset;
- });
+ let skipped = false;
+ this.scrollSubscription = fromEvent(scrollElement, "scroll").subscribe(() => {
+ if (!skipped) {
+ skipped = true;
+ return;
+ }
+ this.scrollPosition = scrollElement.scrollTop;
+ });
}
/** Stops the scroll listener from updating the stored location. */
From 65abeb51aad6b4c6462c3d41e91c6154aca8223e Mon Sep 17 00:00:00 2001
From: Brandon Treston
Date: Wed, 21 Jan 2026 23:47:14 -0500
Subject: [PATCH 10/24] [PM-30500] Centralize Organization Data Ownership
(#18387)
* remove deprecated OrganizationDataOwnership components, promote vNext
* WIP: add new components and copy
* multi step dialog for organization- data ownership
* disable save
* clean up copy, fix bug
* copy change, update button text
* update copy
* un-rename model
* use policyApiService
* simplify style
---
.../organizations/policies/index.ts | 2 +-
.../auto-confirm-policy.component.ts | 2 +-
.../policies/policy-edit-definitions/index.ts | 5 +-
...organization-data-ownership.component.html | 55 ++++-
.../organization-data-ownership.component.ts | 87 ++++++-
...organization-data-ownership.component.html | 103 ++++----
...t-organization-data-ownership.component.ts | 53 +++--
.../policies/policy-edit-dialog.component.ts | 26 +-
...-confirm-edit-policy-dialog.component.html | 0
...to-confirm-edit-policy-dialog.component.ts | 21 +-
.../policies/policy-edit-dialogs/index.ts | 3 +
.../policies/policy-edit-dialogs/models.ts | 7 +
...wnership-edit-policy-dialog.component.html | 72 ++++++
...-ownership-edit-policy-dialog.component.ts | 224 ++++++++++++++++++
apps/web/src/locales/en/messages.json | 31 +++
15 files changed, 586 insertions(+), 105 deletions(-)
rename apps/web/src/app/admin-console/organizations/policies/{ => policy-edit-dialogs}/auto-confirm-edit-policy-dialog.component.html (100%)
rename apps/web/src/app/admin-console/organizations/policies/{ => policy-edit-dialogs}/auto-confirm-edit-policy-dialog.component.ts (94%)
create mode 100644 apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/index.ts
create mode 100644 apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/models.ts
create mode 100644 apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.html
create mode 100644 apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.ts
diff --git a/apps/web/src/app/admin-console/organizations/policies/index.ts b/apps/web/src/app/admin-console/organizations/policies/index.ts
index 3042be240f7..eb614e180e1 100644
--- a/apps/web/src/app/admin-console/organizations/policies/index.ts
+++ b/apps/web/src/app/admin-console/organizations/policies/index.ts
@@ -2,6 +2,6 @@ export { PoliciesComponent } from "./policies.component";
export { ossPolicyEditRegister } from "./policy-edit-register";
export { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component";
export { POLICY_EDIT_REGISTER } from "./policy-register-token";
-export { AutoConfirmPolicyDialogComponent } from "./auto-confirm-edit-policy-dialog.component";
export { AutoConfirmPolicy } from "./policy-edit-definitions";
export { PolicyEditDialogResult } from "./policy-edit-dialog.component";
+export * from "./policy-edit-dialogs";
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts
index cf2a2929905..66074918084 100644
--- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts
+++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts
@@ -15,8 +15,8 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SharedModule } from "../../../../shared";
-import { AutoConfirmPolicyDialogComponent } from "../auto-confirm-edit-policy-dialog.component";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
+import { AutoConfirmPolicyDialogComponent } from "../policy-edit-dialogs/auto-confirm-edit-policy-dialog.component";
export class AutoConfirmPolicy extends BasePolicyEditDefinition {
name = "autoConfirm";
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts
index 9b46e228af9..042f9771529 100644
--- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts
+++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts
@@ -1,7 +1,10 @@
export { DisableSendPolicy } from "./disable-send.component";
export { DesktopAutotypeDefaultSettingPolicy } from "./autotype-policy.component";
export { MasterPasswordPolicy } from "./master-password.component";
-export { OrganizationDataOwnershipPolicy } from "./organization-data-ownership.component";
+export {
+ OrganizationDataOwnershipPolicy,
+ OrganizationDataOwnershipPolicyComponent,
+} from "./organization-data-ownership.component";
export { PasswordGeneratorPolicy } from "./password-generator.component";
export { RemoveUnlockWithPinPolicy } from "./remove-unlock-with-pin.component";
export { RequireSsoPolicy } from "./require-sso.component";
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.html
index 2b6c86b1fdc..bd2237bc2fd 100644
--- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.html
+++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.html
@@ -1,8 +1,57 @@
-
- {{ "personalOwnershipExemption" | i18n }}
-
+
{{ "turnOn" | i18n }}
+
+
+
+ {{ "organizationDataOwnershipWarningTitle" | i18n }}
+
+
+
+
+
+ {{ "continue" | i18n }}
+
+
+ {{ "cancel" | i18n }}
+
+
+
+
+
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts
index ceecf8f2ecc..e4a07b7440d 100644
--- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts
+++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts
@@ -1,22 +1,38 @@
-import { ChangeDetectionStrategy, Component } from "@angular/core";
-import { of, Observable } from "rxjs";
+import { ChangeDetectionStrategy, Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
+import { lastValueFrom, map, Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
+import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { OrgKey } from "@bitwarden/common/types/key";
+import { CenterPositionStrategy, DialogService } from "@bitwarden/components";
+import { EncString } from "@bitwarden/sdk-internal";
import { SharedModule } from "../../../../shared";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
+export interface VNextPolicyRequest {
+ policy: PolicyRequest;
+ metadata: {
+ defaultUserCollectionName: string;
+ };
+}
+
export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
name = "organizationDataOwnership";
- description = "personalOwnershipPolicyDesc";
+ description = "organizationDataOwnershipDesc";
type = PolicyType.OrganizationDataOwnership;
component = OrganizationDataOwnershipPolicyComponent;
+ showDescription = false;
- display$(organization: Organization, configService: ConfigService): Observable {
- // TODO Remove this entire component upon verifying that it can be deleted due to its sole reliance of the CreateDefaultLocation feature flag
- return of(false);
+ override display$(organization: Organization, configService: ConfigService): Observable {
+ return configService
+ .getFeatureFlag$(FeatureFlag.MigrateMyVaultToMyItems)
+ .pipe(map((enabled) => !enabled));
}
}
@@ -26,4 +42,61 @@ export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class OrganizationDataOwnershipPolicyComponent extends BasePolicyEditComponent {}
+export class OrganizationDataOwnershipPolicyComponent
+ extends BasePolicyEditComponent
+ implements OnInit
+{
+ constructor(
+ private dialogService: DialogService,
+ private i18nService: I18nService,
+ private encryptService: EncryptService,
+ ) {
+ super();
+ }
+
+ // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
+ // eslint-disable-next-line @angular-eslint/prefer-signals
+ @ViewChild("dialog", { static: true }) warningContent!: TemplateRef;
+
+ override async confirm(): Promise {
+ if (this.policyResponse?.enabled && !this.enabled.value) {
+ const dialogRef = this.dialogService.open(this.warningContent, {
+ positionStrategy: new CenterPositionStrategy(),
+ });
+ const result = await lastValueFrom(dialogRef.closed);
+ return Boolean(result);
+ }
+ return true;
+ }
+
+ async buildVNextRequest(orgKey: OrgKey): Promise {
+ if (!this.policy) {
+ throw new Error("Policy was not found");
+ }
+
+ const defaultUserCollectionName = await this.getEncryptedDefaultUserCollectionName(orgKey);
+
+ const request: VNextPolicyRequest = {
+ policy: {
+ enabled: this.enabled.value ?? false,
+ data: this.buildRequestData(),
+ },
+ metadata: {
+ defaultUserCollectionName,
+ },
+ };
+
+ return request;
+ }
+
+ private async getEncryptedDefaultUserCollectionName(orgKey: OrgKey): Promise {
+ const defaultCollectionName = this.i18nService.t("myItems");
+ const encrypted = await this.encryptService.encryptString(defaultCollectionName, orgKey);
+
+ if (!encrypted.encryptedString) {
+ throw new Error("Encryption error");
+ }
+
+ return encrypted.encryptedString;
+ }
+}
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html
index bd2237bc2fd..e6c93b323c2 100644
--- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html
+++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html
@@ -1,57 +1,52 @@
-
-
-
- {{ "turnOn" | i18n }}
-
+
+
-
- {{ "organizationDataOwnershipWarningTitle" | i18n }}
-
-
-
-
-
- {{ "continue" | i18n }}
-
-
- {{ "cancel" | i18n }}
-
-
-
-
+
+
+ {{ "turnOn" | i18n }}
+
+
+
+
+
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.ts
index 59670457d88..e1b2f14d457 100644
--- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.ts
+++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.ts
@@ -1,18 +1,30 @@
-import { ChangeDetectionStrategy, Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
-import { lastValueFrom } from "rxjs";
+import {
+ ChangeDetectionStrategy,
+ Component,
+ OnInit,
+ signal,
+ Signal,
+ TemplateRef,
+ viewChild,
+ WritableSignal,
+} from "@angular/core";
+import { Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
+import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrgKey } from "@bitwarden/common/types/key";
-import { CenterPositionStrategy, DialogService } from "@bitwarden/components";
import { EncString } from "@bitwarden/sdk-internal";
import { SharedModule } from "../../../../shared";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
+import { OrganizationDataOwnershipPolicyDialogComponent } from "../policy-edit-dialogs";
-interface VNextPolicyRequest {
+export interface VNextPolicyRequest {
policy: PolicyRequest;
metadata: {
defaultUserCollectionName: string;
@@ -20,11 +32,17 @@ interface VNextPolicyRequest {
}
export class vNextOrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
- name = "organizationDataOwnership";
- description = "organizationDataOwnershipDesc";
+ name = "centralizeDataOwnership";
+ description = "centralizeDataOwnershipDesc";
type = PolicyType.OrganizationDataOwnership;
component = vNextOrganizationDataOwnershipPolicyComponent;
showDescription = false;
+
+ editDialogComponent = OrganizationDataOwnershipPolicyDialogComponent;
+
+ override display$(organization: Organization, configService: ConfigService): Observable {
+ return configService.getFeatureFlag$(FeatureFlag.MigrateMyVaultToMyItems);
+ }
}
@Component({
@@ -38,27 +56,16 @@ export class vNextOrganizationDataOwnershipPolicyComponent
implements OnInit
{
constructor(
- private dialogService: DialogService,
private i18nService: I18nService,
private encryptService: EncryptService,
) {
super();
}
+ private readonly policyForm: Signal | undefined> = viewChild("step0");
+ private readonly warningContent: Signal | undefined> = viewChild("step1");
+ protected readonly step: WritableSignal = signal(0);
- // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
- // eslint-disable-next-line @angular-eslint/prefer-signals
- @ViewChild("dialog", { static: true }) warningContent!: TemplateRef;
-
- override async confirm(): Promise {
- if (this.policyResponse?.enabled && !this.enabled.value) {
- const dialogRef = this.dialogService.open(this.warningContent, {
- positionStrategy: new CenterPositionStrategy(),
- });
- const result = await lastValueFrom(dialogRef.closed);
- return Boolean(result);
- }
- return true;
- }
+ protected steps = [this.policyForm, this.warningContent];
async buildVNextRequest(orgKey: OrgKey): Promise {
if (!this.policy) {
@@ -90,4 +97,8 @@ export class vNextOrganizationDataOwnershipPolicyComponent
return encrypted.encryptedString;
}
+
+ setStep(step: number) {
+ this.step.set(step);
+ }
}
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts
index c633ff5f421..f1b3d04cc7a 100644
--- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts
+++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts
@@ -16,6 +16,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
+import { OrgKey } from "@bitwarden/common/types/key";
import {
DIALOG_DATA,
DialogConfig,
@@ -28,7 +29,7 @@ import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component";
-import { vNextOrganizationDataOwnershipPolicyComponent } from "./policy-edit-definitions/vnext-organization-data-ownership.component";
+import { VNextPolicyRequest } from "./policy-edit-definitions/organization-data-ownership.component";
export type PolicyEditDialogData = {
/**
@@ -73,13 +74,24 @@ export class PolicyEditDialogComponent implements AfterViewInit {
private formBuilder: FormBuilder,
protected dialogRef: DialogRef,
protected toastService: ToastService,
- private keyService: KeyService,
+ protected keyService: KeyService,
) {}
get policy(): BasePolicyEditDefinition {
return this.data.policy;
}
+ /**
+ * Type guard to check if the policy component has the buildVNextRequest method.
+ */
+ private hasVNextRequest(
+ component: BasePolicyEditComponent,
+ ): component is BasePolicyEditComponent & {
+ buildVNextRequest: (orgKey: OrgKey) => Promise;
+ } {
+ return "buildVNextRequest" in component && typeof component.buildVNextRequest === "function";
+ }
+
/**
* Instantiates the child policy component and inserts it into the view.
*/
@@ -129,7 +141,7 @@ export class PolicyEditDialogComponent implements AfterViewInit {
}
try {
- if (this.policyComponent instanceof vNextOrganizationDataOwnershipPolicyComponent) {
+ if (this.hasVNextRequest(this.policyComponent)) {
await this.handleVNextSubmission(this.policyComponent);
} else {
await this.handleStandardSubmission();
@@ -158,7 +170,9 @@ export class PolicyEditDialogComponent implements AfterViewInit {
}
private async handleVNextSubmission(
- policyComponent: vNextOrganizationDataOwnershipPolicyComponent,
+ policyComponent: BasePolicyEditComponent & {
+ buildVNextRequest: (orgKey: OrgKey) => Promise;
+ },
): Promise {
const orgKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
@@ -173,12 +187,12 @@ export class PolicyEditDialogComponent implements AfterViewInit {
throw new Error("No encryption key for this organization.");
}
- const vNextRequest = await policyComponent.buildVNextRequest(orgKey);
+ const request = await policyComponent.buildVNextRequest(orgKey);
await this.policyApiService.putPolicyVNext(
this.data.organizationId,
this.data.policy.type,
- vNextRequest,
+ request,
);
}
static open = (dialogService: DialogService, config: DialogConfig) => {
diff --git a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.html
similarity index 100%
rename from apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html
rename to apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.html
diff --git a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts
similarity index 94%
rename from apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts
rename to apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts
index 9dfb8ebb7e7..fbdeffc71bb 100644
--- a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts
+++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts
@@ -41,20 +41,15 @@ import {
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
-import { SharedModule } from "../../../shared";
-
-import { AutoConfirmPolicyEditComponent } from "./policy-edit-definitions/auto-confirm-policy.component";
+import { SharedModule } from "../../../../shared";
+import { AutoConfirmPolicyEditComponent } from "../policy-edit-definitions/auto-confirm-policy.component";
import {
PolicyEditDialogComponent,
PolicyEditDialogData,
PolicyEditDialogResult,
-} from "./policy-edit-dialog.component";
+} from "../policy-edit-dialog.component";
-export type MultiStepSubmit = {
- sideEffect: () => Promise;
- footerContent: Signal | undefined>;
- titleContent: Signal | undefined>;
-};
+import { MultiStepSubmit } from "./models";
export type AutoConfirmPolicyDialogData = PolicyEditDialogData & {
firstTimeDialog?: boolean;
@@ -202,6 +197,7 @@ export class AutoConfirmPolicyDialogComponent
}
const autoConfirmRequest = await this.policyComponent.buildRequest();
+
await this.policyApiService.putPolicy(
this.data.organizationId,
this.data.policy.type,
@@ -235,7 +231,7 @@ export class AutoConfirmPolicyDialogComponent
data: null,
};
- await this.policyApiService.putPolicy(
+ await this.policyApiService.putPolicyVNext(
this.data.organizationId,
PolicyType.SingleOrg,
singleOrgRequest,
@@ -260,7 +256,10 @@ export class AutoConfirmPolicyDialogComponent
try {
const multiStepSubmit = await firstValueFrom(this.multiStepSubmit);
- await multiStepSubmit[this.currentStep()].sideEffect();
+ const sideEffect = multiStepSubmit[this.currentStep()].sideEffect;
+ if (sideEffect) {
+ await sideEffect();
+ }
if (this.currentStep() === multiStepSubmit.length - 1) {
this.dialogRef.close("saved");
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/index.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/index.ts
new file mode 100644
index 00000000000..307d0da04b0
--- /dev/null
+++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/index.ts
@@ -0,0 +1,3 @@
+export * from "./auto-confirm-edit-policy-dialog.component";
+export * from "./organization-data-ownership-edit-policy-dialog.component";
+export * from "./models";
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/models.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/models.ts
new file mode 100644
index 00000000000..86120623701
--- /dev/null
+++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/models.ts
@@ -0,0 +1,7 @@
+import { Signal, TemplateRef } from "@angular/core";
+
+export type MultiStepSubmit = {
+ sideEffect?: () => Promise;
+ footerContent: Signal | undefined>;
+ titleContent: Signal | undefined>;
+};
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.html
new file mode 100644
index 00000000000..73691e94199
--- /dev/null
+++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.html
@@ -0,0 +1,72 @@
+
+
+
+ {{ policy.name | i18n }}
+
+
+
+ {{ "centralizeDataOwnershipWarningTitle" | i18n }}
+
+
+
+
+ @if (policyComponent?.policyResponse?.enabled) {
+ {{ "save" | i18n }}
+ } @else {
+ {{ "continue" | i18n }}
+ }
+
+
+
+ {{ "cancel" | i18n }}
+
+
+
+
+
+ {{ "continue" | i18n }}
+
+
+ {{ "cancel" | i18n }}
+
+
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.ts
new file mode 100644
index 00000000000..7869eab0063
--- /dev/null
+++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.ts
@@ -0,0 +1,224 @@
+import {
+ AfterViewInit,
+ ChangeDetectorRef,
+ Component,
+ Inject,
+ signal,
+ TemplateRef,
+ viewChild,
+ WritableSignal,
+} from "@angular/core";
+import { FormBuilder } from "@angular/forms";
+import {
+ catchError,
+ combineLatest,
+ defer,
+ firstValueFrom,
+ from,
+ map,
+ Observable,
+ of,
+ startWith,
+ switchMap,
+} from "rxjs";
+
+import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
+import { PolicyType } from "@bitwarden/common/admin-console/enums";
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { getUserId } from "@bitwarden/common/auth/services/account.service";
+import { assertNonNullish } from "@bitwarden/common/auth/utils";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { OrganizationId } from "@bitwarden/common/types/guid";
+import {
+ DIALOG_DATA,
+ DialogConfig,
+ DialogRef,
+ DialogService,
+ ToastService,
+} from "@bitwarden/components";
+import { KeyService } from "@bitwarden/key-management";
+
+import { SharedModule } from "../../../../shared";
+import { vNextOrganizationDataOwnershipPolicyComponent } from "../policy-edit-definitions";
+import {
+ PolicyEditDialogComponent,
+ PolicyEditDialogData,
+ PolicyEditDialogResult,
+} from "../policy-edit-dialog.component";
+
+import { MultiStepSubmit } from "./models";
+
+/**
+ * Custom policy dialog component for Centralize Organization Data
+ * Ownership policy. Satisfies the PolicyDialogComponent interface
+ * structurally via its static open() function.
+ */
+// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
+// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
+@Component({
+ templateUrl: "organization-data-ownership-edit-policy-dialog.component.html",
+ imports: [SharedModule],
+})
+export class OrganizationDataOwnershipPolicyDialogComponent
+ extends PolicyEditDialogComponent
+ implements AfterViewInit
+{
+ policyType = PolicyType;
+
+ protected centralizeDataOwnershipEnabled$: Observable = defer(() =>
+ from(
+ this.policyApiService.getPolicy(
+ this.data.organizationId,
+ PolicyType.OrganizationDataOwnership,
+ ),
+ ).pipe(
+ map((policy) => policy.enabled),
+ catchError(() => of(false)),
+ ),
+ );
+
+ protected readonly currentStep: WritableSignal = signal(0);
+ protected readonly multiStepSubmit: WritableSignal = signal([]);
+
+ private readonly policyForm = viewChild.required>("step0");
+ private readonly warningContent = viewChild.required>("step1");
+ private readonly policyFormTitle = viewChild.required>("step0Title");
+ private readonly warningTitle = viewChild.required>("step1Title");
+
+ override policyComponent: vNextOrganizationDataOwnershipPolicyComponent | undefined;
+
+ constructor(
+ @Inject(DIALOG_DATA) protected data: PolicyEditDialogData,
+ accountService: AccountService,
+ policyApiService: PolicyApiServiceAbstraction,
+ i18nService: I18nService,
+ cdr: ChangeDetectorRef,
+ formBuilder: FormBuilder,
+ dialogRef: DialogRef,
+ toastService: ToastService,
+ protected keyService: KeyService,
+ ) {
+ super(
+ data,
+ accountService,
+ policyApiService,
+ i18nService,
+ cdr,
+ formBuilder,
+ dialogRef,
+ toastService,
+ keyService,
+ );
+ }
+
+ async ngAfterViewInit() {
+ await super.ngAfterViewInit();
+
+ if (this.policyComponent) {
+ this.saveDisabled$ = combineLatest([
+ this.centralizeDataOwnershipEnabled$,
+ this.policyComponent.enabled.valueChanges.pipe(
+ startWith(this.policyComponent.enabled.value),
+ ),
+ ]).pipe(map(([policyEnabled, value]) => !policyEnabled && !value));
+ }
+
+ this.multiStepSubmit.set(this.buildMultiStepSubmit());
+ }
+
+ private buildMultiStepSubmit(): MultiStepSubmit[] {
+ if (this.policyComponent?.policyResponse?.enabled) {
+ return [
+ {
+ sideEffect: () => this.handleSubmit(),
+ footerContent: this.policyForm,
+ titleContent: this.policyFormTitle,
+ },
+ ];
+ }
+
+ return [
+ {
+ footerContent: this.policyForm,
+ titleContent: this.policyFormTitle,
+ },
+ {
+ sideEffect: () => this.handleSubmit(),
+ footerContent: this.warningContent,
+ titleContent: this.warningTitle,
+ },
+ ];
+ }
+
+ private async handleSubmit() {
+ if (!this.policyComponent) {
+ throw new Error("PolicyComponent not initialized.");
+ }
+
+ const orgKey = await firstValueFrom(
+ this.accountService.activeAccount$.pipe(
+ getUserId,
+ switchMap((userId) => this.keyService.orgKeys$(userId)),
+ ),
+ );
+
+ assertNonNullish(orgKey, "Org key not provided");
+
+ const request = await this.policyComponent.buildVNextRequest(
+ orgKey[this.data.organizationId as OrganizationId],
+ );
+
+ await this.policyApiService.putPolicyVNext(
+ this.data.organizationId,
+ this.data.policy.type,
+ request,
+ );
+
+ this.toastService.showToast({
+ variant: "success",
+ message: this.i18nService.t("editedPolicyId", this.i18nService.t(this.data.policy.name)),
+ });
+
+ if (!this.policyComponent.enabled.value) {
+ this.dialogRef.close("saved");
+ }
+ }
+
+ submit = async () => {
+ if (!this.policyComponent) {
+ throw new Error("PolicyComponent not initialized.");
+ }
+
+ if ((await this.policyComponent.confirm()) == false) {
+ this.dialogRef.close();
+ return;
+ }
+
+ try {
+ const sideEffect = this.multiStepSubmit()[this.currentStep()].sideEffect;
+ if (sideEffect) {
+ await sideEffect();
+ }
+
+ if (this.currentStep() === this.multiStepSubmit().length - 1) {
+ this.dialogRef.close("saved");
+ return;
+ }
+
+ this.currentStep.update((value) => value + 1);
+ this.policyComponent.setStep(this.currentStep());
+ } catch (error: any) {
+ this.toastService.showToast({
+ variant: "error",
+ message: error.message,
+ });
+ }
+ };
+
+ static open = (dialogService: DialogService, config: DialogConfig) => {
+ return dialogService.open(
+ OrganizationDataOwnershipPolicyDialogComponent,
+ config,
+ );
+ };
+}
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index bf7c1a4c908..e7cc7c6cb5c 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -5915,6 +5915,37 @@
}
}
},
+ "centralizeDataOwnership":{
+ "message": "Centralize organization ownership"
+ },
+ "centralizeDataOwnershipDesc":{
+ "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. "
+ },
+ "centralizeDataOwnershipContentAnchor": {
+ "message": "Learn more about centralized ownership",
+ "description": "This will be used as a hyperlink"
+ },
+ "benefits":{
+ "message": "Benefits"
+ },
+ "centralizeDataOwnershipBenefit1":{
+ "message": "Gain full visibility into credential health, including shared and unshared items."
+ },
+ "centralizeDataOwnershipBenefit2":{
+ "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps."
+ },
+ "centralizeDataOwnershipBenefit3":{
+ "message": "Give all users a dedicated \"My Items\" space for managing their own logins."
+ },
+ "centralizeDataOwnershipWarningTitle": {
+ "message": "Prompt members to transfer their items"
+ },
+ "centralizeDataOwnershipWarningDesc": {
+ "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime."
+ },
+ "centralizeDataOwnershipWarningLink": {
+ "message": "Learn more about the transfer"
+ },
"organizationDataOwnership": {
"message": "Enforce organization data ownership"
},
From 9a479544a66f70eb4e21d141b1a07be6db36b53f Mon Sep 17 00:00:00 2001
From: Oscar Hinton
Date: Thu, 22 Jan 2026 10:25:39 +0100
Subject: [PATCH 11/24] Add support for rounded layout (#18283)
---
.../src/app/layout/desktop-layout.component.html | 2 +-
libs/components/src/layout/layout.component.html | 3 ++-
libs/components/src/layout/layout.component.ts | 8 +++++++-
libs/components/src/layout/layout.stories.ts | 11 ++++++++++-
4 files changed, 20 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/src/app/layout/desktop-layout.component.html b/apps/desktop/src/app/layout/desktop-layout.component.html
index cb969f573fc..f9921ac11ef 100644
--- a/apps/desktop/src/app/layout/desktop-layout.component.html
+++ b/apps/desktop/src/app/layout/desktop-layout.component.html
@@ -1,4 +1,4 @@
-
+
diff --git a/libs/components/src/layout/layout.component.html b/libs/components/src/layout/layout.component.html
index 255799b6690..66bfcafafe9 100644
--- a/libs/components/src/layout/layout.component.html
+++ b/libs/components/src/layout/layout.component.html
@@ -1,5 +1,5 @@
@let mainContentId = "main-content";
-
+ {{ "organizationDataOwnershipDescContent" | i18n }} + + {{ "organizationDataOwnershipContentAnchor" | i18n }}. + +
+ {{ "organizationDataOwnershipWarningContentTop" | i18n }}
+
+
+
+ {{ "organizationDataOwnershipWarningContentBottom" | i18n }}
+
+ {{ "organizationDataOwnershipContentAnchor" | i18n }}.
+
+ -
+
- + {{ "organizationDataOwnershipWarning1" | i18n }} + +
- + {{ "organizationDataOwnershipWarning2" | i18n }} + +
- + {{ "organizationDataOwnershipWarning3" | i18n }} + +
- {{ "organizationDataOwnershipDescContent" | i18n }} - - {{ "organizationDataOwnershipContentAnchor" | i18n }}. - -
++ {{ "centralizeDataOwnershipDesc" | i18n }} + + {{ "centralizeDataOwnershipContentAnchor" | i18n }} + + +
-
- {{ "organizationDataOwnershipWarningContentTop" | i18n }}
-
-
-
- {{ "organizationDataOwnershipWarningContentBottom" | i18n }}
-
- {{ "organizationDataOwnershipContentAnchor" | i18n }}.
-
- -
-
- - {{ "organizationDataOwnershipWarning1" | i18n }} - -
- - {{ "organizationDataOwnershipWarning2" | i18n }} - -
- - {{ "organizationDataOwnershipWarning3" | i18n }} - -
+ {{ "benefits" | i18n }}:
+
+
+ -
+
- + {{ "centralizeDataOwnershipBenefit1" | i18n }} + +
- + {{ "centralizeDataOwnershipBenefit2" | i18n }} + +
- + {{ "centralizeDataOwnershipBenefit3" | i18n }} + +
+
+ {{ "centralizeDataOwnershipWarningDesc" | i18n }}
+
+
+ {{ "centralizeDataOwnershipWarningLink" | i18n }}
+
+
+
+
diff --git a/libs/components/src/layout/layout.component.ts b/libs/components/src/layout/layout.component.ts
index 7460099cf92..5e3d420c8e5 100644
--- a/libs/components/src/layout/layout.component.ts
+++ b/libs/components/src/layout/layout.component.ts
@@ -1,7 +1,7 @@
import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y";
import { PortalModule } from "@angular/cdk/portal";
import { CommonModule } from "@angular/common";
-import { Component, ElementRef, inject, viewChild } from "@angular/core";
+import { booleanAttribute, Component, ElementRef, inject, input, viewChild } from "@angular/core";
import { RouterModule } from "@angular/router";
import { DrawerHostDirective } from "../drawer/drawer-host.directive";
@@ -38,6 +38,12 @@ export class LayoutComponent {
protected drawerPortal = inject(DrawerService).portal;
private readonly mainContent = viewChild.required>("main");
+
+ /**
+ * Rounded top left corner for the main content area
+ */
+ readonly rounded = input(false, { transform: booleanAttribute });
+
protected focusMainContent() {
this.mainContent().nativeElement.focus();
}
diff --git a/libs/components/src/layout/layout.stories.ts b/libs/components/src/layout/layout.stories.ts
index 59770c21d2e..75ae329a1b3 100644
--- a/libs/components/src/layout/layout.stories.ts
+++ b/libs/components/src/layout/layout.stories.ts
@@ -14,6 +14,8 @@ import { StorybookGlobalStateProvider } from "../utils/state-mock";
import { LayoutComponent } from "./layout.component";
import { mockLayoutI18n } from "./mocks";
+import { formatArgsForCodeSnippet } from ".storybook/format-args-for-code-snippet";
+
export default {
title: "Component Library/Layout",
component: LayoutComponent,
@@ -63,7 +65,7 @@ export const WithContent: Story = {
render: (args) => ({
props: args,
template: /* HTML */ `
-
+ (args)}>
@@ -111,3 +113,10 @@ export const Secondary: Story = {
`,
}),
};
+
+export const Rounded: Story = {
+ ...WithContent,
+ args: {
+ rounded: true,
+ },
+};
From 0433678085493060e061c34a6e0baafce920279e Mon Sep 17 00:00:00 2001
From: Oscar Hinton
Date: Thu, 22 Jan 2026 10:56:43 +0100
Subject: [PATCH 12/24] [PM-31029] Add feature flag for milestone 2 (#18458)
* Add feature flag for milestone 2
* Fix test
* Remove OnPush
---
.../app/tools/send-v2/send-v2.component.html | 118 ++++++++++++++----
.../tools/send-v2/send-v2.component.spec.ts | 7 +-
.../app/tools/send-v2/send-v2.component.ts | 118 +++++++++++++++---
apps/desktop/src/scss/migration.scss | 29 +++++
apps/desktop/src/scss/styles.scss | 1 +
libs/common/src/enums/feature-flag.enum.ts | 2 +
6 files changed, 235 insertions(+), 40 deletions(-)
create mode 100644 apps/desktop/src/scss/migration.scss
diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.html b/apps/desktop/src/app/tools/send-v2/send-v2.component.html
index eda740fa721..dad0e541a4d 100644
--- a/apps/desktop/src/app/tools/send-v2/send-v2.component.html
+++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.html
@@ -1,25 +1,93 @@
-
-
- @if (!disableSend()) {
-
- }
-
-
-
-
-
+@if (useDrawerEditMode()) {
+ ;
let toastService: MockProxy;
let i18nService: MockProxy;
+ let configService: MockProxy;
beforeEach(async () => {
sendService = mock();
@@ -62,6 +63,10 @@ describe("SendV2Component", () => {
sendApiService = mock();
toastService = mock();
i18nService = mock();
+ configService = mock();
+
+ // Setup configService mock - feature flag returns true to test the new drawer mode
+ configService.getFeatureFlag$.mockReturnValue(of(true));
// Setup environmentService mock
environmentService.getEnvironment.mockResolvedValue({
@@ -117,7 +122,7 @@ describe("SendV2Component", () => {
useValue: mock(),
},
{ provide: MessagingService, useValue: mock() },
- { provide: ConfigService, useValue: mock() },
+ { provide: ConfigService, useValue: configService },
{
provide: ActivatedRoute,
useValue: {
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 7fab0cb6702..95c0c971d2c 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,11 +1,13 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
- ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
+ computed,
effect,
inject,
+ signal,
+ viewChild,
} from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { combineLatest, map, switchMap, lastValueFrom } from "rxjs";
@@ -15,6 +17,8 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -36,12 +40,27 @@ import {
import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service";
import { DesktopHeaderComponent } from "../../layout/header";
+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];
+
+// 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: [
JslibModule,
ButtonModule,
+ AddEditComponent,
SendListComponent,
NewSendDropdownV2Component,
DesktopHeaderComponent,
@@ -54,13 +73,19 @@ import { DesktopHeaderComponent } from "../../layout/header";
},
],
templateUrl: "./send-v2.component.html",
- changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendV2Component {
+ protected readonly addEditComponent = viewChild(AddEditComponent);
+
+ protected readonly sendId = signal(null);
+ protected readonly action = signal(Action.None);
+ private readonly selectedSendTypeOverride = signal(undefined);
+
private sendFormConfigService = inject(DefaultSendFormConfigService);
private sendItemsService = inject(SendItemsService);
private policyService = inject(PolicyService);
private accountService = inject(AccountService);
+ private configService = inject(ConfigService);
private i18nService = inject(I18nService);
private platformUtilsService = inject(PlatformUtilsService);
private environmentService = inject(EnvironmentService);
@@ -70,6 +95,11 @@ export class SendV2Component {
private logService = inject(LogService);
private cdr = inject(ChangeDetectorRef);
+ protected readonly useDrawerEditMode = toSignal(
+ this.configService.getFeatureFlag$(FeatureFlag.DesktopUiMigrationMilestone2),
+ { initialValue: false },
+ );
+
protected readonly filteredSends = toSignal(this.sendItemsService.filteredAndSortedSends$, {
initialValue: [],
});
@@ -119,28 +149,79 @@ export class SendV2Component {
});
}
+ protected readonly selectedSendType = computed(() => {
+ const action = this.action();
+ const typeOverride = this.selectedSendTypeOverride();
+
+ if (action === Action.Add && typeOverride !== undefined) {
+ return typeOverride;
+ }
+
+ const sendId = this.sendId();
+ return this.filteredSends().find((s) => s.id === sendId)?.type;
+ });
+
protected async addSend(type: SendType): Promise {
- const formConfig = await this.sendFormConfigService.buildConfig("add", undefined, type);
+ if (this.useDrawerEditMode()) {
+ const formConfig = await this.sendFormConfigService.buildConfig("add", undefined, type);
- const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
- formConfig,
- });
+ const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
+ formConfig,
+ });
- await lastValueFrom(dialogRef.closed);
+ await lastValueFrom(dialogRef.closed);
+ } else {
+ this.action.set(Action.Add);
+ this.sendId.set(null);
+ this.selectedSendTypeOverride.set(type);
+
+ const component = this.addEditComponent();
+ if (component) {
+ await component.resetAndLoad();
+ }
+ }
}
- protected async selectSend(sendId: SendId): Promise {
- const formConfig = await this.sendFormConfigService.buildConfig("edit", sendId);
+ /** Used by old UI to add a send without specifying type (defaults to Text) */
+ protected async addSendWithoutType(): Promise {
+ await this.addSend(SendType.Text);
+ }
- const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
- formConfig,
- });
+ protected closeEditPanel(): void {
+ this.action.set(Action.None);
+ this.sendId.set(null);
+ this.selectedSendTypeOverride.set(undefined);
+ }
- await lastValueFrom(dialogRef.closed);
+ protected async savedSend(send: SendView): Promise {
+ await this.selectSend(send.id);
+ }
+
+ protected async selectSend(sendId: string): Promise {
+ if (this.useDrawerEditMode()) {
+ const formConfig = await this.sendFormConfigService.buildConfig("edit", sendId as SendId);
+
+ const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
+ formConfig,
+ });
+
+ await lastValueFrom(dialogRef.closed);
+ } else {
+ if (sendId === this.sendId() && this.action() === Action.Edit) {
+ return;
+ }
+ this.action.set(Action.Edit);
+ this.sendId.set(sendId);
+ const component = this.addEditComponent();
+ if (component) {
+ component.sendId = sendId;
+ await component.refresh();
+ }
+ }
}
protected async onEditSend(send: SendView): Promise {
- await this.selectSend(send.id as SendId);
+ await this.selectSend(send.id);
}
protected async onCopySend(send: SendView): Promise {
@@ -176,6 +257,11 @@ export class SendV2Component {
title: null,
message: this.i18nService.t("removedPassword"),
});
+
+ if (!this.useDrawerEditMode() && this.sendId() === send.id) {
+ this.sendId.set(null);
+ await this.selectSend(send.id);
+ }
} catch (e) {
this.logService.error(e);
}
@@ -199,5 +285,9 @@ export class SendV2Component {
title: null,
message: this.i18nService.t("deletedSend"),
});
+
+ if (!this.useDrawerEditMode()) {
+ this.closeEditPanel();
+ }
}
}
diff --git a/apps/desktop/src/scss/migration.scss b/apps/desktop/src/scss/migration.scss
new file mode 100644
index 00000000000..ba70d4fa009
--- /dev/null
+++ b/apps/desktop/src/scss/migration.scss
@@ -0,0 +1,29 @@
+/**
+ * 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;
+ }
+}
+/**
+ * Send list panel styling for send-v2 component
+ * Temporary during migration - width handled by tw-w-2/5
+ **/
+.vault > .send-items-panel {
+ order: 2;
+ min-width: 200px;
+ border-right: 1px solid;
+
+ @include themify($themes) {
+ background-color: themed("backgroundColor");
+ border-right-color: themed("borderColor");
+ }
+}
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/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts
index 9f6beb5f81e..f82c095d45f 100644
--- a/libs/common/src/enums/feature-flag.enum.ts
+++ b/libs/common/src/enums/feature-flag.enum.ts
@@ -76,6 +76,7 @@ export enum FeatureFlag {
/* Desktop */
DesktopUiMigrationMilestone1 = "desktop-ui-migration-milestone-1",
+ DesktopUiMigrationMilestone2 = "desktop-ui-migration-milestone-2",
/* UIF */
RouterFocusManagement = "router-focus-management",
@@ -164,6 +165,7 @@ export const DefaultFeatureFlagValue = {
/* Desktop */
[FeatureFlag.DesktopUiMigrationMilestone1]: FALSE,
+ [FeatureFlag.DesktopUiMigrationMilestone2]: FALSE,
/* UIF */
[FeatureFlag.RouterFocusManagement]: FALSE,
From d0e3923eb602ad2133b315e4102269c3dc5ba204 Mon Sep 17 00:00:00 2001
From: Isaiah Inuwa
Date: Thu, 22 Jan 2026 05:58:37 -0600
Subject: [PATCH 13/24] Improve desktop autofill developer builds (#18334)
* Consolidate references to credential provider feature flag
* Adjust entitlements and build stuff for macOS autofill credential extension
* Reduce signature time for MAS builds
---
apps/desktop/electron-builder.json | 3 +-
.../autofill_extension_enabled.entitlements | 14 ++++
.../macos/desktop.xcodeproj/project.pbxproj | 4 +-
.../xcschemes/autofill-extension.xcscheme | 71 +++++++++++++++++++
apps/desktop/package.json | 2 +-
.../entitlements.mas.autofill-enabled.plist | 42 +++++++++++
apps/desktop/scripts/after-sign.js | 5 +-
.../services/desktop-autofill.service.ts | 42 ++++++-----
8 files changed, 161 insertions(+), 22 deletions(-)
create mode 100644 apps/desktop/macos/autofill-extension/autofill_extension_enabled.entitlements
create mode 100644 apps/desktop/macos/desktop.xcodeproj/xcshareddata/xcschemes/autofill-extension.xcscheme
create mode 100644 apps/desktop/resources/entitlements.mas.autofill-enabled.plist
diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json
index 83bd2921551..481d12f02b4 100644
--- a/apps/desktop/electron-builder.json
+++ b/apps/desktop/electron-builder.json
@@ -85,7 +85,8 @@
"signIgnore": [
"MacOS/desktop_proxy",
"MacOS/desktop_proxy.inherit",
- "Contents/Plugins/autofill-extension.appex"
+ "Contents/Plugins/autofill-extension.appex",
+ "Frameworks/Electron Framework.framework/(Electron Framework|Libraries|Resources|Versions/Current)/.*"
],
"target": ["dmg", "zip"]
},
diff --git a/apps/desktop/macos/autofill-extension/autofill_extension_enabled.entitlements b/apps/desktop/macos/autofill-extension/autofill_extension_enabled.entitlements
new file mode 100644
index 00000000000..49fda8f8af8
--- /dev/null
+++ b/apps/desktop/macos/autofill-extension/autofill_extension_enabled.entitlements
@@ -0,0 +1,14 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.application-groups
+
+ LTZ2PFU5D6.com.bitwarden.desktop
+
+ com.apple.developer.authentication-services.autofill-credential-provider
+
+
+
diff --git a/apps/desktop/macos/desktop.xcodeproj/project.pbxproj b/apps/desktop/macos/desktop.xcodeproj/project.pbxproj
index ed19fc9ef5d..c3cb34b6bea 100644
--- a/apps/desktop/macos/desktop.xcodeproj/project.pbxproj
+++ b/apps/desktop/macos/desktop.xcodeproj/project.pbxproj
@@ -256,7 +256,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D83832AD2D67B9D0003FB9F8 /* ReleaseDeveloper.xcconfig */;
buildSettings = {
- CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension.entitlements";
+ CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension_enabled.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer";
CODE_SIGN_STYLE = Manual;
@@ -409,7 +409,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D83832AB2D67B9AE003FB9F8 /* Debug.xcconfig */;
buildSettings = {
- CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension.entitlements";
+ CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension_enabled.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer";
CODE_SIGN_STYLE = Manual;
diff --git a/apps/desktop/macos/desktop.xcodeproj/xcshareddata/xcschemes/autofill-extension.xcscheme b/apps/desktop/macos/desktop.xcodeproj/xcshareddata/xcschemes/autofill-extension.xcscheme
new file mode 100644
index 00000000000..18357be4570
--- /dev/null
+++ b/apps/desktop/macos/desktop.xcodeproj/xcshareddata/xcschemes/autofill-extension.xcscheme
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index ad20e7c0e69..174f3a22a23 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -46,7 +46,7 @@
"pack:mac:with-extension": "npm run clean:dist && npm run build:macos-extension:mac && electron-builder --mac --universal -p never",
"pack:mac:arm64": "npm run clean:dist && electron-builder --mac --arm64 -p never",
"pack:mac:mas": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never",
- "pack:mac:masdev": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never",
+ "pack:mac:masdev": "npm run clean:dist && electron-builder --mac mas-dev --universal -p never -c.mac.identity=null -c.mas.identity=$CSC_NAME -c.mas.provisioningProfile=bitwarden_desktop_developer_id.provisionprofile -c.mas.entitlements=resources/entitlements.mas.autofill-enabled.plist",
"pack:local:mac": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never -c.mac.provisioningProfile=\"\" -c.mas.provisioningProfile=\"\"",
"pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never",
"pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never",
diff --git a/apps/desktop/resources/entitlements.mas.autofill-enabled.plist b/apps/desktop/resources/entitlements.mas.autofill-enabled.plist
new file mode 100644
index 00000000000..f25780e5c12
--- /dev/null
+++ b/apps/desktop/resources/entitlements.mas.autofill-enabled.plist
@@ -0,0 +1,42 @@
+
+
+
+
+ com.apple.application-identifier
+ LTZ2PFU5D6.com.bitwarden.desktop
+ com.apple.developer.team-identifier
+ LTZ2PFU5D6
+ com.apple.security.app-sandbox
+
+ com.apple.security.application-groups
+
+ LTZ2PFU5D6.com.bitwarden.desktop
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.device.usb
+
+ com.apple.security.files.user-selected.read-write
+
+ com.apple.security.network.client
+
+ com.apple.security.temporary-exception.files.home-relative-path.read-write
+
+ /Library/Application Support/Mozilla/NativeMessagingHosts/
+ /Library/Application Support/Google/Chrome/NativeMessagingHosts/
+ /Library/Application Support/Google/Chrome Beta/NativeMessagingHosts/
+ /Library/Application Support/Google/Chrome Dev/NativeMessagingHosts/
+ /Library/Application Support/Google/Chrome Canary/NativeMessagingHosts/
+ /Library/Application Support/Chromium/NativeMessagingHosts/
+ /Library/Application Support/Microsoft Edge/NativeMessagingHosts/
+ /Library/Application Support/Microsoft Edge Beta/NativeMessagingHosts/
+ /Library/Application Support/Microsoft Edge Dev/NativeMessagingHosts/
+ /Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/
+ /Library/Application Support/Vivaldi/NativeMessagingHosts/
+ /Library/Application Support/Zen/NativeMessagingHosts/
+ /Library/Application Support/net.imput.helium
+
+ com.apple.developer.authentication-services.autofill-credential-provider
+
+
+
diff --git a/apps/desktop/scripts/after-sign.js b/apps/desktop/scripts/after-sign.js
index 4275ec7d051..0e0e22fc24a 100644
--- a/apps/desktop/scripts/after-sign.js
+++ b/apps/desktop/scripts/after-sign.js
@@ -16,7 +16,9 @@ async function run(context) {
const appPath = `${context.appOutDir}/${appName}.app`;
const macBuild = context.electronPlatformName === "darwin";
const copySafariExtension = ["darwin", "mas"].includes(context.electronPlatformName);
- const copyAutofillExtension = ["darwin"].includes(context.electronPlatformName); // Disabled for mas builds
+ const isMasDevBuild =
+ context.electronPlatformName === "mas" && context.targets.at(0)?.name === "mas-dev";
+ const copyAutofillExtension = ["darwin"].includes(context.electronPlatformName) || isMasDevBuild;
let shouldResign = false;
@@ -31,7 +33,6 @@ async function run(context) {
fse.mkdirSync(path.join(appPath, "Contents/PlugIns"));
}
fse.copySync(extensionPath, path.join(appPath, "Contents/PlugIns/autofill-extension.appex"));
- shouldResign = true;
}
}
diff --git a/apps/desktop/src/autofill/services/desktop-autofill.service.ts b/apps/desktop/src/autofill/services/desktop-autofill.service.ts
index c50964e31e3..e5cd85aa7a3 100644
--- a/apps/desktop/src/autofill/services/desktop-autofill.service.ts
+++ b/apps/desktop/src/autofill/services/desktop-autofill.service.ts
@@ -10,6 +10,7 @@ import {
mergeMap,
switchMap,
takeUntil,
+ tap,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -52,6 +53,8 @@ import type { NativeWindowObject } from "./desktop-fido2-user-interface.service"
export class DesktopAutofillService implements OnDestroy {
private destroy$ = new Subject();
private registrationRequest: autofill.PasskeyRegistrationRequest;
+ private featureFlag?: FeatureFlag;
+ private isEnabled: boolean = false;
constructor(
private logService: LogService,
@@ -60,19 +63,26 @@ export class DesktopAutofillService implements OnDestroy {
private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction,
private accountService: AccountService,
private authService: AuthService,
- private platformUtilsService: PlatformUtilsService,
- ) {}
+ platformUtilsService: PlatformUtilsService,
+ ) {
+ const deviceType = platformUtilsService.getDevice();
+ if (deviceType === DeviceType.MacOsDesktop) {
+ this.featureFlag = FeatureFlag.MacOsNativeCredentialSync;
+ }
+ }
async init() {
- // Currently only supported for MacOS
- if (this.platformUtilsService.getDevice() !== DeviceType.MacOsDesktop) {
+ this.isEnabled =
+ this.featureFlag && (await this.configService.getFeatureFlag(this.featureFlag));
+ if (!this.isEnabled) {
return;
}
this.configService
- .getFeatureFlag$(FeatureFlag.MacOsNativeCredentialSync)
+ .getFeatureFlag$(this.featureFlag)
.pipe(
distinctUntilChanged(),
+ tap((enabled) => (this.isEnabled = enabled)),
filter((enabled) => enabled === true), // Only proceed if feature is enabled
switchMap(() => {
return combineLatest([
@@ -199,11 +209,11 @@ export class DesktopAutofillService implements OnDestroy {
listenIpc() {
ipc.autofill.listenPasskeyRegistration(async (clientId, sequenceNumber, request, callback) => {
- if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
+ if (!this.isEnabled) {
this.logService.debug(
- "listenPasskeyRegistration: MacOsNativeCredentialSync feature flag is disabled",
+ `listenPasskeyRegistration: Native credential sync feature flag (${this.featureFlag}) is disabled`,
);
- callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
+ callback(new Error("Native credential sync feature flag is disabled"), null);
return;
}
@@ -230,11 +240,11 @@ export class DesktopAutofillService implements OnDestroy {
ipc.autofill.listenPasskeyAssertionWithoutUserInterface(
async (clientId, sequenceNumber, request, callback) => {
- if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
+ if (!this.isEnabled) {
this.logService.debug(
- "listenPasskeyAssertionWithoutUserInterface: MacOsNativeCredentialSync feature flag is disabled",
+ `listenPasskeyAssertionWithoutUserInterface: Native credential sync feature flag (${this.featureFlag}) is disabled`,
);
- callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
+ callback(new Error("Native credential sync feature flag is disabled"), null);
return;
}
@@ -297,11 +307,11 @@ export class DesktopAutofillService implements OnDestroy {
);
ipc.autofill.listenPasskeyAssertion(async (clientId, sequenceNumber, request, callback) => {
- if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
+ if (!this.isEnabled) {
this.logService.debug(
- "listenPasskeyAssertion: MacOsNativeCredentialSync feature flag is disabled",
+ `listenPasskeyAssertion: Native credential sync feature flag (${this.featureFlag}) is disabled`,
);
- callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
+ callback(new Error("Native credential sync feature flag is disabled"), null);
return;
}
@@ -324,9 +334,9 @@ export class DesktopAutofillService implements OnDestroy {
// Listen for native status messages
ipc.autofill.listenNativeStatus(async (clientId, sequenceNumber, status) => {
- if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
+ if (!this.isEnabled) {
this.logService.debug(
- "listenNativeStatus: MacOsNativeCredentialSync feature flag is disabled",
+ `listenNativeStatus: Native credential sync feature flag (${this.featureFlag}) is disabled`,
);
return;
}
From 1ccacb03a6e511949a3b4dd9e9474af8b5103285 Mon Sep 17 00:00:00 2001
From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>
Date: Thu, 22 Jan 2026 13:01:49 +0100
Subject: [PATCH 14/24] [PM-27233] Support v2 encryption for JIT Password
signups (#18222)
* Support v2 encryption for JIT Password signups
* TDE set master password split
* update sdk-internal dependency
* moved encryption v2 to InitializeJitPasswordUserService
* remove account cryptographic state legacy states from #18164
* legacy state comments
* sdk update
* unit test coverage
* consolidate do SetInitialPasswordService
* replace legacy master key with setLegacyMasterKeyFromUnlockData
* typo
* web and desktop overrides with unit tests
* early return
* compact validation
* simplify super prototype
---
.../src/app/services/services.module.ts | 2 +
...sktop-set-initial-password.service.spec.ts | 43 ++-
.../desktop-set-initial-password.service.ts | 13 +
.../web-set-initial-password.service.spec.ts | 40 ++-
.../web-set-initial-password.service.ts | 15 +
apps/web/src/app/core/core.module.ts | 24 +-
...initial-password.service.implementation.ts | 215 +++++++++++---
...fault-set-initial-password.service.spec.ts | 273 +++++++++++++++++-
.../set-initial-password.component.ts | 113 ++++++--
...et-initial-password.service.abstraction.ts | 30 +-
.../src/services/jslib-services.module.ts | 13 +-
libs/common/src/enums/feature-flag.enum.ts | 2 +
package-lock.json | 16 +-
package.json | 4 +-
14 files changed, 693 insertions(+), 110 deletions(-)
diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts
index a5a91c52e7e..66613efd115 100644
--- a/apps/desktop/src/app/services/services.module.ts
+++ b/apps/desktop/src/app/services/services.module.ts
@@ -89,6 +89,7 @@ import {
PlatformUtilsService,
PlatformUtilsService as PlatformUtilsServiceAbstraction,
} from "@bitwarden/common/platform/abstractions/platform-utils.service";
+import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
@@ -432,6 +433,7 @@ const safeProviders: SafeProvider[] = [
InternalUserDecryptionOptionsServiceAbstraction,
MessagingServiceAbstraction,
AccountCryptographicStateService,
+ RegisterSdkService,
],
}),
safeProvider({
diff --git a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts
index 6b29a464e2c..9bb7d5077cf 100644
--- a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts
+++ b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts
@@ -1,8 +1,10 @@
-import { MockProxy, mock } from "jest-mock-extended";
+import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
+import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation";
import {
+ InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordUserType,
@@ -19,12 +21,14 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
+import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
+import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
-import { UserId } from "@bitwarden/common/types/guid";
+import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
@@ -45,6 +49,7 @@ describe("DesktopSetInitialPasswordService", () => {
let userDecryptionOptionsService: MockProxy;
let messagingService: MockProxy;
let accountCryptographicStateService: MockProxy;
+ let registerSdkService: MockProxy;
beforeEach(() => {
apiService = mock();
@@ -59,6 +64,7 @@ describe("DesktopSetInitialPasswordService", () => {
userDecryptionOptionsService = mock();
messagingService = mock();
accountCryptographicStateService = mock();
+ registerSdkService = mock();
sut = new DesktopSetInitialPasswordService(
apiService,
@@ -73,6 +79,7 @@ describe("DesktopSetInitialPasswordService", () => {
userDecryptionOptionsService,
messagingService,
accountCryptographicStateService,
+ registerSdkService,
);
});
@@ -179,4 +186,36 @@ describe("DesktopSetInitialPasswordService", () => {
});
});
});
+
+ describe("initializePasswordJitPasswordUserV2Encryption(...)", () => {
+ it("should send a 'redrawMenu' message", async () => {
+ // Arrange
+ const credentials: InitializeJitPasswordCredentials = {
+ newPasswordHint: "newPasswordHint",
+ orgSsoIdentifier: "orgSsoIdentifier",
+ orgId: "orgId" as OrganizationId,
+ resetPasswordAutoEnroll: false,
+ newPassword: "newPassword123!",
+ salt: "user@example.com" as MasterPasswordSalt,
+ };
+ const userId = "userId" as UserId;
+
+ const superSpy = jest
+ .spyOn(
+ DefaultSetInitialPasswordService.prototype,
+ "initializePasswordJitPasswordUserV2Encryption",
+ )
+ .mockResolvedValue(undefined);
+
+ // Act
+ await sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
+
+ // Assert
+ expect(superSpy).toHaveBeenCalledWith(credentials, userId);
+ expect(messagingService.send).toHaveBeenCalledTimes(1);
+ expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
+
+ superSpy.mockRestore();
+ });
+ });
});
diff --git a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts
index cedfa3fe589..f9fb8361056 100644
--- a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts
+++ b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts
@@ -1,6 +1,7 @@
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation";
import {
+ InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordUserType,
@@ -14,6 +15,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
+import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { UserId } from "@bitwarden/common/types/guid";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
@@ -34,6 +36,7 @@ export class DesktopSetInitialPasswordService
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
private messagingService: MessagingService,
protected accountCryptographicStateService: AccountCryptographicStateService,
+ protected registerSdkService: RegisterSdkService,
) {
super(
apiService,
@@ -47,6 +50,7 @@ export class DesktopSetInitialPasswordService
organizationUserApiService,
userDecryptionOptionsService,
accountCryptographicStateService,
+ registerSdkService,
);
}
@@ -59,4 +63,13 @@ export class DesktopSetInitialPasswordService
this.messagingService.send("redrawMenu");
}
+
+ override async initializePasswordJitPasswordUserV2Encryption(
+ credentials: InitializeJitPasswordCredentials,
+ userId: UserId,
+ ): Promise {
+ await super.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
+
+ this.messagingService.send("redrawMenu");
+ }
}
diff --git a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts
index 1bbaa0ec236..b09b5f0bc9a 100644
--- a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts
+++ b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts
@@ -3,6 +3,7 @@ import { BehaviorSubject, of } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import {
+ InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordUserType,
@@ -20,11 +21,13 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
+import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
-import { UserId } from "@bitwarden/common/types/guid";
+import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { RouterService } from "@bitwarden/web-vault/app/core";
@@ -47,6 +50,7 @@ describe("WebSetInitialPasswordService", () => {
let organizationInviteService: MockProxy;
let routerService: MockProxy;
let accountCryptographicStateService: MockProxy;
+ let registerSdkService: MockProxy;
beforeEach(() => {
apiService = mock();
@@ -62,6 +66,7 @@ describe("WebSetInitialPasswordService", () => {
organizationInviteService = mock();
routerService = mock();
accountCryptographicStateService = mock();
+ registerSdkService = mock();
sut = new WebSetInitialPasswordService(
apiService,
@@ -77,6 +82,7 @@ describe("WebSetInitialPasswordService", () => {
organizationInviteService,
routerService,
accountCryptographicStateService,
+ registerSdkService,
);
});
@@ -208,4 +214,36 @@ describe("WebSetInitialPasswordService", () => {
});
});
});
+
+ describe("initializePasswordJitPasswordUserV2Encryption(...)", () => {
+ it("should call routerService.getAndClearLoginRedirectUrl() and organizationInviteService.clearOrganizationInvitation()", async () => {
+ // Arrange
+ const credentials: InitializeJitPasswordCredentials = {
+ newPasswordHint: "newPasswordHint",
+ orgSsoIdentifier: "orgSsoIdentifier",
+ orgId: "orgId" as OrganizationId,
+ resetPasswordAutoEnroll: false,
+ newPassword: "newPassword123!",
+ salt: "user@example.com" as MasterPasswordSalt,
+ };
+ const userId = "userId" as UserId;
+
+ const superSpy = jest
+ .spyOn(
+ Object.getPrototypeOf(Object.getPrototypeOf(sut)),
+ "initializePasswordJitPasswordUserV2Encryption",
+ )
+ .mockResolvedValue(undefined);
+
+ // Act
+ await sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
+
+ // Assert
+ expect(superSpy).toHaveBeenCalledWith(credentials, userId);
+ expect(routerService.getAndClearLoginRedirectUrl).toHaveBeenCalledTimes(1);
+ expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalledTimes(1);
+
+ superSpy.mockRestore();
+ });
+ });
});
diff --git a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts
index 303b9148e8e..0b8dba6c40e 100644
--- a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts
+++ b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts
@@ -1,6 +1,7 @@
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation";
import {
+ InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordUserType,
@@ -14,6 +15,7 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { UserId } from "@bitwarden/common/types/guid";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { RouterService } from "@bitwarden/web-vault/app/core";
@@ -36,6 +38,7 @@ export class WebSetInitialPasswordService
private organizationInviteService: OrganizationInviteService,
private routerService: RouterService,
protected accountCryptographicStateService: AccountCryptographicStateService,
+ protected registerSdkService: RegisterSdkService,
) {
super(
apiService,
@@ -49,6 +52,7 @@ export class WebSetInitialPasswordService
organizationUserApiService,
userDecryptionOptionsService,
accountCryptographicStateService,
+ registerSdkService,
);
}
@@ -83,4 +87,15 @@ export class WebSetInitialPasswordService
await this.routerService.getAndClearLoginRedirectUrl();
await this.organizationInviteService.clearOrganizationInvitation();
}
+
+ override async initializePasswordJitPasswordUserV2Encryption(
+ credentials: InitializeJitPasswordCredentials,
+ userId: UserId,
+ ): Promise {
+ await super.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
+
+ // TODO: Investigate refactoring the following logic in https://bitwarden.atlassian.net/browse/PM-22615
+ await this.routerService.getAndClearLoginRedirectUrl();
+ await this.organizationInviteService.clearOrganizationInvitation();
+ }
}
diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts
index 661d14502fe..7b248eee8a3 100644
--- a/apps/web/src/app/core/core.module.ts
+++ b/apps/web/src/app/core/core.module.ts
@@ -6,11 +6,11 @@ import { Router } from "@angular/router";
import {
CollectionAdminService,
- DefaultCollectionAdminService,
- OrganizationUserApiService,
CollectionService,
- OrganizationUserService,
+ DefaultCollectionAdminService,
DefaultOrganizationUserService,
+ OrganizationUserApiService,
+ OrganizationUserService,
} from "@bitwarden/admin-console/common";
import { DefaultDeviceManagementComponentService } from "@bitwarden/angular/auth/device-management/default-device-management-component.service";
import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction";
@@ -27,17 +27,17 @@ import {
OBSERVABLE_DISK_LOCAL_STORAGE,
OBSERVABLE_DISK_STORAGE,
OBSERVABLE_MEMORY_STORAGE,
+ SafeInjectionToken,
SECURE_STORAGE,
SYSTEM_LANGUAGE,
- SafeInjectionToken,
WINDOW,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import {
- RegistrationFinishService as RegistrationFinishServiceAbstraction,
LoginComponentService,
- SsoComponentService,
LoginDecryptionOptionsService,
+ RegistrationFinishService as RegistrationFinishServiceAbstraction,
+ SsoComponentService,
TwoFactorAuthDuoComponentService,
} from "@bitwarden/auth/angular";
import {
@@ -90,6 +90,7 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
+import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
@@ -120,9 +121,9 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { GeneratorServicesModule } from "@bitwarden/generator-components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import {
+ BiometricsService,
KdfConfigService,
KeyService as KeyServiceAbstraction,
- BiometricsService,
} from "@bitwarden/key-management";
import {
LockComponentService,
@@ -135,17 +136,17 @@ import { WebVaultPremiumUpgradePromptService } from "@bitwarden/web-vault/app/va
import { flagEnabled } from "../../utils/flags";
import {
- POLICY_EDIT_REGISTER,
ossPolicyEditRegister,
+ POLICY_EDIT_REGISTER,
} from "../admin-console/organizations/policies";
import {
+ LinkSsoService,
WebChangePasswordService,
- WebRegistrationFinishService,
WebLoginComponentService,
WebLoginDecryptionOptionsService,
- WebTwoFactorAuthDuoComponentService,
- LinkSsoService,
+ WebRegistrationFinishService,
WebSetInitialPasswordService,
+ WebTwoFactorAuthDuoComponentService,
} from "../auth";
import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service";
import { WebPremiumInterestStateService } from "../billing/services/premium-interest/web-premium-interest-state.service";
@@ -320,6 +321,7 @@ const safeProviders: SafeProvider[] = [
OrganizationInviteService,
RouterService,
AccountCryptographicStateService,
+ RegisterSdkService,
],
}),
safeProvider({
diff --git a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts
index 2f5c43e2db9..3f6023c1205 100644
--- a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts
+++ b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts
@@ -1,4 +1,4 @@
-import { firstValueFrom } from "rxjs";
+import { concatMap, firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
@@ -19,19 +19,32 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
-import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
+import {
+ MasterPasswordSalt,
+ MasterPasswordUnlockData,
+} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
+import { asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
+import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
-import { KdfConfigService, KeyService, KdfConfig } from "@bitwarden/key-management";
+import {
+ fromSdkKdfConfig,
+ KdfConfig,
+ KdfConfigService,
+ KeyService,
+} from "@bitwarden/key-management";
+import { OrganizationId as SdkOrganizationId, UserId as SdkUserId } from "@bitwarden/sdk-internal";
import {
- SetInitialPasswordService,
+ InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
- SetInitialPasswordUserType,
+ SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
+ SetInitialPasswordUserType,
} from "./set-initial-password.service.abstraction";
export class DefaultSetInitialPasswordService implements SetInitialPasswordService {
@@ -47,6 +60,7 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
protected organizationUserApiService: OrganizationUserApiService,
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
protected accountCryptographicStateService: AccountCryptographicStateService,
+ protected registerSdkService: RegisterSdkService,
) {}
async setInitialPassword(
@@ -199,6 +213,126 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
}
}
+ async setInitialPasswordTdeOffboarding(
+ credentials: SetInitialPasswordTdeOffboardingCredentials,
+ userId: UserId,
+ ) {
+ const { newMasterKey, newServerMasterKeyHash, newPasswordHint } = credentials;
+ for (const [key, value] of Object.entries(credentials)) {
+ if (value == null) {
+ throw new Error(`${key} not found. Could not set password.`);
+ }
+ }
+
+ if (userId == null) {
+ throw new Error("userId not found. Could not set password.");
+ }
+
+ const userKey = await firstValueFrom(this.keyService.userKey$(userId));
+ if (userKey == null) {
+ throw new Error("userKey not found. Could not set password.");
+ }
+
+ const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
+ newMasterKey,
+ userKey,
+ );
+
+ if (!newMasterKeyEncryptedUserKey[1].encryptedString) {
+ throw new Error("newMasterKeyEncryptedUserKey not found. Could not set password.");
+ }
+
+ const request = new UpdateTdeOffboardingPasswordRequest();
+ request.key = newMasterKeyEncryptedUserKey[1].encryptedString;
+ request.newMasterPasswordHash = newServerMasterKeyHash;
+ request.masterPasswordHint = newPasswordHint;
+
+ await this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request);
+
+ // Clear force set password reason to allow navigation back to vault.
+ await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
+ }
+
+ async initializePasswordJitPasswordUserV2Encryption(
+ credentials: InitializeJitPasswordCredentials,
+ userId: UserId,
+ ): Promise {
+ if (userId == null) {
+ throw new Error("User ID is required.");
+ }
+
+ for (const [key, value] of Object.entries(credentials)) {
+ if (value == null) {
+ throw new Error(`${key} is required.`);
+ }
+ }
+
+ const { newPasswordHint, orgSsoIdentifier, orgId, resetPasswordAutoEnroll, newPassword, salt } =
+ credentials;
+
+ const organizationKeys = await this.organizationApiService.getKeys(orgId);
+ if (organizationKeys == null) {
+ throw new Error("Organization keys response is null.");
+ }
+
+ const registerResult = await firstValueFrom(
+ this.registerSdkService.registerClient$(userId).pipe(
+ concatMap(async (sdk) => {
+ if (!sdk) {
+ throw new Error("SDK not available");
+ }
+
+ using ref = sdk.take();
+ return await ref.value
+ .auth()
+ .registration()
+ .post_keys_for_jit_password_registration({
+ org_id: asUuid(orgId),
+ org_public_key: organizationKeys.publicKey,
+ master_password: newPassword,
+ master_password_hint: newPasswordHint,
+ salt: salt,
+ organization_sso_identifier: orgSsoIdentifier,
+ user_id: asUuid(userId),
+ reset_password_enroll: resetPasswordAutoEnroll,
+ });
+ }),
+ ),
+ );
+
+ if (!("V2" in registerResult.account_cryptographic_state)) {
+ throw new Error("Unexpected V2 account cryptographic state");
+ }
+
+ // Note: When SDK state management matures, these should be moved into post_keys_for_tde_registration
+ // Set account cryptography state
+ await this.accountCryptographicStateService.setAccountCryptographicState(
+ registerResult.account_cryptographic_state,
+ userId,
+ );
+
+ // Clear force set password reason to allow navigation back to vault.
+ await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
+
+ const masterPasswordUnlockData = MasterPasswordUnlockData.fromSdk(
+ registerResult.master_password_unlock,
+ );
+ await this.masterPasswordService.setMasterPasswordUnlockData(masterPasswordUnlockData, userId);
+
+ await this.keyService.setUserKey(
+ SymmetricCryptoKey.fromString(registerResult.user_key) as UserKey,
+ userId,
+ );
+
+ await this.updateLegacyState(
+ newPassword,
+ fromSdkKdfConfig(registerResult.master_password_unlock.kdf),
+ new EncString(registerResult.master_password_unlock.masterKeyWrappedUserKey),
+ userId,
+ masterPasswordUnlockData,
+ );
+ }
+
private async makeMasterKeyEncryptedUserKey(
masterKey: MasterKey,
userId: UserId,
@@ -244,6 +378,37 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
await this.keyService.setUserKey(masterKeyEncryptedUserKey[0], userId);
}
+ // Deprecated legacy support - to be removed in future
+ private async updateLegacyState(
+ newPassword: string,
+ kdfConfig: KdfConfig,
+ masterKeyWrappedUserKey: EncString,
+ userId: UserId,
+ masterPasswordUnlockData: MasterPasswordUnlockData,
+ ) {
+ // TODO Remove HasMasterPassword from UserDecryptionOptions https://bitwarden.atlassian.net/browse/PM-23475
+ const userDecryptionOpts = await firstValueFrom(
+ this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
+ );
+ userDecryptionOpts.hasMasterPassword = true;
+ await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
+ userId,
+ userDecryptionOpts,
+ );
+
+ // TODO Remove KDF state https://bitwarden.atlassian.net/browse/PM-30661
+ await this.kdfConfigService.setKdfConfig(userId, kdfConfig);
+ // TODO Remove master key memory state https://bitwarden.atlassian.net/browse/PM-23477
+ await this.masterPasswordService.setMasterKeyEncryptedUserKey(masterKeyWrappedUserKey, userId);
+
+ // TODO Removed with https://bitwarden.atlassian.net/browse/PM-30676
+ await this.masterPasswordService.setLegacyMasterKeyFromUnlockData(
+ newPassword,
+ masterPasswordUnlockData,
+ userId,
+ );
+ }
+
/**
* As part of [PM-28494], adding this setting path to accommodate the changes that are
* emerging with pm-23246-unlock-with-master-password-unlock-data.
@@ -310,44 +475,4 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
enrollmentRequest,
);
}
-
- async setInitialPasswordTdeOffboarding(
- credentials: SetInitialPasswordTdeOffboardingCredentials,
- userId: UserId,
- ) {
- const { newMasterKey, newServerMasterKeyHash, newPasswordHint } = credentials;
- for (const [key, value] of Object.entries(credentials)) {
- if (value == null) {
- throw new Error(`${key} not found. Could not set password.`);
- }
- }
-
- if (userId == null) {
- throw new Error("userId not found. Could not set password.");
- }
-
- const userKey = await firstValueFrom(this.keyService.userKey$(userId));
- if (userKey == null) {
- throw new Error("userKey not found. Could not set password.");
- }
-
- const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
- newMasterKey,
- userKey,
- );
-
- if (!newMasterKeyEncryptedUserKey[1].encryptedString) {
- throw new Error("newMasterKeyEncryptedUserKey not found. Could not set password.");
- }
-
- const request = new UpdateTdeOffboardingPasswordRequest();
- request.key = newMasterKeyEncryptedUserKey[1].encryptedString;
- request.newMasterPasswordHash = newServerMasterKeyHash;
- request.masterPasswordHint = newPasswordHint;
-
- await this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request);
-
- // Clear force set password reason to allow navigation back to vault.
- await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
- }
}
diff --git a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts
index af4505371d3..6b3981a5231 100644
--- a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts
+++ b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts
@@ -1,5 +1,8 @@
-import { MockProxy, mock } from "jest-mock-extended";
-import { BehaviorSubject, of } from "rxjs";
+// Polyfill for Symbol.dispose required by the service's use of `using` keyword
+import "core-js/proposals/explicit-resource-management";
+
+import { mock, MockProxy } from "jest-mock-extended";
+import { BehaviorSubject, Observable, of } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
@@ -27,17 +30,35 @@ import {
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
+import {
+ MasterPasswordSalt,
+ MasterPasswordUnlockData,
+} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
+import { Rc } from "@bitwarden/common/platform/misc/reference-counting/rc";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
+import { makeEncString, makeSymmetricCryptoKey } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
-import { UserId } from "@bitwarden/common/types/guid";
+import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey, UserPrivateKey, UserPublicKey } from "@bitwarden/common/types/key";
-import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
+import {
+ DEFAULT_KDF_CONFIG,
+ fromSdkKdfConfig,
+ KdfConfigService,
+ KeyService,
+} from "@bitwarden/key-management";
+import {
+ AuthClient,
+ BitwardenClient,
+ WrappedAccountCryptographicState,
+} from "@bitwarden/sdk-internal";
import { DefaultSetInitialPasswordService } from "./default-set-initial-password.service.implementation";
import {
+ InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
@@ -58,6 +79,7 @@ describe("DefaultSetInitialPasswordService", () => {
let organizationUserApiService: MockProxy;
let userDecryptionOptionsService: MockProxy;
let accountCryptographicStateService: MockProxy;
+ const registerSdkService = mock();
let userId: UserId;
let userKey: UserKey;
@@ -94,6 +116,7 @@ describe("DefaultSetInitialPasswordService", () => {
organizationUserApiService,
userDecryptionOptionsService,
accountCryptographicStateService,
+ registerSdkService,
);
});
@@ -834,4 +857,246 @@ describe("DefaultSetInitialPasswordService", () => {
});
});
});
+
+ describe("initializePasswordJitPasswordUserV2Encryption()", () => {
+ let mockSdkRef: {
+ value: MockProxy;
+ [Symbol.dispose]: jest.Mock;
+ };
+ let mockSdk: {
+ take: jest.Mock;
+ };
+ let mockRegistration: jest.Mock;
+
+ const userId = "d4e2e3a1-1b5e-4c3b-8d7a-9f8e7d6c5b4a" as UserId;
+ const orgId = "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d" as OrganizationId;
+
+ const credentials: InitializeJitPasswordCredentials = {
+ newPasswordHint: "test-hint",
+ orgSsoIdentifier: "org-sso-id",
+ orgId: orgId,
+ resetPasswordAutoEnroll: false,
+ newPassword: "Test@Password123!",
+ salt: "user@example.com" as unknown as MasterPasswordSalt,
+ };
+
+ const orgKeys: OrganizationKeysResponse = {
+ publicKey: "org-public-key-base64",
+ privateKey: "org-private-key-encrypted",
+ } as OrganizationKeysResponse;
+
+ const sdkRegistrationResult = {
+ account_cryptographic_state: {
+ V2: {
+ private_key: makeEncString().encryptedString!,
+ signed_public_key: "test-signed-public-key",
+ signing_key: makeEncString().encryptedString!,
+ security_state: "test-security-state",
+ },
+ },
+ master_password_unlock: {
+ kdf: {
+ pBKDF2: {
+ iterations: 600000,
+ },
+ },
+ masterKeyWrappedUserKey: makeEncString().encryptedString!,
+ salt: "user@example.com" as unknown as MasterPasswordSalt,
+ },
+ user_key: makeSymmetricCryptoKey(64).keyB64,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockSdkRef = {
+ value: mock(),
+ [Symbol.dispose]: jest.fn(),
+ };
+
+ mockSdkRef.value.auth.mockReturnValue({
+ registration: jest.fn().mockReturnValue({
+ post_keys_for_jit_password_registration: jest.fn(),
+ }),
+ } as unknown as AuthClient);
+
+ mockSdk = {
+ take: jest.fn().mockReturnValue(mockSdkRef),
+ };
+
+ registerSdkService.registerClient$.mockReturnValue(
+ of(mockSdk) as unknown as Observable>,
+ );
+
+ organizationApiService.getKeys.mockResolvedValue(orgKeys);
+
+ mockRegistration = mockSdkRef.value.auth().registration()
+ .post_keys_for_jit_password_registration as unknown as jest.Mock;
+ mockRegistration.mockResolvedValue(sdkRegistrationResult);
+
+ const mockUserDecryptionOpts = new UserDecryptionOptions({ hasMasterPassword: false });
+ userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
+ of(mockUserDecryptionOpts),
+ );
+ });
+
+ it("should successfully initialize JIT password user", async () => {
+ await sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
+
+ expect(organizationApiService.getKeys).toHaveBeenCalledWith(credentials.orgId);
+
+ expect(registerSdkService.registerClient$).toHaveBeenCalledWith(userId);
+ expect(mockRegistration).toHaveBeenCalledWith(
+ expect.objectContaining({
+ org_id: credentials.orgId,
+ org_public_key: orgKeys.publicKey,
+ master_password: credentials.newPassword,
+ master_password_hint: credentials.newPasswordHint,
+ salt: credentials.salt,
+ organization_sso_identifier: credentials.orgSsoIdentifier,
+ user_id: userId,
+ reset_password_enroll: credentials.resetPasswordAutoEnroll,
+ }),
+ );
+
+ expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
+ sdkRegistrationResult.account_cryptographic_state,
+ userId,
+ );
+
+ expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
+ ForceSetPasswordReason.None,
+ userId,
+ );
+
+ expect(masterPasswordService.setMasterPasswordUnlockData).toHaveBeenCalledWith(
+ MasterPasswordUnlockData.fromSdk(sdkRegistrationResult.master_password_unlock),
+ userId,
+ );
+
+ expect(keyService.setUserKey).toHaveBeenCalledWith(
+ SymmetricCryptoKey.fromString(sdkRegistrationResult.user_key) as UserKey,
+ userId,
+ );
+
+ // Verify legacy state updates below
+ expect(userDecryptionOptionsService.userDecryptionOptionsById$).toHaveBeenCalledWith(userId);
+ expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
+ userId,
+ expect.objectContaining({ hasMasterPassword: true }),
+ );
+
+ expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(
+ userId,
+ fromSdkKdfConfig(sdkRegistrationResult.master_password_unlock.kdf),
+ );
+
+ expect(masterPasswordService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
+ new EncString(sdkRegistrationResult.master_password_unlock.masterKeyWrappedUserKey),
+ userId,
+ );
+
+ expect(masterPasswordService.setLegacyMasterKeyFromUnlockData).toHaveBeenCalledWith(
+ credentials.newPassword,
+ MasterPasswordUnlockData.fromSdk(sdkRegistrationResult.master_password_unlock),
+ userId,
+ );
+ });
+
+ describe("input validation", () => {
+ it.each([
+ "newPasswordHint",
+ "orgSsoIdentifier",
+ "orgId",
+ "resetPasswordAutoEnroll",
+ "newPassword",
+ "salt",
+ ])("should throw error when %s is null", async (field) => {
+ const invalidCredentials = {
+ ...credentials,
+ [field]: null,
+ } as unknown as InitializeJitPasswordCredentials;
+
+ const promise = sut.initializePasswordJitPasswordUserV2Encryption(
+ invalidCredentials,
+ userId,
+ );
+
+ await expect(promise).rejects.toThrow(`${field} is required.`);
+
+ expect(organizationApiService.getKeys).not.toHaveBeenCalled();
+ expect(registerSdkService.registerClient$).not.toHaveBeenCalled();
+ });
+
+ it("should throw error when userId is null", async () => {
+ const nullUserId = null as unknown as UserId;
+
+ const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, nullUserId);
+
+ await expect(promise).rejects.toThrow("User ID is required.");
+ expect(organizationApiService.getKeys).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("organization API error handling", () => {
+ it("should throw when organizationApiService.getKeys returns null", async () => {
+ organizationApiService.getKeys.mockResolvedValue(
+ null as unknown as OrganizationKeysResponse,
+ );
+
+ const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
+
+ await expect(promise).rejects.toThrow("Organization keys response is null.");
+ expect(organizationApiService.getKeys).toHaveBeenCalledWith(credentials.orgId);
+ expect(registerSdkService.registerClient$).not.toHaveBeenCalled();
+ });
+
+ it("should throw when organizationApiService.getKeys rejects", async () => {
+ const apiError = new Error("API network error");
+ organizationApiService.getKeys.mockRejectedValue(apiError);
+
+ const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
+
+ await expect(promise).rejects.toThrow("API network error");
+ expect(registerSdkService.registerClient$).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("SDK error handling", () => {
+ it("should throw when SDK is not available", async () => {
+ organizationApiService.getKeys.mockResolvedValue(orgKeys);
+ registerSdkService.registerClient$.mockReturnValue(
+ of(null) as unknown as Observable>,
+ );
+
+ const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
+
+ await expect(promise).rejects.toThrow("SDK not available");
+ });
+
+ it("should throw when SDK registration fails", async () => {
+ const sdkError = new Error("SDK crypto operation failed");
+
+ organizationApiService.getKeys.mockResolvedValue(orgKeys);
+ mockRegistration.mockRejectedValue(sdkError);
+
+ const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
+
+ await expect(promise).rejects.toThrow("SDK crypto operation failed");
+ });
+ });
+
+ it("should throw when account_cryptographic_state is not V2", async () => {
+ const invalidResult = {
+ ...sdkRegistrationResult,
+ account_cryptographic_state: { V1: {} } as unknown as WrappedAccountCryptographicState,
+ };
+
+ mockRegistration.mockResolvedValue(invalidResult);
+
+ const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
+
+ await expect(promise).rejects.toThrow("Unexpected V2 account cryptographic state");
+ });
+ });
});
diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts
index 0e0bae62b9a..4ab26ecd09e 100644
--- a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts
+++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts
@@ -21,14 +21,16 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
-import { assertTruthy, assertNonNullish } from "@bitwarden/common/auth/utils";
+import { assertNonNullish, assertTruthy } from "@bitwarden/common/auth/utils";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { SyncService } from "@bitwarden/common/platform/sync";
-import { UserId } from "@bitwarden/common/types/guid";
+import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import {
AnonLayoutWrapperDataService,
ButtonModule,
@@ -39,6 +41,7 @@ import {
import { I18nPipe } from "@bitwarden/ui-common";
import {
+ InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
@@ -86,6 +89,7 @@ export class SetInitialPasswordComponent implements OnInit {
private syncService: SyncService,
private toastService: ToastService,
private validationService: ValidationService,
+ private configService: ConfigService,
) {}
async ngOnInit() {
@@ -101,6 +105,51 @@ export class SetInitialPasswordComponent implements OnInit {
this.initializing = false;
}
+ protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
+ this.submitting = true;
+
+ switch (this.userType) {
+ case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER: {
+ const accountEncryptionV2 = await this.configService.getFeatureFlag(
+ FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration,
+ );
+
+ if (accountEncryptionV2) {
+ await this.setInitialPasswordJitMPUserV2Encryption(passwordInputResult);
+ return;
+ }
+
+ await this.setInitialPassword(passwordInputResult);
+
+ break;
+ }
+ case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
+ await this.setInitialPassword(passwordInputResult);
+ break;
+ case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER:
+ await this.setInitialPasswordTdeOffboarding(passwordInputResult);
+ break;
+ default:
+ this.logService.error(
+ `Unexpected user type: ${this.userType}. Could not set initial password.`,
+ );
+ this.validationService.showError("Unexpected user type. Could not set initial password.");
+ }
+ }
+
+ protected async logout() {
+ const confirmed = await this.dialogService.openSimpleDialog({
+ title: { key: "logOut" },
+ content: { key: "logOutConfirmation" },
+ acceptButtonText: { key: "logOut" },
+ type: "warning",
+ });
+
+ if (confirmed) {
+ this.messagingService.send("logout");
+ }
+ }
+
private async establishUserType() {
if (!this.userId) {
throw new Error("userId not found. Could not determine user type.");
@@ -189,22 +238,39 @@ export class SetInitialPasswordComponent implements OnInit {
}
}
- protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
- this.submitting = true;
+ private async setInitialPasswordJitMPUserV2Encryption(passwordInputResult: PasswordInputResult) {
+ const ctx = "Could not set initial password for SSO JIT master password encryption user.";
+ assertTruthy(passwordInputResult.newPassword, "newPassword", ctx);
+ assertTruthy(passwordInputResult.salt, "salt", ctx);
+ assertTruthy(this.orgSsoIdentifier, "orgSsoIdentifier", ctx);
+ assertTruthy(this.orgId, "orgId", ctx);
+ assertTruthy(this.userId, "userId", ctx);
+ assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
+ assertNonNullish(this.resetPasswordAutoEnroll, "resetPasswordAutoEnroll", ctx); // can have `false` as a valid value, so check non-nullish
- switch (this.userType) {
- case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER:
- case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
- await this.setInitialPassword(passwordInputResult);
- break;
- case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER:
- await this.setInitialPasswordTdeOffboarding(passwordInputResult);
- break;
- default:
- this.logService.error(
- `Unexpected user type: ${this.userType}. Could not set initial password.`,
- );
- this.validationService.showError("Unexpected user type. Could not set initial password.");
+ try {
+ const credentials: InitializeJitPasswordCredentials = {
+ newPasswordHint: passwordInputResult.newPasswordHint,
+ orgSsoIdentifier: this.orgSsoIdentifier,
+ orgId: this.orgId as OrganizationId,
+ resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,
+ newPassword: passwordInputResult.newPassword,
+ salt: passwordInputResult.salt,
+ };
+
+ await this.setInitialPasswordService.initializePasswordJitPasswordUserV2Encryption(
+ credentials,
+ this.userId,
+ );
+
+ this.showSuccessToastByUserType();
+
+ this.submitting = false;
+ await this.router.navigate(["vault"]);
+ } catch (e) {
+ this.logService.error("Error setting initial password", e);
+ this.validationService.showError(e);
+ this.submitting = false;
}
}
@@ -307,17 +373,4 @@ export class SetInitialPasswordComponent implements OnInit {
});
}
}
-
- protected async logout() {
- const confirmed = await this.dialogService.openSimpleDialog({
- title: { key: "logOut" },
- content: { key: "logOutConfirmation" },
- acceptButtonText: { key: "logOut" },
- type: "warning",
- });
-
- if (confirmed) {
- this.messagingService.send("logout");
- }
- }
}
diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts
index 5620194e1bb..2667040c707 100644
--- a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts
+++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts
@@ -1,5 +1,5 @@
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
-import { UserId } from "@bitwarden/common/types/guid";
+import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { MasterKey } from "@bitwarden/common/types/key";
import { KdfConfig } from "@bitwarden/key-management";
@@ -61,6 +61,24 @@ export interface SetInitialPasswordTdeOffboardingCredentials {
newPasswordHint: string;
}
+/**
+ * Credentials required to initialize a just-in-time (JIT) provisioned user with a master password.
+ */
+export interface InitializeJitPasswordCredentials {
+ /** Hint for the new master password */
+ newPasswordHint: string;
+ /** SSO identifier for the organization */
+ orgSsoIdentifier: string;
+ /** Organization ID */
+ orgId: OrganizationId;
+ /** Whether to auto-enroll the user in account recovery (reset password) */
+ resetPasswordAutoEnroll: boolean;
+ /** The new master password */
+ newPassword: string;
+ /** Master password salt (typically the user's email) */
+ salt: MasterPasswordSalt;
+}
+
/**
* Handles setting an initial password for an existing authed user.
*
@@ -95,4 +113,14 @@ export abstract class SetInitialPasswordService {
credentials: SetInitialPasswordTdeOffboardingCredentials,
userId: UserId,
) => Promise;
+
+ /**
+ * Initializes a JIT-provisioned user's cryptographic state and enrolls them in master password unlock.
+ * @param credentials The credentials needed to initialize the JIT password user
+ * @param userId The account userId
+ */
+ abstract initializePasswordJitPasswordUserV2Encryption(
+ credentials: InitializeJitPasswordCredentials,
+ userId: UserId,
+ ): Promise;
}
diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts
index 5eaac4033eb..cf41b28baca 100644
--- a/libs/angular/src/services/jslib-services.module.ts
+++ b/libs/angular/src/services/jslib-services.module.ts
@@ -108,7 +108,7 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
import { WebAuthnLoginApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-api.service.abstraction";
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
-import { SendTokenService, DefaultSendTokenService } from "@bitwarden/common/auth/send-access";
+import { DefaultSendTokenService, SendTokenService } from "@bitwarden/common/auth/send-access";
import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service";
@@ -131,10 +131,10 @@ import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauth
import { WebAuthnLoginPrfKeyService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-key.service";
import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service";
import {
- TwoFactorApiService,
DefaultTwoFactorApiService,
- TwoFactorService,
DefaultTwoFactorService,
+ TwoFactorApiService,
+ TwoFactorService,
} from "@bitwarden/common/auth/two-factor";
import {
AutofillSettingsService,
@@ -208,8 +208,8 @@ import { PinService } from "@bitwarden/common/key-management/pin/pin.service.imp
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service";
import {
- SendPasswordService,
DefaultSendPasswordService,
+ SendPasswordService,
} from "@bitwarden/common/key-management/sends";
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
@@ -387,12 +387,12 @@ import { SafeInjectionToken } from "@bitwarden/ui-common";
// eslint-disable-next-line no-restricted-imports
import { PasswordRepromptService } from "@bitwarden/vault";
import {
+ DefaultVaultExportApiService,
IndividualVaultExportService,
IndividualVaultExportServiceAbstraction,
- DefaultVaultExportApiService,
- VaultExportApiService,
OrganizationVaultExportService,
OrganizationVaultExportServiceAbstraction,
+ VaultExportApiService,
VaultExportService,
VaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
@@ -1583,6 +1583,7 @@ const safeProviders: SafeProvider[] = [
OrganizationUserApiService,
InternalUserDecryptionOptionsServiceAbstraction,
AccountCryptographicStateService,
+ RegisterSdkService,
],
}),
safeProvider({
diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts
index f82c095d45f..811f4e524ac 100644
--- a/libs/common/src/enums/feature-flag.enum.ts
+++ b/libs/common/src/enums/feature-flag.enum.ts
@@ -47,6 +47,7 @@ export enum FeatureFlag {
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
PM27279_V2RegistrationTdeJit = "pm-27279-v2-registration-tde-jit",
EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration",
+ EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration",
/* Tools */
UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators",
@@ -156,6 +157,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
[FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE,
[FeatureFlag.EnableAccountEncryptionV2KeyConnectorRegistration]: FALSE,
+ [FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration]: FALSE,
/* Platform */
[FeatureFlag.IpcChannelFramework]: FALSE,
diff --git a/package-lock.json b/package-lock.json
index 95842c6b409..ff632dc2807 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23,8 +23,8 @@
"@angular/platform-browser": "20.3.15",
"@angular/platform-browser-dynamic": "20.3.15",
"@angular/router": "20.3.15",
- "@bitwarden/commercial-sdk-internal": "0.2.0-main.450",
- "@bitwarden/sdk-internal": "0.2.0-main.450",
+ "@bitwarden/commercial-sdk-internal": "0.2.0-main.470",
+ "@bitwarden/sdk-internal": "0.2.0-main.470",
"@electron/fuses": "1.8.0",
"@emotion/css": "11.13.5",
"@koa/multer": "4.0.0",
@@ -4982,9 +4982,9 @@
"link": true
},
"node_modules/@bitwarden/commercial-sdk-internal": {
- "version": "0.2.0-main.450",
- "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.450.tgz",
- "integrity": "sha512-WCihR6ykpIfaqJBHl4Wou4xDB8mp+5UPi94eEKYUdkx/9/19YyX33SX9H56zEriOuOMCD8l2fymhzAFjAAB++g==",
+ "version": "0.2.0-main.470",
+ "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.470.tgz",
+ "integrity": "sha512-QYhxv5eX6ouFJv94gMtBW7MjuK6t2KAN9FLz+/w1wnq8dScnA9Iky25phNPw+iHMgWwhq/dzZq45asKUFF//oA==",
"license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT",
"dependencies": {
"type-fest": "^4.41.0"
@@ -5087,9 +5087,9 @@
"link": true
},
"node_modules/@bitwarden/sdk-internal": {
- "version": "0.2.0-main.450",
- "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.450.tgz",
- "integrity": "sha512-XRhrBN0uoo66ONx7dYo9glhe9N451+VhwtC/oh3wo3j3qYxbPwf9yE98szlQ52u3iUExLisiYJY7sQNzhZrbZw==",
+ "version": "0.2.0-main.470",
+ "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.470.tgz",
+ "integrity": "sha512-XKvcUtoU6NnxeEzl3WK7bATiCh2RNxRmuX6JYNgcQHUtHUH+x3ckToR6II1qM3nha0VH0u1ijy3+07UdNQM+JQ==",
"license": "GPL-3.0",
"dependencies": {
"type-fest": "^4.41.0"
diff --git a/package.json b/package.json
index 01d11df89f8..829dc91370a 100644
--- a/package.json
+++ b/package.json
@@ -162,8 +162,8 @@
"@angular/platform-browser": "20.3.15",
"@angular/platform-browser-dynamic": "20.3.15",
"@angular/router": "20.3.15",
- "@bitwarden/sdk-internal": "0.2.0-main.450",
- "@bitwarden/commercial-sdk-internal": "0.2.0-main.450",
+ "@bitwarden/sdk-internal": "0.2.0-main.470",
+ "@bitwarden/commercial-sdk-internal": "0.2.0-main.470",
"@electron/fuses": "1.8.0",
"@emotion/css": "11.13.5",
"@koa/multer": "4.0.0",
From eee6fb895cec13c17cf08b0ff51cb7de9aa84ceb Mon Sep 17 00:00:00 2001
From: Jason Ng
Date: Thu, 22 Jan 2026 08:58:17 -0500
Subject: [PATCH 15/24] [PM-30889] Remove clone option from archive item
desktop (#18457)
* remove clone option from archive item desktop for users who lose premium status
---
.../src/vault/app/vault/item-footer.component.html | 14 +++++---------
.../src/vault/app/vault/item-footer.component.ts | 13 +++++++++++++
2 files changed, 18 insertions(+), 9 deletions(-)
diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.html b/apps/desktop/src/vault/app/vault/item-footer.component.html
index a03f3e96b06..0af73bf7d8a 100644
--- a/apps/desktop/src/vault/app/vault/item-footer.component.html
+++ b/apps/desktop/src/vault/app/vault/item-footer.component.html
@@ -36,15 +36,11 @@
>
-
-
-
+ @if (showCloneOption) {
+
+
+
+ }
Date: Thu, 22 Jan 2026 06:38:26 -0800
Subject: [PATCH 16/24] Desktop Autotype windows integration tests (#17639)
---
.github/workflows/test.yml | 16 +-
.../desktop_native/autotype/Cargo.toml | 9 +
.../autotype/tests/integration_tests.rs | 324 ++++++++++++++++++
3 files changed, 343 insertions(+), 6 deletions(-)
create mode 100644 apps/desktop/desktop_native/autotype/tests/integration_tests.rs
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index cf7251b259a..4280cabc812 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -111,7 +111,7 @@ jobs:
working-directory: ./apps/desktop/desktop_native
run: cargo build
- - name: Test Ubuntu
+ - name: Linux unit tests
if: ${{ matrix.os=='ubuntu-22.04' }}
working-directory: ./apps/desktop/desktop_native
run: |
@@ -120,17 +120,21 @@ jobs:
mkdir -p ~/.local/share/keyrings
eval "$(printf '\n' | gnome-keyring-daemon --unlock)"
eval "$(printf '\n' | /usr/bin/gnome-keyring-daemon --start)"
- cargo test -- --test-threads=1
+ cargo test --lib -- --test-threads=1
- - name: Test macOS
+ - name: MacOS unit tests
if: ${{ matrix.os=='macos-14' }}
working-directory: ./apps/desktop/desktop_native
- run: cargo test -- --test-threads=1
+ run: cargo test --lib -- --test-threads=1
- - name: Test Windows
+ - name: Windows unit tests
if: ${{ matrix.os=='windows-2022'}}
working-directory: ./apps/desktop/desktop_native
- run: cargo test --workspace --exclude=desktop_napi -- --test-threads=1
+ run: cargo test --lib --workspace --exclude=desktop_napi -- --test-threads=1
+
+ - name: Doc tests
+ working-directory: ./apps/desktop/desktop_native
+ run: cargo test --doc
rust-coverage:
name: Rust Coverage
diff --git a/apps/desktop/desktop_native/autotype/Cargo.toml b/apps/desktop/desktop_native/autotype/Cargo.toml
index a9c826af57d..59dd36c6c91 100644
--- a/apps/desktop/desktop_native/autotype/Cargo.toml
+++ b/apps/desktop/desktop_native/autotype/Cargo.toml
@@ -19,5 +19,14 @@ windows-core = { workspace = true }
[dependencies]
anyhow = { workspace = true }
+[target.'cfg(windows)'.dev-dependencies]
+windows = { workspace = true, features = [
+ "Win32_UI_Input_KeyboardAndMouse",
+ "Win32_UI_WindowsAndMessaging",
+ "Win32_Foundation",
+ "Win32_System_LibraryLoader",
+ "Win32_Graphics_Gdi",
+] }
+
[lints]
workspace = true
diff --git a/apps/desktop/desktop_native/autotype/tests/integration_tests.rs b/apps/desktop/desktop_native/autotype/tests/integration_tests.rs
new file mode 100644
index 00000000000..b87219f77fe
--- /dev/null
+++ b/apps/desktop/desktop_native/autotype/tests/integration_tests.rs
@@ -0,0 +1,324 @@
+#![cfg(target_os = "windows")]
+
+use std::{
+ sync::{Arc, Mutex},
+ thread,
+ time::Duration,
+};
+
+use autotype::{get_foreground_window_title, type_input};
+use serial_test::serial;
+use tracing::debug;
+use windows::Win32::{
+ Foundation::{COLORREF, HINSTANCE, HMODULE, HWND, LPARAM, LRESULT, WPARAM},
+ Graphics::Gdi::{CreateSolidBrush, UpdateWindow, ValidateRect, COLOR_WINDOW},
+ System::LibraryLoader::{GetModuleHandleA, GetModuleHandleW},
+ UI::WindowsAndMessaging::*,
+};
+use windows_core::{s, w, Result, PCSTR, PCWSTR};
+
+struct TestWindow {
+ handle: HWND,
+ capture: Option,
+}
+
+impl Drop for TestWindow {
+ fn drop(&mut self) {
+ // Clean up the InputCapture pointer
+ unsafe {
+ let capture_ptr = GetWindowLongPtrW(self.handle, GWLP_USERDATA) as *mut InputCapture;
+ if !capture_ptr.is_null() {
+ let _ = Box::from_raw(capture_ptr);
+ }
+ CloseWindow(self.handle).expect("window handle should be closeable");
+ DestroyWindow(self.handle).expect("window handle should be destroyable");
+ }
+ }
+}
+
+// state to capture keyboard input
+#[derive(Clone)]
+struct InputCapture {
+ chars: Arc>>,
+}
+
+impl InputCapture {
+ fn new() -> Self {
+ Self {
+ chars: Arc::new(Mutex::new(Vec::new())),
+ }
+ }
+
+ fn get_chars(&self) -> Vec {
+ self.chars
+ .lock()
+ .expect("mutex should not be poisoned")
+ .clone()
+ }
+}
+
+// Custom window procedure that captures input
+unsafe extern "system" fn capture_input_proc(
+ handle: HWND,
+ msg: u32,
+ wparam: WPARAM,
+ lparam: LPARAM,
+) -> LRESULT {
+ match msg {
+ WM_CREATE => {
+ // Store the InputCapture pointer in window data
+ let create_struct = lparam.0 as *const CREATESTRUCTW;
+ let capture_ptr = (*create_struct).lpCreateParams as *mut InputCapture;
+ SetWindowLongPtrW(handle, GWLP_USERDATA, capture_ptr as isize);
+ LRESULT(0)
+ }
+ WM_CHAR => {
+ // Get the InputCapture from window data
+ let capture_ptr = GetWindowLongPtrW(handle, GWLP_USERDATA) as *mut InputCapture;
+ if !capture_ptr.is_null() {
+ let capture = &*capture_ptr;
+ if let Some(ch) = char::from_u32(wparam.0 as u32) {
+ capture
+ .chars
+ .lock()
+ .expect("mutex should not be poisoned")
+ .push(ch);
+ }
+ }
+ LRESULT(0)
+ }
+ WM_DESTROY => {
+ PostQuitMessage(0);
+ LRESULT(0)
+ }
+ _ => DefWindowProcW(handle, msg, wparam, lparam),
+ }
+}
+
+// A pointer to the window procedure
+type ProcType = unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM) -> LRESULT;
+
+//
+extern "system" fn show_window_proc(
+ handle: HWND, // the window handle
+ message: u32, // the system message
+ wparam: WPARAM, /* additional message information. The contents of the wParam parameter
+ * depend on the value of the message parameter. */
+ lparam: LPARAM, /* additional message information. The contents of the lParam parameter
+ * depend on the value of the message parameter. */
+) -> LRESULT {
+ unsafe {
+ match message {
+ WM_PAINT => {
+ debug!("WM_PAINT");
+ let res = ValidateRect(Some(handle), None);
+ debug_assert!(res.ok().is_ok());
+ LRESULT(0)
+ }
+ WM_DESTROY => {
+ debug!("WM_DESTROY");
+ PostQuitMessage(0);
+ LRESULT(0)
+ }
+ _ => DefWindowProcA(handle, message, wparam, lparam),
+ }
+ }
+}
+
+impl TestWindow {
+ fn set_foreground(&self) -> Result<()> {
+ unsafe {
+ let _ = ShowWindow(self.handle, SW_SHOW);
+ let _ = SetForegroundWindow(self.handle);
+ let _ = UpdateWindow(self.handle);
+ let _ = SetForegroundWindow(self.handle);
+ }
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ Ok(())
+ }
+
+ fn wait_for_input(&self, timeout_ms: u64) {
+ let start = std::time::Instant::now();
+ while start.elapsed().as_millis() < timeout_ms as u128 {
+ process_messages();
+ thread::sleep(Duration::from_millis(10));
+ }
+ }
+}
+
+fn process_messages() {
+ unsafe {
+ let mut msg = MSG::default();
+ while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
+ let _ = TranslateMessage(&msg);
+ DispatchMessageW(&msg);
+ }
+ }
+}
+
+fn create_input_window(title: PCWSTR, proc_type: ProcType) -> Result {
+ unsafe {
+ let instance = GetModuleHandleW(None).unwrap_or(HMODULE(std::ptr::null_mut()));
+ let instance: HINSTANCE = instance.into();
+ debug_assert!(!instance.is_invalid());
+
+ let window_class = w!("show_window");
+
+ // Register window class with our custom proc
+ let wc = WNDCLASSW {
+ lpfnWndProc: Some(proc_type),
+ hInstance: instance,
+ lpszClassName: window_class,
+ hbrBackground: CreateSolidBrush(COLORREF(
+ (COLOR_WINDOW.0 + 1).try_into().expect("i32 to fit in u32"),
+ )),
+ ..Default::default()
+ };
+
+ let _atom = RegisterClassW(&wc);
+
+ let capture = InputCapture::new();
+
+ // Pass InputCapture as lpParam
+ let capture_ptr = Box::into_raw(Box::new(capture.clone()));
+
+ // Create window
+ //
+ let handle = CreateWindowExW(
+ WINDOW_EX_STYLE(0),
+ window_class,
+ title,
+ WS_OVERLAPPEDWINDOW | WS_VISIBLE,
+ CW_USEDEFAULT,
+ CW_USEDEFAULT,
+ 400,
+ 300,
+ None,
+ None,
+ Some(instance),
+ Some(capture_ptr as *const _),
+ )
+ .expect("window should be created");
+
+ // Process pending messages
+ process_messages();
+ thread::sleep(Duration::from_millis(100));
+
+ Ok(TestWindow {
+ handle,
+ capture: Some(capture),
+ })
+ }
+}
+
+fn create_title_window(title: PCSTR, proc_type: ProcType) -> Result {
+ unsafe {
+ let instance = GetModuleHandleA(None)?;
+ let instance: HINSTANCE = instance.into();
+ debug_assert!(!instance.is_invalid());
+
+ let window_class = s!("input_window");
+
+ // Register window class with our custom proc
+ //
+ let wc = WNDCLASSA {
+ hCursor: LoadCursorW(None, IDC_ARROW)?,
+ hInstance: instance,
+ lpszClassName: window_class,
+ style: CS_HREDRAW | CS_VREDRAW,
+ lpfnWndProc: Some(proc_type),
+ ..Default::default()
+ };
+
+ let _atom = RegisterClassA(&wc);
+
+ // Create window
+ //
+ let handle = CreateWindowExA(
+ WINDOW_EX_STYLE::default(),
+ window_class,
+ title,
+ WS_OVERLAPPEDWINDOW | WS_VISIBLE,
+ CW_USEDEFAULT,
+ CW_USEDEFAULT,
+ 800,
+ 600,
+ None,
+ None,
+ Some(instance),
+ None,
+ )
+ .expect("window should be created");
+
+ Ok(TestWindow {
+ handle,
+ capture: None,
+ })
+ }
+}
+
+#[serial]
+#[test]
+fn test_get_active_window_title_success() {
+ let title;
+ {
+ let window = create_title_window(s!("TITLE_FOOBAR"), show_window_proc).unwrap();
+ window.set_foreground().unwrap();
+ title = get_foreground_window_title().unwrap();
+ }
+
+ assert_eq!(title, "TITLE_FOOBAR\0".to_owned());
+
+ thread::sleep(Duration::from_millis(100));
+}
+
+#[serial]
+#[test]
+fn test_get_active_window_title_doesnt_fail_if_empty_title() {
+ let title;
+ {
+ let window = create_title_window(s!(""), show_window_proc).unwrap();
+ window.set_foreground().unwrap();
+ title = get_foreground_window_title();
+ }
+
+ assert_eq!(title.unwrap(), "".to_owned());
+
+ thread::sleep(Duration::from_millis(100));
+}
+
+#[serial]
+#[test]
+fn test_type_input_success() {
+ const TAB: u16 = 0x09;
+ let chars;
+ {
+ let window = create_input_window(w!("foo"), capture_input_proc).unwrap();
+ window.set_foreground().unwrap();
+
+ type_input(
+ &[
+ 0x66, 0x6F, 0x6C, 0x6C, 0x6F, 0x77, 0x5F, 0x74, 0x68, 0x65, TAB, 0x77, 0x68, 0x69,
+ 0x74, 0x65, 0x5F, 0x72, 0x61, 0x62, 0x62, 0x69, 0x74,
+ ],
+ &["Control".to_owned(), "Alt".to_owned(), "B".to_owned()],
+ )
+ .unwrap();
+
+ // Wait for and process input messages
+ window.wait_for_input(250);
+
+ // Verify captured input
+ let capture = window.capture.as_ref().unwrap();
+ chars = capture.get_chars();
+ }
+
+ assert!(!chars.is_empty(), "No input captured");
+
+ let input_str = String::from_iter(chars.iter());
+ let input_str = input_str.replace("\t", "_");
+
+ assert_eq!(input_str, "follow_the_white_rabbit");
+
+ thread::sleep(Duration::from_millis(100));
+}
From 139a5c1eb65070b7169149c557c5c05f6253505a Mon Sep 17 00:00:00 2001
From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com>
Date: Thu, 22 Jan 2026 14:04:34 -0600
Subject: [PATCH 17/24] avoid setting width on body when extension is within a
tab (#18499)
---
.../src/platform/popup/layout/popup-size.service.ts | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/apps/browser/src/platform/popup/layout/popup-size.service.ts b/apps/browser/src/platform/popup/layout/popup-size.service.ts
index ff3f09d0d01..4c0c901270e 100644
--- a/apps/browser/src/platform/popup/layout/popup-size.service.ts
+++ b/apps/browser/src/platform/popup/layout/popup-size.service.ts
@@ -37,7 +37,7 @@ export class PopupSizeService {
/** Begin listening for state changes */
async init() {
this.width$.subscribe((width: PopupWidthOption) => {
- PopupSizeService.setStyle(width);
+ void PopupSizeService.setStyle(width);
localStorage.setItem(PopupSizeService.LocalStorageKey, width);
});
}
@@ -77,8 +77,9 @@ export class PopupSizeService {
}
}
- private static setStyle(width: PopupWidthOption) {
- if (!BrowserPopupUtils.inPopup(window)) {
+ private static async setStyle(width: PopupWidthOption) {
+ const isInTab = await BrowserPopupUtils.isInTab();
+ if (!BrowserPopupUtils.inPopup(window) || isInTab) {
return;
}
const pxWidth = PopupWidthOptions[width] ?? PopupWidthOptions.default;
@@ -91,6 +92,6 @@ export class PopupSizeService {
**/
static initBodyWidthFromLocalStorage() {
const storedValue = localStorage.getItem(PopupSizeService.LocalStorageKey);
- this.setStyle(storedValue as any);
+ void this.setStyle(storedValue as any);
}
}
From 0270474c99bafd330dba5d5b30adf9616237abc4 Mon Sep 17 00:00:00 2001
From: neuronull <9162534+neuronull@users.noreply.github.com>
Date: Thu, 22 Jan 2026 12:27:36 -0800
Subject: [PATCH 18/24] Move approve ssh request out of Platform (#18226)
---
.../{platform => autofill}/components/approve-ssh-request.html | 0
.../{platform => autofill}/components/approve-ssh-request.ts | 0
apps/desktop/src/autofill/services/ssh-agent.service.ts | 2 +-
3 files changed, 1 insertion(+), 1 deletion(-)
rename apps/desktop/src/{platform => autofill}/components/approve-ssh-request.html (100%)
rename apps/desktop/src/{platform => autofill}/components/approve-ssh-request.ts (100%)
diff --git a/apps/desktop/src/platform/components/approve-ssh-request.html b/apps/desktop/src/autofill/components/approve-ssh-request.html
similarity index 100%
rename from apps/desktop/src/platform/components/approve-ssh-request.html
rename to apps/desktop/src/autofill/components/approve-ssh-request.html
diff --git a/apps/desktop/src/platform/components/approve-ssh-request.ts b/apps/desktop/src/autofill/components/approve-ssh-request.ts
similarity index 100%
rename from apps/desktop/src/platform/components/approve-ssh-request.ts
rename to apps/desktop/src/autofill/components/approve-ssh-request.ts
diff --git a/apps/desktop/src/autofill/services/ssh-agent.service.ts b/apps/desktop/src/autofill/services/ssh-agent.service.ts
index 7e289720ec8..e3280f07ede 100644
--- a/apps/desktop/src/autofill/services/ssh-agent.service.ts
+++ b/apps/desktop/src/autofill/services/ssh-agent.service.ts
@@ -32,8 +32,8 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { CipherType } from "@bitwarden/common/vault/enums";
import { DialogService, ToastService } from "@bitwarden/components";
-import { ApproveSshRequestComponent } from "../../platform/components/approve-ssh-request";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
+import { ApproveSshRequestComponent } from "../components/approve-ssh-request";
import { SshAgentPromptType } from "../models/ssh-agent-setting";
@Injectable({
From 676b75902b0d422abcc58512ac72ab78b20fd110 Mon Sep 17 00:00:00 2001
From: brandonbiete
Date: Thu, 22 Jan 2026 15:49:37 -0500
Subject: [PATCH 19/24] [BRE-1507] Remove PR process from workflow to allow
direct pushes (#18504)
---
.github/workflows/repository-management.yml | 46 ++-------------------
1 file changed, 3 insertions(+), 43 deletions(-)
diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml
index 79f3335313e..65607268cda 100644
--- a/.github/workflows/repository-management.yml
+++ b/.github/workflows/repository-management.yml
@@ -72,7 +72,6 @@ jobs:
permissions:
id-token: write
contents: write
- pull-requests: write
steps:
- name: Validate version input format
@@ -111,8 +110,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, committing to, and pushing new branches
- permission-pull-requests: write # for generating pull requests
+ permission-contents: write # for committing and pushing to main (bypasses rulesets)
- name: Check out branch
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
@@ -448,53 +446,15 @@ jobs:
echo "No changes to commit!";
fi
- - name: Create version bump branch
- if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
- run: |
- BRANCH_NAME="version-bump-$(date +%s)"
- git checkout -b "$BRANCH_NAME"
- echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
-
- name: Commit version bumps with GPG signature
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
run: |
git commit -m "Bumped client version(s)" -a
- - name: Push version bump branch
+ - name: Push changes to main
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
run: |
- git push --set-upstream origin "$BRANCH_NAME"
-
- - name: Create Pull Request for version bump
- if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- env:
- VERSION_BROWSER: ${{ steps.set-final-version-output.outputs.version_browser }}
- VERSION_CLI: ${{ steps.set-final-version-output.outputs.version_cli }}
- VERSION_DESKTOP: ${{ steps.set-final-version-output.outputs.version_desktop }}
- VERSION_WEB: ${{ steps.set-final-version-output.outputs.version_web }}
- with:
- github-token: ${{ steps.app-token.outputs.token }}
- script: |
- const versions = [];
- if (process.env.VERSION_BROWSER) versions.push(`- Browser: ${process.env.VERSION_BROWSER}`);
- if (process.env.VERSION_CLI) versions.push(`- CLI: ${process.env.VERSION_CLI}`);
- if (process.env.VERSION_DESKTOP) versions.push(`- Desktop: ${process.env.VERSION_DESKTOP}`);
- if (process.env.VERSION_WEB) versions.push(`- Web: ${process.env.VERSION_WEB}`);
-
- const body = versions.length > 0
- ? `Automated version bump:\n\n${versions.join('\n')}`
- : 'Automated version bump';
-
- const { data: pr } = await github.rest.pulls.create({
- owner: context.repo.owner,
- repo: context.repo.repo,
- title: 'Bumped client version(s)',
- body: body,
- head: process.env.BRANCH_NAME,
- base: context.ref.replace('refs/heads/', '')
- });
- console.log(`Created PR #${pr.number}: ${pr.html_url}`);
+ git push
cut_branch:
name: Cut branch
From 1baed4dea8992081cf3456bdd74c624766cf668c Mon Sep 17 00:00:00 2001
From: Derek Nance
Date: Thu, 22 Jan 2026 15:12:15 -0600
Subject: [PATCH 20/24] [PM-30470] Revert to using X11 on Linux desktop
(#18465)
---
apps/desktop/resources/linux-wrapper.sh | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/resources/linux-wrapper.sh b/apps/desktop/resources/linux-wrapper.sh
index 3c5d16c3a3d..e1cb69274d7 100644
--- a/apps/desktop/resources/linux-wrapper.sh
+++ b/apps/desktop/resources/linux-wrapper.sh
@@ -12,9 +12,13 @@ if [ -e "/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" ]; then
export LD_PRELOAD="/usr/lib/x86_64-linux-gnu/libdbus-1.so.3"
fi
+# A bug in Electron 39 (which now enables Wayland by default) causes a crash on
+# systems using Wayland with hardware acceleration. Platform decided to
+# configure Electron to use X11 (with an opt-out) until the upstream bug is
+# fixed. The follow-up task is https://bitwarden.atlassian.net/browse/PM-31080.
PARAMS="--enable-features=UseOzonePlatform,WaylandWindowDecorations --ozone-platform-hint=auto"
-if [ "$USE_X11" = "true" ]; then
- PARAMS=""
+if [ "$USE_X11" != "false" ]; then
+ PARAMS="--ozone-platform=x11"
fi
$APP_PATH/bitwarden-app $PARAMS "$@"
From a9d8edc52ccdeec6d3d46df880ed8856344e40a2 Mon Sep 17 00:00:00 2001
From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com>
Date: Thu, 22 Jan 2026 15:20:53 -0600
Subject: [PATCH 21/24] [PM-28749] Desktop Transfer Items (#18410)
* add transfer items prompt to desktop
* add transfer service to vault v3
---
.../desktop/src/vault/app/vault-v3/vault.component.ts | 11 +++++++++++
.../desktop/src/vault/app/vault/vault-v2.component.ts | 11 +++++++++++
2 files changed, 22 insertions(+)
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 c104f76ff2d..a64830c3b5d 100644
--- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts
+++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts
@@ -79,6 +79,8 @@ import {
VaultFilter,
VaultFilterServiceAbstraction as VaultFilterService,
RoutedVaultFilterBridgeService,
+ VaultItemsTransferService,
+ DefaultVaultItemsTransferService,
} from "@bitwarden/vault";
import { SearchBarService } from "../../../app/layout/search/search-bar.service";
@@ -130,6 +132,7 @@ const BroadcasterSubscriptionId = "VaultComponent";
provide: COPY_CLICK_LISTENER,
useExisting: VaultComponent,
},
+ { provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService },
],
})
export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
@@ -214,6 +217,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService,
private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService,
private vaultFilterService: VaultFilterService,
+ private vaultItemTransferService: VaultItemsTransferService,
) {}
async ngOnInit() {
@@ -266,6 +270,11 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
if (this.vaultItemsComponent) {
await this.vaultItemsComponent.refresh().catch(() => {});
}
+ if (this.activeUserId) {
+ void this.vaultItemTransferService.enforceOrganizationDataOwnership(
+ this.activeUserId,
+ );
+ }
break;
case "modalShown":
this.showingModal = true;
@@ -372,6 +381,8 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
.subscribe((collections) => {
this.filteredCollections = collections;
});
+
+ void this.vaultItemTransferService.enforceOrganizationDataOwnership(this.activeUserId);
}
ngOnDestroy() {
diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts
index c1ab9d6f22a..efbdee97798 100644
--- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts
+++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts
@@ -92,6 +92,8 @@ import {
PasswordRepromptService,
CipherFormComponent,
ArchiveCipherUtilitiesService,
+ VaultItemsTransferService,
+ DefaultVaultItemsTransferService,
} from "@bitwarden/vault";
import { NavComponent } from "../../../app/layout/nav.component";
@@ -150,6 +152,7 @@ const BroadcasterSubscriptionId = "VaultComponent";
provide: COPY_CLICK_LISTENER,
useExisting: VaultV2Component,
},
+ { provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService },
],
})
export class VaultV2Component
@@ -264,6 +267,7 @@ export class VaultV2Component
private policyService: PolicyService,
private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService,
private masterPasswordService: MasterPasswordServiceAbstraction,
+ private vaultItemTransferService: VaultItemsTransferService,
) {}
async ngOnInit() {
@@ -317,6 +321,11 @@ export class VaultV2Component
.catch(() => {});
await this.vaultFilterComponent.reloadOrganizations().catch(() => {});
}
+ if (this.activeUserId) {
+ void this.vaultItemTransferService.enforceOrganizationDataOwnership(
+ this.activeUserId,
+ );
+ }
break;
case "modalShown":
this.showingModal = true;
@@ -420,6 +429,8 @@ export class VaultV2Component
.subscribe((collections) => {
this.allCollections = collections;
});
+
+ void this.vaultItemTransferService.enforceOrganizationDataOwnership(this.activeUserId);
}
ngOnDestroy() {
From daacff888d5da3cb6eae4dedf258377440c44f7f Mon Sep 17 00:00:00 2001
From: Bryan Cunningham
Date: Thu, 22 Jan 2026 16:55:35 -0500
Subject: [PATCH 22/24] [CL-1020] background color updates (#18417)
* Adding new background colors
* add sidenav color variables
* fix admin console text color
* update sidenav logos to use correct fill color
* update nav logo focus ring color
---
libs/assets/src/svg/svgs/admin-console.ts | 4 +--
libs/assets/src/svg/svgs/password-manager.ts | 4 +--
libs/assets/src/svg/svgs/provider-portal.ts | 4 +--
libs/assets/src/svg/svgs/secrets-manager.ts | 4 +--
libs/assets/src/svg/svgs/shield.ts | 4 +--
.../src/icon-button/icon-button.component.ts | 4 +--
.../src/navigation/nav-group.component.html | 2 +-
.../src/navigation/nav-item.component.html | 16 ++++++------
.../src/navigation/nav-item.component.ts | 2 +-
.../src/navigation/nav-logo.component.html | 2 +-
.../src/navigation/side-nav.component.html | 12 ++++-----
libs/components/src/tw-theme.css | 26 +++++++++++++++++++
libs/components/tailwind.config.base.js | 15 +++++++----
13 files changed, 65 insertions(+), 34 deletions(-)
diff --git a/libs/assets/src/svg/svgs/admin-console.ts b/libs/assets/src/svg/svgs/admin-console.ts
index 83c8cf9f0e1..3e8f47ec4a5 100644
--- a/libs/assets/src/svg/svgs/admin-console.ts
+++ b/libs/assets/src/svg/svgs/admin-console.ts
@@ -2,13 +2,13 @@ import { svgIcon } from "../icon-service";
const AdminConsoleLogo = svgIcon`
diff --git a/libs/assets/src/svg/svgs/password-manager.ts b/libs/assets/src/svg/svgs/password-manager.ts
index 17b6f148be3..5b19562e022 100644
--- a/libs/assets/src/svg/svgs/password-manager.ts
+++ b/libs/assets/src/svg/svgs/password-manager.ts
@@ -2,13 +2,13 @@ import { svgIcon } from "../icon-service";
const PasswordManagerLogo = svgIcon`
diff --git a/libs/assets/src/svg/svgs/provider-portal.ts b/libs/assets/src/svg/svgs/provider-portal.ts
index 51c04e1553b..fad2ce6b864 100644
--- a/libs/assets/src/svg/svgs/provider-portal.ts
+++ b/libs/assets/src/svg/svgs/provider-portal.ts
@@ -2,13 +2,13 @@ import { svgIcon } from "../icon-service";
const ProviderPortalLogo = svgIcon`
diff --git a/libs/assets/src/svg/svgs/secrets-manager.ts b/libs/assets/src/svg/svgs/secrets-manager.ts
index 27589e7e2f9..62b54174c55 100644
--- a/libs/assets/src/svg/svgs/secrets-manager.ts
+++ b/libs/assets/src/svg/svgs/secrets-manager.ts
@@ -2,13 +2,13 @@ import { svgIcon } from "../icon-service";
const SecretsManagerLogo = svgIcon`
diff --git a/libs/assets/src/svg/svgs/shield.ts b/libs/assets/src/svg/svgs/shield.ts
index 38d429604aa..af626a98e9d 100644
--- a/libs/assets/src/svg/svgs/shield.ts
+++ b/libs/assets/src/svg/svgs/shield.ts
@@ -3,11 +3,11 @@ import { svgIcon } from "../icon-service";
const BitwardenShield = svgIcon`
diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts
index c7eb28fc086..3b5e01132a2 100644
--- a/libs/components/src/icon-button/icon-button.component.ts
+++ b/libs/components/src/icon-button/icon-button.component.ts
@@ -71,9 +71,9 @@ const styles: Record = {
primary: ["!tw-text-primary-600", "focus-visible:before:tw-ring-primary-600", ...focusRing],
danger: ["!tw-text-danger-600", "focus-visible:before:tw-ring-primary-600", ...focusRing],
"nav-contrast": [
- "!tw-text-alt2",
+ "!tw-text-fg-sidenav-text",
"hover:!tw-bg-hover-contrast",
- "focus-visible:before:tw-ring-text-alt2",
+ "focus-visible:before:tw-ring-border-focus",
...focusRing,
],
};
diff --git a/libs/components/src/navigation/nav-group.component.html b/libs/components/src/navigation/nav-group.component.html
index 1790fea179a..d305f89063e 100644
--- a/libs/components/src/navigation/nav-group.component.html
+++ b/libs/components/src/navigation/nav-group.component.html
@@ -19,7 +19,7 @@
+
+
+
+ @if (!disableSend()) {
+
+ }
+
+
+
+
+
+
+} @else {
+
+
+
+ }
+
+
+ @if (!action()) {
+
+}
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 a73a0534ff9..3670713f8f3 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
@@ -49,6 +49,7 @@ describe("SendV2Component", () => {
let sendApiService: MockProxy
+
+
+ @if (!disableSend()) {
+
+
+ {{ "new" | i18n }}
+
+ }
+
+
+
+
+ @if (action() == "add" || action() == "edit") {
+
+
+
+
+ {{ "newSend" | i18n }}
+
+
+
+
+
+ }
+
+
+
+
+
+
@if (open) {
@@ -31,7 +31,7 @@
>
@if (icon()) {
@@ -47,7 +47,7 @@
0"
data-fvw
[routerLink]="route()"
@@ -68,7 +68,7 @@
0"
data-fvw
(click)="mainContentClicked.emit()"
@@ -79,7 +79,7 @@
@if (open) {
diff --git a/libs/components/src/navigation/nav-item.component.ts b/libs/components/src/navigation/nav-item.component.ts
index b32ca0e3fde..e57413d9980 100644
--- a/libs/components/src/navigation/nav-item.component.ts
+++ b/libs/components/src/navigation/nav-item.component.ts
@@ -90,7 +90,7 @@ export class NavItemComponent extends NavBaseComponent {
protected focusVisibleWithin$ = new BehaviorSubject(false);
protected fvwStyles$ = this.focusVisibleWithin$.pipe(
map((value) =>
- value ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-text-alt2" : "",
+ value ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-border-focus" : "",
),
);
@HostListener("focusin", ["$event.target"])
diff --git a/libs/components/src/navigation/nav-logo.component.html b/libs/components/src/navigation/nav-logo.component.html
index 5915f029357..1d9961554c2 100644
--- a/libs/components/src/navigation/nav-logo.component.html
+++ b/libs/components/src/navigation/nav-logo.component.html
@@ -8,7 +8,7 @@