diff --git a/apps/web/src/app/billing/payment/components/display-payment-method-inline.component.ts b/apps/web/src/app/billing/payment/components/display-payment-method-inline.component.ts
new file mode 100644
index 00000000000..aa6d15401f4
--- /dev/null
+++ b/apps/web/src/app/billing/payment/components/display-payment-method-inline.component.ts
@@ -0,0 +1,230 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ inject,
+ input,
+ output,
+ signal,
+ viewChild,
+} from "@angular/core";
+
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { ToastService, IconComponent } from "@bitwarden/components";
+import { LogService } from "@bitwarden/logging";
+import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
+
+import { SharedModule } from "../../../shared";
+import { BitwardenSubscriber } from "../../types";
+import { getCardBrandIcon, MaskedPaymentMethod, TokenizablePaymentMethods } from "../types";
+
+import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
+
+/**
+ * Component for inline editing of payment methods.
+ * Displays a form to update payment method details directly within the parent view.
+ */
+@Component({
+ selector: "app-display-payment-method-inline",
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ @if (!isChangingPayment()) {
+ {{ "paymentMethod" | i18n }}
+
+ @if (paymentMethod(); as pm) {
+ @switch (pm.type) {
+ @case ("bankAccount") {
+ @if (pm.hostedVerificationUrl) {
+
+ {{ "verifyBankAccountWithStripe" | i18n }}
+ {{ "verifyNow" | i18n }}
+
+ }
+
+
+
+ {{ pm.bankName }}, *{{ pm.last4 }}
+ @if (pm.hostedVerificationUrl) {
+ - {{ "unverified" | i18n }}
+ }
+
+ }
+ @case ("card") {
+
+ @if (cardBrandIcon(); as icon) {
+
+ } @else {
+
+ }
+ {{ pm.brand | titlecase }}, *{{ pm.last4 }},
+ {{ pm.expiration }}
+
+ }
+ @case ("payPal") {
+
+
+ {{ pm.email }}
+
+ }
+ }
+ } @else {
+
{{ "noPaymentMethod" | i18n }}
+ }
+ @let key = paymentMethod() ? "changePaymentMethod" : "addPaymentMethod";
+
+ {{ key | i18n }}
+
+ } @else {
+
+
+
+
+
+
+ }
+
+ `,
+ standalone: true,
+ imports: [SharedModule, EnterPaymentMethodComponent, IconComponent],
+ providers: [SubscriberBillingClient],
+})
+export class DisplayPaymentMethodInlineComponent {
+ readonly subscriber = input.required
();
+ readonly paymentMethod = input.required();
+ readonly updated = output();
+ readonly changingStateChanged = output();
+
+ protected formGroup = EnterPaymentMethodComponent.getFormGroup();
+
+ private readonly enterPaymentMethodComponent = viewChild(
+ EnterPaymentMethodComponent,
+ );
+
+ protected readonly isChangingPayment = signal(false);
+ protected readonly cardBrandIcon = computed(() => getCardBrandIcon(this.paymentMethod()));
+
+ private readonly billingClient = inject(SubscriberBillingClient);
+ private readonly i18nService = inject(I18nService);
+ private readonly toastService = inject(ToastService);
+ private readonly logService = inject(LogService);
+
+ /**
+ * Initiates the payment method change process by displaying the inline form.
+ */
+ protected changePaymentMethod = async (): Promise => {
+ this.isChangingPayment.set(true);
+ this.changingStateChanged.emit(true);
+ };
+
+ /**
+ * Submits the payment method update form.
+ * Validates the form, tokenizes the payment method, and sends the update request.
+ */
+ protected submit = async (): Promise => {
+ try {
+ if (!this.formGroup.valid) {
+ this.formGroup.markAllAsTouched();
+ throw new Error("Form is invalid");
+ }
+
+ const component = this.enterPaymentMethodComponent();
+ if (!component) {
+ throw new Error("Payment method component not found");
+ }
+
+ const paymentMethod = await component.tokenize();
+ if (!paymentMethod) {
+ throw new Error("Failed to tokenize payment method");
+ }
+
+ const billingAddress =
+ this.formGroup.value.type !== TokenizablePaymentMethods.payPal
+ ? this.formGroup.controls.billingAddress.getRawValue()
+ : null;
+
+ await this.handlePaymentMethodUpdate(paymentMethod, billingAddress);
+ } catch (error) {
+ this.logService.error("Error submitting payment method update:", error);
+ this.toastService.showToast({
+ variant: "error",
+ title: "",
+ message: this.i18nService.t("paymentMethodUpdateError"),
+ });
+ throw error;
+ }
+ };
+
+ /**
+ * Handles the payment method update API call and result processing.
+ */
+ private async handlePaymentMethodUpdate(paymentMethod: any, billingAddress: any): Promise {
+ const result = await this.billingClient.updatePaymentMethod(
+ this.subscriber(),
+ paymentMethod,
+ billingAddress,
+ );
+
+ switch (result.type) {
+ case "success": {
+ this.toastService.showToast({
+ variant: "success",
+ title: "",
+ message: this.i18nService.t("paymentMethodUpdated"),
+ });
+ this.updated.emit(result.value);
+ this.isChangingPayment.set(false);
+ this.changingStateChanged.emit(false);
+ this.formGroup.reset();
+ break;
+ }
+ case "error": {
+ this.logService.error("Error submitting payment method update:", result);
+
+ this.toastService.showToast({
+ variant: "error",
+ title: "",
+ message: this.i18nService.t("paymentMethodUpdateError"),
+ });
+ break;
+ }
+ }
+ }
+
+ /**
+ * Cancels the inline editing and resets the form.
+ */
+ protected cancel = (): void => {
+ this.formGroup.reset();
+ this.changingStateChanged.emit(false);
+ this.isChangingPayment.set(false);
+ };
+}
diff --git a/apps/web/src/app/billing/payment/components/display-payment-method.component.ts b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts
index c5ffa4268ed..f8e244b3b7a 100644
--- a/apps/web/src/app/billing/payment/components/display-payment-method.component.ts
+++ b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts
@@ -1,4 +1,4 @@
-import { Component, EventEmitter, Input, Output } from "@angular/core";
+import { Component, EventEmitter, input, Input, Output } from "@angular/core";
import { lastValueFrom } from "rxjs";
import { DialogService } from "@bitwarden/components";
@@ -15,7 +15,9 @@ import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dial
selector: "app-display-payment-method",
template: `
- {{ "paymentMethod" | i18n }}
+ @if (!hideHeader()) {
+ {{ "paymentMethod" | i18n }}
+ }
@if (paymentMethod) {
@switch (paymentMethod.type) {
@case ("bankAccount") {
@@ -81,6 +83,7 @@ export class DisplayPaymentMethodComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() updated = new EventEmitter();
+ protected readonly hideHeader = input(false);
constructor(private dialogService: DialogService) {}
diff --git a/apps/web/src/app/billing/payment/components/index.ts b/apps/web/src/app/billing/payment/components/index.ts
index 5e10fa4763b..d570d3265e2 100644
--- a/apps/web/src/app/billing/payment/components/index.ts
+++ b/apps/web/src/app/billing/payment/components/index.ts
@@ -2,6 +2,7 @@ export * from "./add-account-credit-dialog.component";
export * from "./change-payment-method-dialog.component";
export * from "./display-account-credit.component";
export * from "./display-billing-address.component";
+export * from "./display-payment-method-inline.component";
export * from "./display-payment-method.component";
export * from "./edit-billing-address-dialog.component";
export * from "./enter-billing-address.component";
diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts
index 3511257d6da..96c23d6dd19 100644
--- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts
+++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts
@@ -260,9 +260,9 @@ export class VaultItemsComponent {
}
get isAllSelected() {
- return this.editableItems
- .slice(0, MaxSelectionCount)
- .every((item) => this.selection.isSelected(item));
+ // Check selection against sorted items to match toggleAll() behavior
+ const sortedItems = this.getSortedEditableItems();
+ return sortedItems.slice(0, MaxSelectionCount).every((item) => this.selection.isSelected(item));
}
get isEmpty() {
@@ -376,9 +376,30 @@ export class VaultItemsComponent {
}
protected toggleAll() {
- this.isAllSelected
- ? this.selection.clear()
- : this.selection.select(...this.editableItems.slice(0, MaxSelectionCount));
+ if (this.isAllSelected) {
+ this.selection.clear();
+ } else {
+ const sortedItems = this.getSortedEditableItems();
+ this.selection.select(...sortedItems.slice(0, MaxSelectionCount));
+ }
+ }
+
+ /**
+ * Returns editableItems sorted according to the current table sort configuration.
+ * This ensures bulk selection matches the visual order displayed to the user.
+ */
+ private getSortedEditableItems(): VaultItem[] {
+ const currentSort = this.dataSource.sort;
+ const items = [...this.editableItems];
+
+ // If no sort function is set, return items in their original order (as displayed in table)
+ if (!currentSort || !currentSort.fn) {
+ return items;
+ }
+
+ // Apply sort function with direction modifier (matches TableDataSource.sortData behavior)
+ const directionModifier = currentSort.direction === "asc" ? 1 : -1;
+ return items.sort((a, b) => currentSort.fn(a, b, currentSort.direction) * directionModifier);
}
protected event(event: VaultItemEvent) {
@@ -584,12 +605,13 @@ export class VaultItemsComponent {
* Sorts VaultItems, grouping collections before ciphers, and sorting each group alphabetically by name.
*/
protected sortByName = (a: VaultItem, b: VaultItem, direction: SortDirection) => {
- // Collections before ciphers
- const collectionCompare = this.prioritizeCollections(a, b, direction);
+ // Collections before ciphers (direction-independent)
+ const collectionCompare = this.prioritizeCollections(a, b);
if (collectionCompare !== 0) {
return collectionCompare;
}
+ // Name comparison (direction-dependent, handled by directionModifier)
return this.compareNames(a, b);
};
@@ -611,8 +633,8 @@ export class VaultItemsComponent {
return null;
};
- // Collections before ciphers
- const collectionCompare = this.prioritizeCollections(a, b, direction);
+ // Collections before ciphers (direction-independent)
+ const collectionCompare = this.prioritizeCollections(a, b);
if (collectionCompare !== 0) {
return collectionCompare;
}
@@ -655,8 +677,8 @@ export class VaultItemsComponent {
return priorityMap[permission] ?? -1;
};
- // Collections before ciphers
- const collectionCompare = this.prioritizeCollections(a, b, direction);
+ // Collections before ciphers (direction-independent)
+ const collectionCompare = this.prioritizeCollections(a, b);
if (collectionCompare !== 0) {
return collectionCompare;
}
@@ -664,11 +686,12 @@ export class VaultItemsComponent {
const priorityA = getPermissionPriority(a);
const priorityB = getPermissionPriority(b);
- // Higher priority first
+ // Higher priority first (direction-dependent, handled by directionModifier)
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
+ // Fallback to name comparison (direction-dependent, handled by directionModifier)
return this.compareNames(a, b);
};
@@ -679,22 +702,19 @@ export class VaultItemsComponent {
/**
* Sorts VaultItems by prioritizing collections over ciphers.
- * Collections are always placed before ciphers, regardless of the sorting direction.
+ * Always returns -1 for collections before ciphers, regardless of sort direction.
+ * This comparison is direction-independent; the direction is applied separately via directionModifier.
*/
- private prioritizeCollections(
- a: VaultItem,
- b: VaultItem,
- direction: SortDirection,
- ): number {
+ private prioritizeCollections(a: VaultItem, b: VaultItem): number {
if (a.collection && !b.collection) {
- return direction === "asc" ? -1 : 1;
+ return -1; // a (collection) comes before b (cipher)
}
if (!a.collection && b.collection) {
- return direction === "asc" ? 1 : -1;
+ return 1; // b (collection) comes before a (cipher)
}
- return 0;
+ return 0; // Both are collections or both are ciphers
}
private hasPersonalItems(): boolean {
diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts
index fe4b7f1f96f..65846810cd2 100644
--- a/apps/web/src/app/vault/individual-vault/vault.component.ts
+++ b/apps/web/src/app/vault/individual-vault/vault.component.ts
@@ -9,7 +9,6 @@ import {
lastValueFrom,
Observable,
Subject,
- zip,
} from "rxjs";
import {
concatMap,
@@ -35,7 +34,6 @@ import {
ItemTypes,
BitSvg,
} from "@bitwarden/assets/svg";
-import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import {
@@ -60,9 +58,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { EventType } from "@bitwarden/common/enums";
-import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
-import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -113,15 +109,9 @@ import {
VaultItemsTransferService,
DefaultVaultItemsTransferService,
} from "@bitwarden/vault";
-import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/individual/upgrade/services";
import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module";
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
-import {
- AutoConfirmPolicy,
- AutoConfirmPolicyDialogComponent,
- PolicyEditDialogResult,
-} from "../../admin-console/organizations/policies";
import {
CollectionDialogAction,
CollectionDialogTabType,
@@ -138,6 +128,7 @@ import { VaultItem } from "../components/vault-items/vault-item";
import { VaultItemEvent } from "../components/vault-items/vault-item-event";
import { VaultItemsComponent } from "../components/vault-items/vault-items.component";
import { VaultItemsModule } from "../components/vault-items/vault-items.module";
+import { WebVaultPromptService } from "../services/web-vault-prompt.service";
import {
BulkDeleteDialogResult,
@@ -183,6 +174,7 @@ type EmptyStateMap = Record;
RoutedVaultFilterService,
RoutedVaultFilterBridgeService,
DefaultCipherFormConfigService,
+ WebVaultPromptService,
{ provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService },
],
})
@@ -195,7 +187,6 @@ export class VaultComponent implements OnInit, OnDestr
@ViewChild("vaultItems", { static: false }) vaultItemsComponent: VaultItemsComponent;
trashCleanupWarning: string = null;
- kdfIterations: number;
activeFilter: VaultFilter = new VaultFilter();
protected deactivatedOrgIcon = DeactivatedOrg;
@@ -224,7 +215,6 @@ export class VaultComponent implements OnInit, OnDestr
private destroy$ = new Subject();
private vaultItemDialogRef?: DialogRef | undefined;
- private autoConfirmDialogRef?: DialogRef | undefined;
protected showAddCipherBtn: boolean = false;
@@ -346,11 +336,8 @@ export class VaultComponent implements OnInit, OnDestr
private cipherArchiveService: CipherArchiveService,
private organizationWarningsService: OrganizationWarningsService,
private policyService: PolicyService,
- private unifiedUpgradePromptService: UnifiedUpgradePromptService,
private premiumUpgradePromptService: PremiumUpgradePromptService,
- private autoConfirmService: AutomaticUserConfirmationService,
- private configService: ConfigService,
- private vaultItemTransferService: VaultItemsTransferService,
+ private webVaultPromptService: WebVaultPromptService,
) {}
async ngOnInit() {
@@ -646,11 +633,8 @@ export class VaultComponent implements OnInit, OnDestr
this.changeDetectorRef.markForCheck();
},
);
- void this.unifiedUpgradePromptService.displayUpgradePromptConditionally();
- this.setupAutoConfirm();
-
- void this.vaultItemTransferService.enforceOrganizationDataOwnership(activeUserId);
+ void this.webVaultPromptService.conditionallyPromptUser();
}
ngOnDestroy() {
@@ -1608,72 +1592,6 @@ export class VaultComponent implements OnInit, OnDestr
const cipherView = await this.cipherService.decrypt(_cipher, activeUserId);
return cipherView.login?.password;
}
-
- private async openAutoConfirmFeatureDialog(organization: Organization) {
- if (this.autoConfirmDialogRef) {
- return;
- }
-
- this.autoConfirmDialogRef = AutoConfirmPolicyDialogComponent.open(this.dialogService, {
- data: {
- policy: new AutoConfirmPolicy(),
- organizationId: organization.id,
- firstTimeDialog: true,
- },
- });
-
- await lastValueFrom(this.autoConfirmDialogRef.closed);
- this.autoConfirmDialogRef = undefined;
- }
-
- private setupAutoConfirm() {
- // if the policy is enabled, then the user may only belong to one organization at most.
- const organization$ = this.organizations$.pipe(map((organizations) => organizations[0]));
-
- const featureFlag$ = this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm);
-
- const autoConfirmState$ = this.userId$.pipe(
- switchMap((userId) => this.autoConfirmService.configuration$(userId)),
- );
-
- const policyEnabled$ = combineLatest([
- this.userId$.pipe(
- switchMap((userId) => this.policyService.policies$(userId)),
- map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm && p.enabled)),
- ),
- organization$,
- ]).pipe(
- map(
- ([policy, organization]) => (policy && policy.organizationId === organization?.id) ?? false,
- ),
- );
-
- zip([organization$, featureFlag$, autoConfirmState$, policyEnabled$, this.userId$])
- .pipe(
- first(),
- switchMap(async ([organization, flagEnabled, autoConfirmState, policyEnabled, userId]) => {
- const showDialog =
- flagEnabled &&
- !policyEnabled &&
- autoConfirmState.showSetupDialog &&
- !!organization &&
- organization.canEnableAutoConfirmPolicy;
-
- if (showDialog) {
- await this.openAutoConfirmFeatureDialog(organization);
-
- await this.autoConfirmService.upsert(userId, {
- ...autoConfirmState,
- showSetupDialog: false,
- });
- }
- }),
- takeUntil(this.destroy$),
- )
- .subscribe({
- error: (err: unknown) => this.logService.error("Failed to update auto-confirm state", err),
- });
- }
}
/**
diff --git a/apps/web/src/app/vault/services/web-vault-prompt.service.spec.ts b/apps/web/src/app/vault/services/web-vault-prompt.service.spec.ts
new file mode 100644
index 00000000000..a224b8e7c4b
--- /dev/null
+++ b/apps/web/src/app/vault/services/web-vault-prompt.service.spec.ts
@@ -0,0 +1,234 @@
+import { fakeAsync, TestBed, tick } from "@angular/core/testing";
+import { BehaviorSubject, of } from "rxjs";
+
+import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
+import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
+import { PolicyType } from "@bitwarden/common/admin-console/enums";
+import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { UserId } from "@bitwarden/common/types/guid";
+import { DialogRef, DialogService } from "@bitwarden/components";
+import { LogService } from "@bitwarden/logging";
+import { VaultItemsTransferService } from "@bitwarden/vault";
+
+import {
+ AutoConfirmPolicyDialogComponent,
+ PolicyEditDialogResult,
+} from "../../admin-console/organizations/policies";
+import { UnifiedUpgradePromptService } from "../../billing/individual/upgrade/services";
+
+import { WebVaultPromptService } from "./web-vault-prompt.service";
+
+describe("WebVaultPromptService", () => {
+ let service: WebVaultPromptService;
+
+ const mockUserId = "user-123" as UserId;
+ const mockOrganizationId = "org-456";
+
+ const getFeatureFlag$ = jest.fn().mockReturnValue(of(false));
+ const open = jest.fn();
+ const policies$ = jest.fn().mockReturnValue(of([]));
+ const configurationAutoConfirm$ = jest
+ .fn()
+ .mockReturnValue(
+ of({ showSetupDialog: false, enabled: false, showBrowserNotification: false }),
+ );
+ const upsertAutoConfirm = jest.fn().mockResolvedValue(undefined);
+ const organizations$ = jest.fn().mockReturnValue(of([]));
+ const displayUpgradePromptConditionally = jest.fn().mockResolvedValue(undefined);
+ const enforceOrganizationDataOwnership = jest.fn().mockResolvedValue(undefined);
+ const logError = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ TestBed.configureTestingModule({
+ providers: [
+ WebVaultPromptService,
+ { provide: UnifiedUpgradePromptService, useValue: { displayUpgradePromptConditionally } },
+ { provide: VaultItemsTransferService, useValue: { enforceOrganizationDataOwnership } },
+ { provide: PolicyService, useValue: { policies$ } },
+ { provide: AccountService, useValue: { activeAccount$: of({ id: mockUserId }) } },
+ {
+ provide: AutomaticUserConfirmationService,
+ useValue: { configuration$: configurationAutoConfirm$, upsert: upsertAutoConfirm },
+ },
+ { provide: OrganizationService, useValue: { organizations$ } },
+ { provide: ConfigService, useValue: { getFeatureFlag$ } },
+ { provide: DialogService, useValue: { open } },
+ { provide: LogService, useValue: { error: logError } },
+ ],
+ });
+
+ service = TestBed.inject(WebVaultPromptService);
+ });
+
+ describe("conditionallyPromptUser", () => {
+ it("calls displayUpgradePromptConditionally", async () => {
+ await service.conditionallyPromptUser();
+
+ expect(
+ service["unifiedUpgradePromptService"].displayUpgradePromptConditionally,
+ ).toHaveBeenCalled();
+ });
+
+ it("calls enforceOrganizationDataOwnership with the userId", async () => {
+ await service.conditionallyPromptUser();
+
+ expect(
+ service["vaultItemTransferService"].enforceOrganizationDataOwnership,
+ ).toHaveBeenCalledWith(mockUserId);
+ });
+ });
+
+ describe("setupAutoConfirm", () => {
+ it("shows dialog when all conditions are met", fakeAsync(() => {
+ getFeatureFlag$.mockReturnValueOnce(of(true));
+ configurationAutoConfirm$.mockReturnValueOnce(
+ of({ showSetupDialog: true, enabled: false, showBrowserNotification: false }),
+ );
+ policies$.mockReturnValueOnce(of([]));
+
+ const mockOrg = {
+ id: mockOrganizationId,
+ canManagePolicies: true,
+ canEnableAutoConfirmPolicy: true,
+ } as Organization;
+ organizations$.mockReturnValueOnce(of([mockOrg]));
+
+ const dialogClosedSubject = new BehaviorSubject(null);
+ const dialogRefMock = {
+ closed: dialogClosedSubject.asObservable(),
+ } as unknown as DialogRef;
+
+ const openSpy = jest
+ .spyOn(AutoConfirmPolicyDialogComponent, "open")
+ .mockReturnValue(dialogRefMock);
+
+ void service.conditionallyPromptUser();
+
+ tick();
+
+ expect(openSpy).toHaveBeenCalledWith(expect.anything(), {
+ data: {
+ policy: expect.any(Object),
+ organizationId: mockOrganizationId,
+ firstTimeDialog: true,
+ },
+ });
+
+ dialogClosedSubject.next(null);
+ }));
+
+ it("does not show dialog when feature flag is disabled", fakeAsync(() => {
+ getFeatureFlag$.mockReturnValueOnce(of(false));
+ configurationAutoConfirm$.mockReturnValueOnce(
+ of({ showSetupDialog: true, enabled: false, showBrowserNotification: false }),
+ );
+ policies$.mockReturnValueOnce(of([]));
+
+ const mockOrg = {
+ id: mockOrganizationId,
+ } as Organization;
+ organizations$.mockReturnValueOnce(of([mockOrg]));
+
+ const openSpy = jest.spyOn(AutoConfirmPolicyDialogComponent, "open");
+
+ void service.conditionallyPromptUser();
+
+ tick();
+
+ expect(openSpy).not.toHaveBeenCalled();
+ }));
+
+ it("does not show dialog when policy is already enabled", fakeAsync(() => {
+ getFeatureFlag$.mockReturnValueOnce(of(true));
+ configurationAutoConfirm$.mockReturnValueOnce(
+ of({ showSetupDialog: true, enabled: false, showBrowserNotification: false }),
+ );
+
+ const mockPolicy = {
+ type: PolicyType.AutoConfirm,
+ enabled: true,
+ } as Policy;
+ policies$.mockReturnValueOnce(of([mockPolicy]));
+
+ const mockOrg = {
+ id: mockOrganizationId,
+ } as Organization;
+ organizations$.mockReturnValueOnce(of([mockOrg]));
+
+ const openSpy = jest.spyOn(AutoConfirmPolicyDialogComponent, "open");
+
+ void service.conditionallyPromptUser();
+
+ tick();
+
+ expect(openSpy).not.toHaveBeenCalled();
+ }));
+
+ it("does not show dialog when showSetupDialog is false", fakeAsync(() => {
+ getFeatureFlag$.mockReturnValueOnce(of(true));
+ configurationAutoConfirm$.mockReturnValueOnce(
+ of({ showSetupDialog: false, enabled: false, showBrowserNotification: false }),
+ );
+ policies$.mockReturnValueOnce(of([]));
+
+ const mockOrg = {
+ id: mockOrganizationId,
+ } as Organization;
+ organizations$.mockReturnValueOnce(of([mockOrg]));
+
+ const openSpy = jest.spyOn(AutoConfirmPolicyDialogComponent, "open");
+
+ void service.conditionallyPromptUser();
+
+ tick();
+
+ expect(openSpy).not.toHaveBeenCalled();
+ }));
+
+ it("does not show dialog when organization is undefined", fakeAsync(() => {
+ getFeatureFlag$.mockReturnValueOnce(of(true));
+ configurationAutoConfirm$.mockReturnValueOnce(
+ of({ showSetupDialog: true, enabled: false, showBrowserNotification: false }),
+ );
+ policies$.mockReturnValueOnce(of([]));
+ organizations$.mockReturnValueOnce(of([]));
+
+ const openSpy = jest.spyOn(AutoConfirmPolicyDialogComponent, "open");
+
+ void service.conditionallyPromptUser();
+
+ tick();
+
+ expect(openSpy).not.toHaveBeenCalled();
+ }));
+
+ it("does not show dialog when organization cannot enable auto-confirm policy", fakeAsync(() => {
+ getFeatureFlag$.mockReturnValueOnce(of(true));
+ configurationAutoConfirm$.mockReturnValueOnce(
+ of({ showSetupDialog: true, enabled: false, showBrowserNotification: false }),
+ );
+ policies$.mockReturnValueOnce(of([]));
+
+ const mockOrg = {
+ id: mockOrganizationId,
+ canManagePolicies: false,
+ } as Organization;
+
+ organizations$.mockReturnValueOnce(of([mockOrg]));
+
+ const openSpy = jest.spyOn(AutoConfirmPolicyDialogComponent, "open");
+
+ void service.conditionallyPromptUser();
+
+ tick();
+
+ expect(openSpy).not.toHaveBeenCalled();
+ }));
+ });
+});
diff --git a/apps/web/src/app/vault/services/web-vault-prompt.service.ts b/apps/web/src/app/vault/services/web-vault-prompt.service.ts
new file mode 100644
index 00000000000..1774bfcc085
--- /dev/null
+++ b/apps/web/src/app/vault/services/web-vault-prompt.service.ts
@@ -0,0 +1,113 @@
+import { inject, Injectable } from "@angular/core";
+import { map, switchMap, combineLatest, zip, first, firstValueFrom } from "rxjs";
+
+import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
+import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
+import { PolicyType } from "@bitwarden/common/admin-console/enums";
+import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+import { 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 { DialogService } from "@bitwarden/components";
+import { LogService } from "@bitwarden/logging";
+import { VaultItemsTransferService } from "@bitwarden/vault";
+
+import {
+ AutoConfirmPolicyDialogComponent,
+ AutoConfirmPolicy,
+} from "../../admin-console/organizations/policies";
+import { UnifiedUpgradePromptService } from "../../billing/individual/upgrade/services";
+
+@Injectable()
+export class WebVaultPromptService {
+ private unifiedUpgradePromptService = inject(UnifiedUpgradePromptService);
+ private vaultItemTransferService = inject(VaultItemsTransferService);
+ private policyService = inject(PolicyService);
+ private accountService = inject(AccountService);
+ private autoConfirmService = inject(AutomaticUserConfirmationService);
+ private organizationService = inject(OrganizationService);
+ private configService = inject(ConfigService);
+ private dialogService = inject(DialogService);
+ private logService = inject(LogService);
+
+ private userId$ = this.accountService.activeAccount$.pipe(getUserId);
+
+ private organizations$ = this.userId$.pipe(
+ switchMap((id) => this.organizationService.organizations$(id)),
+ );
+
+ /**
+ * Conditionally initiates prompts for users.
+ * All logic for users should be handled within this method to avoid
+ * the user seeing multiple onboarding prompts at different times.
+ */
+ async conditionallyPromptUser() {
+ const userId = await firstValueFrom(this.userId$);
+
+ void this.unifiedUpgradePromptService.displayUpgradePromptConditionally();
+
+ void this.vaultItemTransferService.enforceOrganizationDataOwnership(userId);
+
+ this.checkForAutoConfirm();
+ }
+
+ private async openAutoConfirmFeatureDialog(organization: Organization) {
+ AutoConfirmPolicyDialogComponent.open(this.dialogService, {
+ data: {
+ policy: new AutoConfirmPolicy(),
+ organizationId: organization.id,
+ firstTimeDialog: true,
+ },
+ });
+ }
+
+ private checkForAutoConfirm() {
+ // if the policy is enabled, then the user may only belong to one organization at most.
+ const organization$ = this.organizations$.pipe(map((organizations) => organizations[0]));
+
+ const featureFlag$ = this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm);
+
+ const autoConfirmState$ = this.userId$.pipe(
+ switchMap((userId) => this.autoConfirmService.configuration$(userId)),
+ );
+
+ const policyEnabled$ = combineLatest([
+ this.userId$.pipe(
+ switchMap((userId) => this.policyService.policies$(userId)),
+ map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm && p.enabled)),
+ ),
+ organization$,
+ ]).pipe(
+ map(
+ ([policy, organization]) => (policy && policy.organizationId === organization?.id) ?? false,
+ ),
+ );
+
+ zip([organization$, featureFlag$, autoConfirmState$, policyEnabled$, this.userId$])
+ .pipe(
+ first(),
+ switchMap(async ([organization, flagEnabled, autoConfirmState, policyEnabled, userId]) => {
+ const showDialog =
+ flagEnabled &&
+ !policyEnabled &&
+ autoConfirmState.showSetupDialog &&
+ !!organization &&
+ organization.canEnableAutoConfirmPolicy;
+
+ if (showDialog) {
+ await this.openAutoConfirmFeatureDialog(organization);
+
+ await this.autoConfirmService.upsert(userId, {
+ ...autoConfirmState,
+ showSetupDialog: false,
+ });
+ }
+ }),
+ )
+ .subscribe({
+ error: (err: unknown) => this.logService.error("Failed to update auto-confirm state", err),
+ });
+ }
+}
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index 97bb46029a7..fc2f463d9e6 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -5394,8 +5394,8 @@
"minimumNumberOfWords": {
"message": "Minimum number of words"
},
- "overridePasswordTypePolicy": {
- "message": "Password Type",
+ "passwordTypePolicyOverride": {
+ "message": "Password type",
"description": "Name of the password generator policy that overrides the user's password/passphrase selection."
},
"userPreference": {
@@ -12794,5 +12794,54 @@
},
"perUser": {
"message": "per user"
+ },
+ "upgradeToTeams": {
+ "message": "Upgrade to Teams"
+ },
+ "upgradeToEnterprise": {
+ "message": "Upgrade to Enterprise"
+ },
+ "upgradeShareEvenMore": {
+ "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise"
+ },
+ "organizationUpgradeTaxInformationMessage": {
+ "message": "Prices exclude tax and are billed annually."
+ },
+ "invoicePreviewErrorMessage": {
+ "message": "Encountered an error while generating the invoice preview."
+ },
+ "planProratedMembershipInMonths": {
+ "message": "Prorated $PLAN$ membership ($NUMOFMONTHS$)",
+ "placeholders": {
+ "plan": {
+ "content": "$1",
+ "example": "Families"
+ },
+ "numofmonths": {
+ "content": "$2",
+ "example": "6 Months"
+ }
+ }
+ },
+ "premiumSubscriptionCredit": {
+ "message": "Premium subscription credit"
+ },
+ "enterpriseMembership": {
+ "message": "Enterprise membership"
+ },
+ "teamsMembership": {
+ "message": "Teams membership"
+ },
+ "plansUpdated": {
+ "message": "You've upgraded to $PLAN$!",
+ "placeholders": {
+ "plan": {
+ "content": "$1",
+ "example": "Families"
+ }
+ }
+ },
+ "paymentMethodUpdateError": {
+ "message": "There was an error updating your payment method."
}
}
diff --git a/libs/angular/src/billing/types/subscription-pricing-card-details.ts b/libs/angular/src/billing/types/subscription-pricing-card-details.ts
index 5f37f91c4f0..8430f6d35b5 100644
--- a/libs/angular/src/billing/types/subscription-pricing-card-details.ts
+++ b/libs/angular/src/billing/types/subscription-pricing-card-details.ts
@@ -1,10 +1,13 @@
-import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { BitwardenIcon, ButtonType } from "@bitwarden/components";
export type SubscriptionPricingCardDetails = {
title: string;
tagline: string;
- price?: { amount: number; cadence: SubscriptionCadence };
+ price?: {
+ amount: number;
+ cadence: "month" | "monthly" | "year" | "annually";
+ showPerUser?: boolean;
+ };
button: {
text: string;
type: ButtonType;
diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.html b/libs/pricing/src/components/cart-summary/cart-summary.component.html
index d3a0ad25e6c..4e3e75baed9 100644
--- a/libs/pricing/src/components/cart-summary/cart-summary.component.html
+++ b/libs/pricing/src/components/cart-summary/cart-summary.component.html
@@ -1,6 +1,6 @@
@let cart = this.cart();
@let term = this.term();
-
+@let hideTerm = this.hidePricingTerm();
@@ -16,7 +16,9 @@
{{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD
- / {{ term }}
+ @if (!hideTerm) {
+ / {{ term | i18n }}
+ }
}
@@ -86,8 +90,11 @@
)
}}
@if (!additionalStorage.hideBreakdown) {
- x {{ additionalStorage.cost | currency: "USD" : "symbol" }} /
- {{ term }}
+ x {{ additionalStorage.cost | currency: "USD" : "symbol" }}
+ @if (!hideTerm) {
+ /
+ {{ term }}
+ }
}
@@ -125,7 +132,10 @@
@if (!secretsManagerSeats.hideBreakdown) {
x
{{ secretsManagerSeats.cost | currency: "USD" : "symbol" }}
- / {{ term }}
+ @if (!hideTerm) {
+ /
+ {{ term }}
+ }
}