[PM-31778] Multi-step policy edit dialog (#19406)

* implement multi-step policy ui framework

* policy wip

* Refactor organization data ownership policy component to use multi-step dialog and signals. Remove deprecated dialog component and update related tests. Add organizationId input to base policy edit component and implement save logic in the new dialog structure.

* Update multi-step policy edit dialog to handle loading state and improve template initialization. Adjust button disable logic to account for loading status and modify ViewChild static property for proper change detection.

* Refactor policy confirmation logic in base policy edit component. Remove deprecated confirm method and update policy edit dialog to handle confirmation checks dynamically. Adjust organization data ownership policy component to align with new confirmation structure.

* Update apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* Refactor multi-step policy dialog component and remove legacy confirm method

- Deleted the multi-step policy plan documentation.
- Introduced a new `MultiStepPolicyEditDialogComponent` to handle multi-step workflows.
- Updated `BasePolicyEditComponent` to support multi-step configurations and removed the deprecated `confirm()` method.
- Enhanced `PolicyStep` type to include optional `disableSave` and `bodyContent` properties.
- Adjusted HTML templates to utilize signals for dynamic content rendering.
- Refactored existing policy components to integrate with the new multi-step dialog structure.

* Refactor AutoConfirmPolicy component constructor to use readonly modifier for firstTimeDialog parameter

* Update multi-step policy edit dialog template to ensure proper rendering of policy form before lifecycle hooks. Added comment for clarity on rendering behavior.

* Update apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* Add confirmation step before policy submission in dialog component

- Introduced an optional `confirm` method in `BasePolicyEditComponent` to allow components to implement a confirmation step before saving.
- Updated `PolicyEditDialogComponent` to call the `confirm` method and handle user confirmation.
- Added new test files for `AutoConfirmPolicyEditComponent` and `MultiStepPolicyEditDialogComponent` to ensure proper functionality and behavior of the new confirmation logic.

* Update apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.ts

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* Refactor policy edit components to streamline side effects and improve save logic

- Moved side effect logic from inline definitions to dedicated methods in AutoConfirmPolicyEditComponent.
- Updated vNextOrganizationDataOwnershipPolicyComponent to remove unused template references.
- Adjusted PolicyStep type to allow for side effects returning void.
- Enhanced save button logic in MultiStepPolicyEditDialogComponent to utilize a computed signal for save state management.

* Enhance MultiStepPolicyEditDialogComponent tests and update references

- Refactored tests in multi-step policy edit dialog to improve saveDisabled logic and ensure accurate state management.
- Updated references from AutoConfirmPolicyDialogComponent to MultiStepPolicyEditDialogComponent in web-vault-prompt service tests for consistency.

* Refactor auto-confirm policy component template and update imports

- Improved readability of the auto-confirm policy component template by formatting the button element.
- Cleaned up import statements in the auto-confirm policy component test file for consistency.

* Update WebVaultPromptService tests to validate firstTimeDialog property

- Added assertion to ensure the firstTimeDialog property is correctly passed in the policy data during dialog closure in WebVaultPromptService tests.

* Refactor policy edit components to support migration to new policy steps

- Added TODO comments in BasePolicyEditComponent and PolicyEditDialogComponent to indicate the removal of the confirm method when the `MigrateMyVaultToMyItems` feature flag is deprecated.
- Updated vNextOrganizationDataOwnershipPolicyComponent to ensure proper handling of organizationId.
- Enhanced MultiStepPolicyEditDialogComponent template for improved subtitle handling and readability.

* Refactor policy edit components to utilize Angular's input function

- Replaced traditional @Input() properties with the new input() function in BasePolicyEditComponent for better reactivity.
- Updated policy edit dialog component to set inputs using the new method for improved clarity and consistency.
- Adjusted various policy components to ensure compatibility with the new input handling, enhancing overall code maintainability.

* fix(policy-edit): update policy access method to use function call syntax

Changed the way policy properties are accessed in the auto-confirm policy component template, ensuring compatibility with the updated policy structure.

* refactor(session-timeout): update policyResponse handling in component and tests

Modified the SessionTimeoutPolicyComponent to use function call syntax for accessing policyResponse. Updated corresponding unit tests to reflect this change, ensuring consistent handling of policy data across the component and its tests.

* refactor(policy-edit-dialog): simplify test structure and improve saveDisabled logic

Updated the unit tests for MultiStepPolicyEditDialogComponent to eliminate the use of fakeAsync and detectChanges, replacing them with direct state manipulation and TestBed.flushEffects(). This change enhances test clarity and reliability. Additionally, refactored the account age calculation in WebVaultExtensionPromptService to use UTC midnight for accurate day counting, addressing potential off-by-one errors.

* Fix change to prompt service which was unneeded

* refactor(policy-edit): enhance savePolicy method and inject dependencies

Updated the BasePolicyEditComponent to inject AccountService, KeyService, and PolicyApiServiceAbstraction. Refactored the savePolicy method to streamline the policy saving process, ensuring proper handling of organization keys and error management. Adjusted AutoConfirmPolicyEditComponent and vNextOrganizationDataOwnershipPolicyComponent to utilize the new savePolicy implementation.

* refactor(policy-edit): remove unused AccountService dependency

Removed the AccountService dependency from MasterPasswordPolicyComponent and ResetPasswordPolicyComponent as it was not utilized. Added a getter for autoConfirmPolicy in AutoConfirmPolicyEditComponent to enhance code clarity.

* refactor(policy-edit): update sideEffect callbacks in AutoConfirmPolicyEditComponent

Modified the sideEffect properties in the AutoConfirmPolicyEditComponent to use arrow functions, ensuring proper context binding for the savePolicy and navigateToExtensionPromptStep methods.

* refactor(policy-edit): update sideEffect to use arrow function in vNextOrganizationDataOwnershipPolicyComponent

Changed the sideEffect property in the vNextOrganizationDataOwnershipPolicyComponent to use an arrow function for the savePolicy method, ensuring proper context binding.

* Fixes tests for PR

* refactor(vault): improve account age calculation to use UTC boundaries

Updated the account age calculation in WebVaultExtensionPromptService to utilize UTC midnight boundaries, preventing issues with Daylight Saving Time skewing the day count.

* Refactor PolicyEditDialogComponent to improve signal usage for save button state management

- Changed saveDisabled property to use a private signal for better encapsulation.
- Updated template bindings to call loading() and saveDisabled() as functions for correct state handling.
- Enhanced observables management for save button disable logic to ensure proper state updates.

* Fix optional chaining in policy confirmation and add policy steps to vNext organization data ownership component

* Update tests for vNext organization data ownership component to use fixture inputs and add missing service mocks

---------

Co-authored-by: Brandon <btreston@bitwarden.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
Jared 2026-04-08 11:17:14 -04:00 committed by GitHub
parent 65e37bf577
commit 405c30008a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1093 additions and 1088 deletions

View File

@ -105,7 +105,7 @@ const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours
<div id="container">
<div class="loading" *ngIf="loading">
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
<bit-spinner></bit-spinner>
</div>
<router-outlet *ngIf="!loading"></router-outlet>
</div>

View File

@ -10,7 +10,7 @@ import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { IconModule } from "@bitwarden/components";
import { IconModule, SpinnerComponent } from "@bitwarden/components";
import { SshAgentService } from "../autofill/services/ssh-agent.service";
import { PremiumComponent } from "../billing/app/accounts/premium.component";
@ -36,6 +36,7 @@ import { ServicesModule } from "./services/services.module";
AppRoutingModule,
JslibModule,
IconModule,
SpinnerComponent,
ReactiveFormsModule,
OverlayModule,
ServicesModule,

View File

@ -1,19 +1,26 @@
import { Directive, Input, OnInit } from "@angular/core";
import { Directive, OnInit, Signal, inject, input, signal } from "@angular/core";
import { FormControl, UntypedFormGroup } from "@angular/forms";
import { Observable, of } from "rxjs";
import { Observable, firstValueFrom, of, switchMap } from "rxjs";
import { Constructor } from "type-fest";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
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 { VNextSavePolicyRequest } from "@bitwarden/common/admin-console/models/request/v-next-save-policy.request";
import { PolicyStatusResponse } from "@bitwarden/common/admin-console/models/response/policy-status.response";
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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { PolicyCategory } from "./pipes/policy-category";
import type { PolicyEditDialogData, PolicyEditDialogResult } from "./policy-edit-dialog.component";
import type { PolicyStep, PolicyStepResult } from "./policy-edit-dialogs/models";
/**
* Interface for policy dialog components.
@ -91,12 +98,14 @@ export abstract class BasePolicyEditDefinition {
*/
@Directive()
export abstract class BasePolicyEditComponent implements OnInit {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() policyResponse: PolicyStatusResponse | undefined;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() policy: BasePolicyEditDefinition | undefined;
readonly policyResponse = input<PolicyStatusResponse | undefined>(undefined);
readonly policy = input<BasePolicyEditDefinition | undefined>(undefined);
readonly currentStep = input<Signal<number>>(signal(0));
readonly organizationId = input<string | undefined>(undefined);
protected readonly accountService = inject(AccountService);
protected readonly keyService = inject(KeyService);
protected readonly policyApiService = inject(PolicyApiServiceAbstraction);
/**
* Whether the policy is enabled.
@ -108,16 +117,31 @@ export abstract class BasePolicyEditComponent implements OnInit {
*/
data: UntypedFormGroup | undefined;
ngOnInit(): void {
this.enabled.setValue(this.policyResponse?.enabled ?? false);
/**
* Optional multi-step configuration for policies that require multiple steps to complete.
*/
policySteps?: PolicyStep[];
if (this.policyResponse?.data != null) {
ngOnInit(): void {
this.enabled.setValue(this.policyResponse()?.enabled ?? false);
if (this.policyResponse()?.data != null) {
this.loadData();
}
}
/**
* An optional guard called before submission in {@link PolicyEditDialogComponent}.
* Return `false` to abort the save (e.g. when the user cancels a warning dialog).
* Components that need a confirmation step before saving should override this method.
*
* TODO: Remove this method when the `MigrateMyVaultToMyItems` feature flag is removed.
* New policy components should use {@link policySteps} with a `sideEffect` instead.
*/
confirm?(): Promise<boolean>;
async buildVNextRequest(orgKey: OrgKey): Promise<VNextSavePolicyRequest> {
if (!this.policy) {
if (!this.policy()) {
throw new Error("Policy was not found");
}
@ -130,7 +154,7 @@ export abstract class BasePolicyEditComponent implements OnInit {
}
buildRequest() {
if (!this.policy) {
if (!this.policy()) {
throw new Error("Policy was not found");
}
@ -143,16 +167,40 @@ export abstract class BasePolicyEditComponent implements OnInit {
}
/**
* This is called before the policy is saved. If it returns false, it will not be saved
* and the user will remain on the policy edit dialog.
* This can be used to trigger an additional confirmation modal before saving.
* */
confirm(): Promise<boolean> | boolean {
return true;
* Saves the policy via the vNext API. Subclasses that require additional steps or side effects
* (e.g. enabling a prerequisite policy) should override this method.
*/
protected async savePolicy(): Promise<PolicyStepResult | void> {
if (!this.policy()) {
throw new Error("Policy was not found");
}
const orgKeys = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
),
);
assertNonNullish(orgKeys, "Org keys not provided");
const orgKey = orgKeys[this.organizationId() as OrganizationId];
if (orgKey == null) {
throw new Error("No encryption key for this organization.");
}
const request = await this.buildVNextRequest(orgKey);
await this.policyApiService.putPolicyVNext(
this.organizationId() ?? "",
this.policy()!.type,
request,
);
}
protected loadData() {
this.data?.patchValue(this.policyResponse?.data ?? {});
this.data?.patchValue(this.policyResponse()?.data ?? {});
}
/**

View File

@ -159,6 +159,7 @@ export class PoliciesComponent {
edit(policy: BasePolicyEditDefinition, organization: Organization) {
const dialogComponent: PolicyDialogComponent =
policy.editDialogComponent ?? PolicyEditDialogComponent;
dialogComponent.open(this.dialogService, {
data: {
policy: policy,

View File

@ -1,6 +1,21 @@
<ng-container [ngTemplateOutlet]="steps[step]()"></ng-container>
<ng-template #step0Title>
<div class="tw-flex tw-flex-col">
@let showBadge = autoConfirmPolicy?.firstTimeDialog ?? false;
@if (showBadge) {
<span bitBadge variant="info" class="tw-w-[99px] tw-my-2"> {{ "availableNow" | i18n }}</span>
}
<span>
{{ (showBadge ? "autoConfirm" : "editPolicy") | i18n }}
@if (!showBadge) {
<span class="tw-text-muted tw-font-normal tw-text-sm">
{{ policy()!.name | i18n }}
</span>
}
</span>
</div>
</ng-template>
<ng-template #step0>
<ng-template #step0Content>
<p class="tw-mb-6">
{{ "autoConfirmPolicyEditDescription" | i18n }}
</p>
@ -46,7 +61,32 @@
<bit-label>{{ "autoConfirmCheckBoxLabel" | i18n }}</bit-label>
</ng-template>
<ng-template #step1>
<ng-template #step0Footer>
<button
bitButton
buttonType="primary"
[disabled]="saveDisabled$ | async"
bitFormButton
type="submit"
>
@let autoConfirmEnabled = autoConfirmEnabled$ | async;
@let managePoliciesOnly = managePoliciesOnly$ | async;
@if (autoConfirmEnabled || managePoliciesOnly) {
{{ "save" | i18n }}
} @else {
{{ "continue" | i18n }}
}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
</ng-template>
<ng-template #step1Title>
{{ "howToTurnOnAutoConfirm" | i18n }}
</ng-template>
<ng-template #step1Content>
<div class="tw-flex tw-justify-center tw-mb-6">
<bit-svg class="tw-w-[233px]" [content]="autoConfirmSvg"></bit-svg>
</div>
@ -61,3 +101,19 @@
</li>
</ol>
</ng-template>
<ng-template #step1Footer>
<button
bitButton
buttonType="primary"
[disabled]="saveDisabled$ | async"
bitFormButton
type="submit"
>
{{ "openExtension" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "close" | i18n }}
</button>
</ng-template>

View File

@ -0,0 +1,234 @@
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { MockProxy, mock } from "jest-mock-extended";
import { of } from "rxjs";
import { AutomaticUserConfirmationService, AutoConfirmState } from "@bitwarden/auto-confirm";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { 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 { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import { AutoConfirmPolicy, AutoConfirmPolicyEditComponent } from "./auto-confirm-policy.component";
describe("AutoConfirmPolicy", () => {
it("has correct attributes", () => {
const policy = new AutoConfirmPolicy();
expect(policy.name).toBe("automaticUserConfirmation");
expect(policy.description).toBe("autoConfirmDescription");
expect(policy.type).toBe(PolicyType.AutoConfirm);
expect(policy.component).toBe(AutoConfirmPolicyEditComponent);
expect(policy.showDescription).toBe(false);
});
});
describe("AutoConfirmPolicyEditComponent — policySteps[0].sideEffect", () => {
let component: AutoConfirmPolicyEditComponent;
let fixture: ComponentFixture<AutoConfirmPolicyEditComponent>;
let accountService: FakeAccountService;
let organizationService: MockProxy<OrganizationService>;
let policyService: MockProxy<PolicyService>;
let policyApiService: MockProxy<PolicyApiServiceAbstraction>;
let autoConfirmService: MockProxy<AutomaticUserConfirmationService>;
let router: MockProxy<Router>;
const userId = newGuid() as UserId;
const orgId = newGuid() as OrganizationId;
/** Convenience factory for Organization-shaped test objects. */
function makeOrg(isAdmin: boolean, canManagePolicies: boolean): Organization {
return { id: orgId, isAdmin, canManagePolicies } as unknown as Organization;
}
/** Convenience factory for Policy-shaped test objects. */
function makePolicy(type: PolicyType, enabled: boolean): Policy {
return { type, enabled } as Policy;
}
beforeEach(async () => {
accountService = mockAccountServiceWith(userId);
organizationService = mock<OrganizationService>();
policyService = mock<PolicyService>();
policyApiService = mock<PolicyApiServiceAbstraction>();
autoConfirmService = mock<AutomaticUserConfirmationService>();
router = mock<Router>();
// Defaults: admin org, no policies, API calls succeed, setup dialog visible
organizationService.organizations$.mockReturnValue(of([makeOrg(true, false)]));
policyService.policies$.mockReturnValue(of([]));
policyApiService.putPolicyVNext.mockResolvedValue(undefined);
autoConfirmService.configuration$.mockReturnValue(
of(Object.assign(new AutoConfirmState(), { showSetupDialog: true })),
);
autoConfirmService.upsert.mockResolvedValue(undefined);
await TestBed.configureTestingModule({
providers: [
{ provide: AccountService, useValue: accountService },
{ provide: KeyService, useValue: mock<KeyService>() },
{ provide: OrganizationService, useValue: organizationService },
{ provide: PolicyService, useValue: policyService },
{ provide: PolicyApiServiceAbstraction, useValue: policyApiService },
{ provide: AutomaticUserConfirmationService, useValue: autoConfirmService },
{ provide: Router, useValue: router },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(AutoConfirmPolicyEditComponent);
component = fixture.componentInstance;
// Simulate the inputs that MultiStepPolicyEditDialogComponent provides
fixture.componentRef.setInput("organizationId", orgId);
fixture.componentRef.setInput("policy", new AutoConfirmPolicy());
component.enabled.setValue(true);
// Intentionally skip detectChanges() — viewChild signals are not needed for sideEffect tests
});
async function runSideEffect() {
return component.policySteps[0].sideEffect!();
}
describe("SingleOrg prerequisite enablement", () => {
it("enables SingleOrg before saving AutoConfirm when SingleOrg is not already enabled", async () => {
policyService.policies$.mockReturnValue(of([]));
await runSideEffect();
expect(policyApiService.putPolicyVNext).toHaveBeenCalledWith(orgId, PolicyType.SingleOrg, {
policy: { enabled: true, data: null },
metadata: null,
});
expect(policyApiService.putPolicyVNext).toHaveBeenCalledWith(
orgId,
PolicyType.AutoConfirm,
expect.objectContaining({ policy: expect.objectContaining({ enabled: true }) }),
);
});
it("does not enable SingleOrg when it is already enabled", async () => {
policyService.policies$.mockReturnValue(of([makePolicy(PolicyType.SingleOrg, true)]));
await runSideEffect();
const singleOrgEnableCalls = policyApiService.putPolicyVNext.mock.calls.filter(
([, type, req]) => type === PolicyType.SingleOrg && req?.policy?.enabled === true,
);
expect(singleOrgEnableCalls).toHaveLength(0);
});
it("saves AutoConfirm with the enabled value from the form control", async () => {
component.enabled.setValue(false);
await runSideEffect();
expect(policyApiService.putPolicyVNext).toHaveBeenCalledWith(
orgId,
PolicyType.AutoConfirm,
expect.objectContaining({ policy: expect.objectContaining({ enabled: false }) }),
);
});
});
describe("rollback on AutoConfirm save failure", () => {
it("rolls back SingleOrg when AutoConfirm save fails and SingleOrg was enabled during this action", async () => {
policyService.policies$.mockReturnValue(of([])); // SingleOrg not previously enabled
policyApiService.putPolicyVNext
.mockResolvedValueOnce(undefined) // SingleOrg enable → success
.mockRejectedValueOnce(new Error("network error")) // AutoConfirm save → fail
.mockResolvedValueOnce(undefined); // SingleOrg rollback → success
await expect(runSideEffect()).rejects.toThrow("network error");
expect(policyApiService.putPolicyVNext).toHaveBeenCalledWith(orgId, PolicyType.SingleOrg, {
policy: { enabled: false, data: null },
metadata: null,
});
});
it("does not roll back SingleOrg when AutoConfirm save fails but SingleOrg was already enabled", async () => {
policyService.policies$.mockReturnValue(
of([makePolicy(PolicyType.SingleOrg, true)]), // SingleOrg already on
);
policyApiService.putPolicyVNext.mockRejectedValueOnce(new Error("network error"));
await expect(runSideEffect()).rejects.toThrow("network error");
const singleOrgDisableCalls = policyApiService.putPolicyVNext.mock.calls.filter(
([, type, req]) => type === PolicyType.SingleOrg && req?.policy?.enabled === false,
);
expect(singleOrgDisableCalls).toHaveLength(0);
});
});
describe("dialog close behavior", () => {
it("returns { closeDialog: true } when disabling AutoConfirm (no extension step needed)", async () => {
component.enabled.setValue(false);
const result = await runSideEffect();
expect(result).toEqual({ closeDialog: true });
});
it("returns { closeDialog: true } when user has manage-policies permission only (no admin role)", async () => {
organizationService.organizations$.mockReturnValue(
of([makeOrg(false, true)]), // not admin, but canManagePolicies
);
component.enabled.setValue(true);
const result = await runSideEffect();
expect(result).toEqual({ closeDialog: true });
});
it("returns undefined to proceed to the extension step when enabling as a full admin", async () => {
organizationService.organizations$.mockReturnValue(of([makeOrg(true, false)]));
component.enabled.setValue(true);
const result = await runSideEffect();
expect(result).toBeUndefined();
});
});
describe("setup dialog dismissal", () => {
it("dismisses the first-time setup dialog prompt after a successful save", async () => {
const currentState = Object.assign(new AutoConfirmState(), { showSetupDialog: true });
autoConfirmService.configuration$.mockReturnValue(of(currentState));
await runSideEffect();
expect(autoConfirmService.upsert).toHaveBeenCalledWith(userId, {
...currentState,
showSetupDialog: false,
});
});
it("preserves the rest of the AutoConfirmState when dismissing the setup dialog", async () => {
const currentState = Object.assign(new AutoConfirmState(), {
enabled: true,
showSetupDialog: true,
showBrowserNotification: false,
});
autoConfirmService.configuration$.mockReturnValue(of(currentState));
await runSideEffect();
expect(autoConfirmService.upsert).toHaveBeenCalledWith(userId, {
enabled: true,
showSetupDialog: false,
showBrowserNotification: false,
});
});
});
});

View File

@ -1,25 +1,26 @@
// FIXME(https://bitwarden.atlassian.net/browse/CL-1062): `OnPush` components should not use mutable properties
/* eslint-disable @bitwarden/components/enforce-readonly-angular-properties */
import {
ChangeDetectionStrategy,
Component,
OnInit,
Signal,
TemplateRef,
viewChild,
} from "@angular/core";
import { BehaviorSubject, map, Observable } from "rxjs";
import { ChangeDetectionStrategy, Component, Signal, TemplateRef, viewChild } from "@angular/core";
import { Router } from "@angular/router";
import { combineLatest, defer, firstValueFrom, map, Observable, startWith, switchMap } from "rxjs";
import { AutoConfirmSvg } from "@bitwarden/assets/svg";
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 { 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 { getById } from "@bitwarden/common/platform/misc";
import { SharedModule } from "../../../../shared";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
import { PolicyCategory } from "../pipes/policy-category";
import { AutoConfirmPolicyDialogComponent } from "../policy-edit-dialogs/auto-confirm-edit-policy-dialog.component";
import {
MultiStepPolicyEditDialogComponent,
PolicyStep,
PolicyStepResult,
} from "../policy-edit-dialogs";
export class AutoConfirmPolicy extends BasePolicyEditDefinition {
name = "automaticUserConfirmation";
@ -29,7 +30,11 @@ export class AutoConfirmPolicy extends BasePolicyEditDefinition {
priority = 90;
component = AutoConfirmPolicyEditComponent;
showDescription = false;
editDialogComponent = AutoConfirmPolicyDialogComponent;
editDialogComponent = MultiStepPolicyEditDialogComponent;
constructor(readonly firstTimeDialog: boolean = false) {
super();
}
override display$(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService
@ -44,21 +49,133 @@ export class AutoConfirmPolicy extends BasePolicyEditDefinition {
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AutoConfirmPolicyEditComponent extends BasePolicyEditComponent implements OnInit {
protected readonly autoConfirmSvg = AutoConfirmSvg;
private readonly policyForm: Signal<TemplateRef<any> | undefined> = viewChild("step0");
private readonly extensionButton: Signal<TemplateRef<any> | undefined> = viewChild("step1");
protected step: number = 0;
protected steps = [this.policyForm, this.extensionButton];
protected singleOrgEnabled$: BehaviorSubject<boolean> = new BehaviorSubject(false);
setSingleOrgEnabled(enabled: boolean) {
this.singleOrgEnabled$.next(enabled);
export class AutoConfirmPolicyEditComponent extends BasePolicyEditComponent {
constructor(
private readonly organizationService: OrganizationService,
private readonly policyService: PolicyService,
private readonly autoConfirmService: AutomaticUserConfirmationService,
private readonly router: Router,
) {
super();
}
setStep(step: number) {
this.step = step;
protected readonly autoConfirmSvg = AutoConfirmSvg;
protected get autoConfirmPolicy(): AutoConfirmPolicy | undefined {
return this.policy() as AutoConfirmPolicy | undefined;
}
private readonly step0Title: Signal<TemplateRef<unknown>> = viewChild.required("step0Title");
private readonly step0Content: Signal<TemplateRef<unknown>> = viewChild.required("step0Content");
private readonly step0Footer: Signal<TemplateRef<unknown>> = viewChild.required("step0Footer");
private readonly step1Title: Signal<TemplateRef<unknown>> = viewChild.required("step1Title");
private readonly step1Content: Signal<TemplateRef<unknown>> = viewChild.required("step1Content");
private readonly step1Footer: Signal<TemplateRef<unknown>> = viewChild.required("step1Footer");
protected readonly autoConfirmEnabled$: Observable<boolean> =
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm)?.enabled ?? false),
);
protected readonly singleOrgEnabled$: Observable<boolean> =
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
map((policies) => policies.find((p) => p.type === PolicyType.SingleOrg)?.enabled ?? false),
);
// defer() ensures this.organizationId() is read at subscription time rather than at
// class-field initialization time, where it would still be undefined.
protected readonly managePoliciesOnly$: Observable<boolean> = defer(() =>
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.organizationService.organizations$(userId)),
getById(this.organizationId()),
map((organization) => (!organization?.isAdmin && organization?.canManagePolicies) ?? false),
),
);
protected readonly saveDisabled$ = combineLatest([
this.autoConfirmEnabled$,
this.enabled.valueChanges.pipe(startWith(this.enabled.value)),
]).pipe(map(([policyEnabled, value]) => !policyEnabled && !value));
readonly policySteps: PolicyStep[] = [
{
titleContent: this.step0Title,
bodyContent: this.step0Content,
footerContent: this.step0Footer,
disableSave: this.saveDisabled$,
sideEffect: () => this.savePolicy(),
},
{
titleContent: this.step1Title,
bodyContent: this.step1Content,
footerContent: this.step1Footer,
sideEffect: () => this.navigateToExtensionPromptStep(),
},
];
protected override async savePolicy(): Promise<PolicyStepResult | void> {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const organizations = await firstValueFrom(this.organizationService.organizations$(userId));
const organization = organizations.find((o) => o.id === this.organizationId()) ?? null;
const managePoliciesOnly = (!organization?.isAdmin && organization?.canManagePolicies) ?? false;
const policies = await firstValueFrom(this.policyService.policies$(userId));
const singleOrgAlreadyEnabled =
policies.find((p) => p.type === PolicyType.SingleOrg)?.enabled ?? false;
const enabledSingleOrgDuringAction = !singleOrgAlreadyEnabled;
// AutoConfirm requires SingleOrg; enable it as a prerequisite if not already on.
if (enabledSingleOrgDuringAction) {
await this.policyApiService.putPolicyVNext(
this.organizationId() ?? "",
PolicyType.SingleOrg,
{
policy: { enabled: true, data: null },
metadata: null,
},
);
}
try {
const request = await this.buildRequest();
await this.policyApiService.putPolicyVNext(
this.organizationId() ?? "",
PolicyType.AutoConfirm,
{ policy: request, metadata: null },
);
} catch (error) {
// Roll back the SingleOrg enablement if AutoConfirm save fails.
if (enabledSingleOrgDuringAction) {
await this.policyApiService.putPolicyVNext(
this.organizationId() ?? "",
PolicyType.SingleOrg,
{ policy: { enabled: false, data: null }, metadata: null },
);
}
throw error;
}
// Dismiss the first-time setup dialog prompt now that the admin has configured the policy.
const currentState = await firstValueFrom(this.autoConfirmService.configuration$(userId));
await this.autoConfirmService.upsert(userId, { ...currentState, showSetupDialog: false });
// Close immediately when disabling (no extension step needed) or when the user only has
// manage-policies permission and cannot configure the client-side extension setting.
if (!this.enabled.value || managePoliciesOnly) {
return { closeDialog: true };
}
}
private async navigateToExtensionPromptStep(): Promise<void> {
await this.router.navigate(["/browser-extension-prompt"], {
queryParams: { url: "AutoConfirm" },
});
}
}

View File

@ -3,9 +3,11 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { KeyService } from "@bitwarden/key-management";
import { MasterPasswordPolicyComponent } from "./master-password.component";
@ -19,6 +21,8 @@ describe("MasterPasswordPolicyComponent", () => {
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: OrganizationService, useValue: mock<OrganizationService>() },
{ provide: AccountService, useValue: mock<AccountService>() },
{ provide: KeyService, useValue: mock<KeyService>() },
{ provide: PolicyApiServiceAbstraction, useValue: mock<PolicyApiServiceAbstraction>() },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();

View File

@ -13,7 +13,6 @@ import {
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@ -61,7 +60,6 @@ export class MasterPasswordPolicyComponent extends BasePolicyEditComponent imple
private formBuilder: FormBuilder,
i18nService: I18nService,
private organizationService: OrganizationService,
private accountService: AccountService,
) {
super();
@ -81,7 +79,7 @@ export class MasterPasswordPolicyComponent extends BasePolicyEditComponent imple
const organization = await firstValueFrom(
this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(this.policyResponse.organizationId)),
.pipe(getOrganizationById(this.policyResponse()!.organizationId)),
);
this.showKeyConnectorInfo = organization.keyConnectorEnabled;
}

View File

@ -58,8 +58,8 @@ export class OrganizationDataOwnershipPolicyComponent
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("dialog", { static: true }) warningContent!: TemplateRef<unknown>;
override async confirm(): Promise<boolean> {
if (this.policyResponse?.enabled && !this.enabled.value) {
async confirm(): Promise<boolean> {
if (this.policyResponse()?.enabled && !this.enabled.value) {
const dialogRef = this.dialogService.open(this.warningContent, {
positionStrategy: new CenterPositionStrategy(),
});
@ -72,7 +72,7 @@ export class OrganizationDataOwnershipPolicyComponent
async buildVNextRequest(
orgKey: OrgKey,
): Promise<VNextSaveOrganizationDataOwnershipPolicyRequest> {
if (!this.policy) {
if (!this.policy()) {
throw new Error("Policy was not found");
}

View File

@ -3,10 +3,13 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyStatusResponse } from "@bitwarden/common/admin-console/models/response/policy-status.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import {
RemoveUnlockWithPinPolicy,
@ -34,6 +37,9 @@ describe("RemoveUnlockWithPinPolicyComponent", () => {
providers: [
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: I18nService, useValue: i18nService },
{ provide: AccountService, useValue: mock<AccountService>() },
{ provide: KeyService, useValue: mock<KeyService>() },
{ provide: PolicyApiServiceAbstraction, useValue: mock<PolicyApiServiceAbstraction>() },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
@ -43,11 +49,14 @@ describe("RemoveUnlockWithPinPolicyComponent", () => {
});
it("input selected on load when policy enabled", async () => {
component.policyResponse = new PolicyStatusResponse({
organizationId: "org1",
type: PolicyType.RemoveUnlockWithPin,
enabled: true,
});
fixture.componentRef.setInput(
"policyResponse",
new PolicyStatusResponse({
organizationId: "org1",
type: PolicyType.RemoveUnlockWithPin,
enabled: true,
}),
);
component.ngOnInit();
fixture.detectChanges();
@ -63,11 +72,14 @@ describe("RemoveUnlockWithPinPolicyComponent", () => {
});
it("input not selected on load when policy disabled", async () => {
component.policyResponse = new PolicyStatusResponse({
organizationId: "org1",
type: PolicyType.RemoveUnlockWithPin,
enabled: false,
});
fixture.componentRef.setInput(
"policyResponse",
new PolicyStatusResponse({
organizationId: "org1",
type: PolicyType.RemoveUnlockWithPin,
enabled: false,
}),
);
component.ngOnInit();
fixture.detectChanges();
@ -83,11 +95,14 @@ describe("RemoveUnlockWithPinPolicyComponent", () => {
});
it("turn on message label", async () => {
component.policyResponse = new PolicyStatusResponse({
organizationId: "org1",
type: PolicyType.RemoveUnlockWithPin,
enabled: false,
});
fixture.componentRef.setInput(
"policyResponse",
new PolicyStatusResponse({
organizationId: "org1",
type: PolicyType.RemoveUnlockWithPin,
enabled: false,
}),
);
i18nService.t.mockReturnValue("Turn on");
component.ngOnInit();
@ -99,12 +114,15 @@ describe("RemoveUnlockWithPinPolicyComponent", () => {
});
it("buildVNextRequest should delegate to buildRequest and wrap with null metadata", async () => {
component.policy = new RemoveUnlockWithPinPolicy();
component.policyResponse = new PolicyStatusResponse({
organizationId: "org1",
type: PolicyType.RemoveUnlockWithPin,
enabled: true,
});
fixture.componentRef.setInput("policy", new RemoveUnlockWithPinPolicy());
fixture.componentRef.setInput(
"policyResponse",
new PolicyStatusResponse({
organizationId: "org1",
type: PolicyType.RemoveUnlockWithPin,
enabled: true,
}),
);
component.ngOnInit();
const buildRequestSpy = jest.spyOn(component, "buildRequest");

View File

@ -11,7 +11,6 @@ import {
} from "@bitwarden/common/admin-console/abstractions/organization/organization.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";
@ -59,7 +58,6 @@ export class ResetPasswordPolicyComponent extends BasePolicyEditComponent implem
constructor(
private formBuilder: FormBuilder,
private organizationService: OrganizationService,
private accountService: AccountService,
) {
super();
}
@ -73,14 +71,14 @@ export class ResetPasswordPolicyComponent extends BasePolicyEditComponent implem
throw new Error("No user found.");
}
if (!this.policyResponse) {
if (!this.policyResponse()) {
throw new Error("Policies not found");
}
const organization = await firstValueFrom(
this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(this.policyResponse.organizationId)),
.pipe(getOrganizationById(this.policyResponse()!.organizationId)),
);
if (!organization) {

View File

@ -25,10 +25,10 @@ export class SingleOrgPolicyComponent extends BasePolicyEditComponent implements
async ngOnInit() {
super.ngOnInit();
if (!this.policyResponse) {
if (!this.policyResponse()) {
throw new Error("Policies not found");
}
if (!this.policyResponse.canToggleState) {
if (!this.policyResponse()!.canToggleState) {
this.enabled.disable();
}
}

View File

@ -56,7 +56,7 @@ export class UriMatchDefaultPolicyComponent extends BasePolicyEditComponent {
}
protected loadData() {
const uriMatchDetection = this.policyResponse?.data?.uriMatchDetection;
const uriMatchDetection = this.policyResponse()?.data?.uriMatchDetection;
this.data?.patchValue({
uriMatchDetection: uriMatchDetection,

View File

@ -5,6 +5,7 @@ import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyStatusResponse } from "@bitwarden/common/admin-console/models/response/policy-status.response";
@ -12,6 +13,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
import {
vNextOrganizationDataOwnershipPolicy,
@ -66,6 +68,8 @@ describe("vNextOrganizationDataOwnershipPolicyComponent", () => {
{ provide: EncryptService, useValue: mock<EncryptService>() },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: KeyService, useValue: mock<KeyService>() },
{ provide: PolicyApiServiceAbstraction, useValue: mock<PolicyApiServiceAbstraction>() },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
@ -76,7 +80,7 @@ describe("vNextOrganizationDataOwnershipPolicyComponent", () => {
describe("loadData with null server response", () => {
it("should default enableIndividualItemsTransfer to false when data is null", async () => {
component.policyResponse = makePolicyResponse(true, null);
fixture.componentRef.setInput("policyResponse", makePolicyResponse(true, null));
await component.ngOnInit();
@ -84,7 +88,10 @@ describe("vNextOrganizationDataOwnershipPolicyComponent", () => {
});
it("should default enableIndividualItemsTransfer to false when the attribute is null", async () => {
component.policyResponse = makePolicyResponse(true, { enableIndividualItemsTransfer: null });
fixture.componentRef.setInput(
"policyResponse",
makePolicyResponse(true, { enableIndividualItemsTransfer: null }),
);
await component.ngOnInit();
@ -95,7 +102,7 @@ describe("vNextOrganizationDataOwnershipPolicyComponent", () => {
describe("useMyItems conditional behavior", () => {
it("should enable enableIndividualItemsTransfer control when policy is enabled and organization.useMyItems is true", async () => {
setupOrg(true);
component.policyResponse = makePolicyResponse(true);
fixture.componentRef.setInput("policyResponse", makePolicyResponse(true));
await component.ngOnInit();
@ -104,7 +111,7 @@ describe("vNextOrganizationDataOwnershipPolicyComponent", () => {
it("should keep enableIndividualItemsTransfer control disabled when policy is enabled but organization.useMyItems is false", async () => {
setupOrg(false);
component.policyResponse = makePolicyResponse(true);
fixture.componentRef.setInput("policyResponse", makePolicyResponse(true));
await component.ngOnInit();
@ -113,7 +120,7 @@ describe("vNextOrganizationDataOwnershipPolicyComponent", () => {
it("should keep enableIndividualItemsTransfer control disabled when organization is not found", async () => {
// mockOrganizationService returns [] by default (no org found)
component.policyResponse = makePolicyResponse(true);
fixture.componentRef.setInput("policyResponse", makePolicyResponse(true));
await component.ngOnInit();
@ -122,7 +129,7 @@ describe("vNextOrganizationDataOwnershipPolicyComponent", () => {
it("should enable enableIndividualItemsTransfer control when enabled changes to true and useMyItems is true", async () => {
setupOrg(true);
component.policyResponse = makePolicyResponse(false);
fixture.componentRef.setInput("policyResponse", makePolicyResponse(false));
await component.ngOnInit();
component.enabled.setValue(true);
@ -132,7 +139,7 @@ describe("vNextOrganizationDataOwnershipPolicyComponent", () => {
it("should keep enableIndividualItemsTransfer control disabled when enabled changes to true but useMyItems is false", async () => {
setupOrg(false);
component.policyResponse = makePolicyResponse(false);
fixture.componentRef.setInput("policyResponse", makePolicyResponse(false));
await component.ngOnInit();
component.enabled.setValue(true);
@ -144,7 +151,10 @@ describe("vNextOrganizationDataOwnershipPolicyComponent", () => {
describe("buildRequestData", () => {
it("should return enableIndividualItemsTransfer: false when useMyItems is false, even if control value is true", async () => {
setupOrg(false);
component.policyResponse = makePolicyResponse(true, { enableIndividualItemsTransfer: true });
fixture.componentRef.setInput(
"policyResponse",
makePolicyResponse(true, { enableIndividualItemsTransfer: true }),
);
await component.ngOnInit();
@ -155,7 +165,10 @@ describe("vNextOrganizationDataOwnershipPolicyComponent", () => {
it("should return enableIndividualItemsTransfer: true when useMyItems is true and control value is true", async () => {
setupOrg(true);
component.policyResponse = makePolicyResponse(true, { enableIndividualItemsTransfer: true });
fixture.componentRef.setInput(
"policyResponse",
makePolicyResponse(true, { enableIndividualItemsTransfer: true }),
);
await component.ngOnInit();

View File

@ -7,7 +7,6 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { VNextSavePolicyRequest } from "@bitwarden/common/admin-console/models/request/v-next-save-policy.request";
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 { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
@ -21,7 +20,8 @@ import { EncString } from "@bitwarden/sdk-internal";
import { SharedModule } from "../../../../shared";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
import { PolicyCategory } from "../pipes/policy-category";
import { OrganizationDataOwnershipPolicyDialogComponent } from "../policy-edit-dialogs";
import { MultiStepPolicyEditDialogComponent } from "../policy-edit-dialogs";
import { PolicyStep } from "../policy-edit-dialogs/models";
type VNextSaveOrganizationDataOwnershipPolicyRequest = VNextSavePolicyRequest<{
defaultUserCollectionName: string;
@ -40,7 +40,7 @@ export class vNextOrganizationDataOwnershipPolicy extends BasePolicyEditDefiniti
component = vNextOrganizationDataOwnershipPolicyComponent;
showDescription = false;
editDialogComponent = OrganizationDataOwnershipPolicyDialogComponent;
editDialogComponent = MultiStepPolicyEditDialogComponent;
override display$(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService.getFeatureFlag$(FeatureFlag.MigrateMyVaultToMyItems);
@ -64,7 +64,6 @@ export class vNextOrganizationDataOwnershipPolicyComponent
private readonly encryptService: EncryptService,
private readonly formBuilder: FormBuilder,
private readonly organizationService: OrganizationService,
private readonly accountService: AccountService,
) {
super();
@ -78,6 +77,12 @@ export class vNextOrganizationDataOwnershipPolicyComponent
});
}
override readonly policySteps: PolicyStep[] = [
{
sideEffect: () => this.savePolicy(),
},
];
readonly data = this.formBuilder.group({
enableIndividualItemsTransfer: [{ value: false, disabled: true }],
});
@ -90,7 +95,7 @@ export class vNextOrganizationDataOwnershipPolicyComponent
override async ngOnInit(): Promise<void> {
super.ngOnInit();
const orgId = this.policyResponse?.organizationId as OrganizationId | undefined;
const orgId = this.policyResponse()?.organizationId as OrganizationId | undefined;
if (orgId) {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const org = await firstValueFrom(
@ -105,11 +110,11 @@ export class vNextOrganizationDataOwnershipPolicyComponent
}
protected override loadData() {
if (!this.policyResponse?.data) {
if (!this.policyResponse()?.data) {
return;
}
const data = this.policyResponse.data as OrganizationDataOwnershipPolicyData;
const data = this.policyResponse()!.data as OrganizationDataOwnershipPolicyData;
this.data.patchValue({
enableIndividualItemsTransfer: data.enableIndividualItemsTransfer ?? false,
});
@ -126,7 +131,7 @@ export class vNextOrganizationDataOwnershipPolicyComponent
async buildVNextRequest(
orgKey: OrgKey,
): Promise<VNextSaveOrganizationDataOwnershipPolicyRequest> {
if (!this.policy) {
if (!this.policy()) {
throw new Error("Policy was not found");
}

View File

@ -5,6 +5,7 @@ import {
Component,
DestroyRef,
Inject,
Signal,
ViewContainerRef,
inject,
signal,
@ -61,7 +62,8 @@ export class PolicyEditDialogComponent implements AfterViewInit {
protected readonly policyType = PolicyType;
protected readonly loading = signal(true);
protected readonly enabled = false;
protected readonly saveDisabled = signal(false);
private readonly _saveDisabled = signal(false);
protected readonly saveDisabled: Signal<boolean> = this._saveDisabled;
protected readonly policyComponent = signal<BasePolicyEditComponent | undefined>(undefined);
readonly formGroup = this.formBuilder.group({
@ -106,9 +108,10 @@ export class PolicyEditDialogComponent implements AfterViewInit {
throw new Error("Template not initialized.");
}
const component = policyFormRef.createComponent(this.data.policy.component).instance;
component.policy = this.data.policy;
component.policyResponse = policyResponse;
const componentRef = policyFormRef.createComponent(this.data.policy.component);
componentRef.setInput("policy", this.data.policy);
componentRef.setInput("policyResponse", policyResponse);
const component = componentRef.instance;
this.policyComponent.set(component);
if (component.data) {
@ -117,7 +120,7 @@ export class PolicyEditDialogComponent implements AfterViewInit {
map((status) => status !== "VALID" || !policyResponse.canToggleState),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((disabled) => this.saveDisabled.set(disabled));
.subscribe((disabled) => this._saveDisabled.set(disabled));
}
this.cdr.detectChanges();
@ -145,7 +148,7 @@ export class PolicyEditDialogComponent implements AfterViewInit {
throw new Error("PolicyComponent not initialized.");
}
if ((await policyComponent.confirm()) == false) {
if ((await policyComponent.confirm?.()) == false) {
this.dialogRef.close();
return;
}

View File

@ -1,93 +0,0 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [loading]="loading()">
<ng-container bitDialogTitle>
@let title = (multiStepSubmit | async)[currentStep()]?.titleContent();
@if (title) {
<ng-container [ngTemplateOutlet]="title"></ng-container>
}
</ng-container>
<ng-container bitDialogContent>
@if (loading()) {
<div>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
}
<div [hidden]="loading()">
@if (policy.showDescription) {
<p bitTypography="body1">{{ policy.description | i18n }}</p>
}
</div>
<ng-template #policyForm></ng-template>
</ng-container>
<ng-container bitDialogFooter>
@let footer = (multiStepSubmit | async)[currentStep()]?.footerContent();
@if (footer) {
<ng-container [ngTemplateOutlet]="footer"></ng-container>
}
</ng-container>
</bit-dialog>
</form>
<ng-template #step0Title>
<div class="tw-flex tw-flex-col">
@let showBadge = firstTimeDialog();
@if (showBadge) {
<span bitBadge variant="info" class="tw-w-[99px] tw-my-2"> {{ "availableNow" | i18n }}</span>
}
<span class="tw-flex tw-items-center tw-gap-2">
{{ (showBadge ? "automaticUserConfirmation" : "editPolicy") | i18n }}
@if (!showBadge) {
<span class="tw-text-muted tw-font-normal tw-text-sm">
{{ policy.name | i18n }}
</span>
}
</span>
</div>
</ng-template>
<ng-template #step1Title>
{{ "howToTurnOnAutoConfirm" | i18n }}
</ng-template>
<ng-template #step0>
<button
bitButton
buttonType="primary"
[disabled]="saveDisabled$ | async"
bitFormButton
type="submit"
>
@let autoConfirmEnabled = autoConfirmEnabled$ | async;
@let managePoliciesOnly = managePoliciesOnly$ | async;
@if (autoConfirmEnabled || managePoliciesOnly) {
{{ "save" | i18n }}
} @else {
{{ "continue" | i18n }}
}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
</ng-template>
<ng-template #step1>
<button
bitButton
buttonType="primary"
[disabled]="saveDisabled$ | async"
bitFormButton
type="submit"
endIcon="bwi-external-link"
>
{{ "openExtension" | i18n }}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "close" | i18n }}
</button>
</ng-template>

View File

@ -1,276 +0,0 @@
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormBuilder } from "@angular/forms";
import { Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components";
import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import { AutoConfirmPolicyEditComponent } from "../policy-edit-definitions/auto-confirm-policy.component";
import {
AutoConfirmPolicyDialogComponent,
AutoConfirmPolicyDialogData,
} from "./auto-confirm-edit-policy-dialog.component";
describe("AutoConfirmPolicyDialogComponent", () => {
let component: AutoConfirmPolicyDialogComponent;
let fixture: ComponentFixture<AutoConfirmPolicyDialogComponent>;
let mockPolicyApiService: MockProxy<PolicyApiServiceAbstraction>;
let mockAccountService: FakeAccountService;
let mockOrganizationService: MockProxy<OrganizationService>;
let mockPolicyService: MockProxy<PolicyService>;
let mockRouter: MockProxy<Router>;
let mockAutoConfirmService: MockProxy<AutomaticUserConfirmationService>;
let mockDialogRef: MockProxy<DialogRef>;
let mockToastService: MockProxy<ToastService>;
let mockI18nService: MockProxy<I18nService>;
let mockKeyService: MockProxy<KeyService>;
const mockUserId = newGuid() as UserId;
const mockOrgId = newGuid() as OrganizationId;
const mockOrg = {
id: mockOrgId,
name: "Test Organization",
enabled: true,
isAdmin: true,
canManagePolicies: true,
} as Organization;
const mockDialogData: AutoConfirmPolicyDialogData = {
organization: mockOrg,
policy: {
name: "automaticUserConfirmation",
description: "Auto Confirm Policy",
type: PolicyType.AutoConfirm,
component: {} as any,
showDescription: true,
display$: () => of(true),
},
firstTimeDialog: false,
};
beforeEach(async () => {
mockPolicyApiService = mock<PolicyApiServiceAbstraction>();
mockAccountService = mockAccountServiceWith(mockUserId);
mockOrganizationService = mock<OrganizationService>();
mockPolicyService = mock<PolicyService>();
mockRouter = mock<Router>();
mockAutoConfirmService = mock<AutomaticUserConfirmationService>();
mockDialogRef = mock<DialogRef>();
mockToastService = mock<ToastService>();
mockI18nService = mock<I18nService>();
mockKeyService = mock<KeyService>();
mockPolicyService.policies$.mockReturnValue(of([]));
mockOrganizationService.organizations$.mockReturnValue(of([mockOrg]));
await TestBed.configureTestingModule({
imports: [AutoConfirmPolicyDialogComponent],
providers: [
FormBuilder,
{ provide: DIALOG_DATA, useValue: mockDialogData },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
{ provide: I18nService, useValue: mockI18nService },
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: ToastService, useValue: mockToastService },
{ provide: KeyService, useValue: mockKeyService },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: PolicyService, useValue: mockPolicyService },
{ provide: Router, useValue: mockRouter },
{ provide: AutomaticUserConfirmationService, useValue: mockAutoConfirmService },
],
schemas: [NO_ERRORS_SCHEMA],
})
.overrideComponent(AutoConfirmPolicyDialogComponent, {
set: { template: "<div></div>" },
})
.compileComponents();
fixture = TestBed.createComponent(AutoConfirmPolicyDialogComponent);
component = fixture.componentInstance;
});
afterEach(() => {
jest.resetAllMocks();
});
it("should create", () => {
expect(component).toBeTruthy();
});
describe("handleSubmit", () => {
beforeEach(() => {
// Mock the policyComponent as an AutoConfirmPolicyEditComponent instance so instanceof check passes
const mockPolicyEditComponent = Object.assign(
Object.create(AutoConfirmPolicyEditComponent.prototype),
{
buildRequest: jest.fn().mockResolvedValue({ enabled: true, data: null }),
enabled: { value: true },
setSingleOrgEnabled: jest.fn(),
},
) as AutoConfirmPolicyEditComponent;
component["policyComponent"].set(mockPolicyEditComponent);
mockAutoConfirmService.configuration$.mockReturnValue(
of({ enabled: false, showSetupDialog: true, showBrowserNotification: undefined }),
);
mockAutoConfirmService.upsert.mockResolvedValue(undefined);
mockI18nService.t.mockReturnValue("Policy updated");
});
it("should enable SingleOrg policy when it was not already enabled", async () => {
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
// Call handleSubmit with singleOrgEnabled = false (meaning it needs to be enabled)
await component["handleSubmit"](false);
// First call should be SingleOrg enable
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
1,
mockOrgId,
PolicyType.SingleOrg,
{ policy: { enabled: true, data: null } },
);
});
it("should not enable SingleOrg policy when it was already enabled", async () => {
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
// Call handleSubmit with singleOrgEnabled = true (meaning it's already enabled)
await component["handleSubmit"](true);
// Should only call putPolicyVNext once (for AutoConfirm, not SingleOrg)
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(1);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
mockOrgId,
PolicyType.AutoConfirm,
{ policy: { enabled: true, data: null } },
);
});
it("should rollback SingleOrg policy when AutoConfirm fails and SingleOrg was enabled during action", async () => {
const autoConfirmError = new Error("AutoConfirm failed");
// First call (SingleOrg enable) succeeds, second call (AutoConfirm) fails, third call (SingleOrg rollback) succeeds
mockPolicyApiService.putPolicyVNext
.mockResolvedValueOnce({} as any) // SingleOrg enable
.mockRejectedValueOnce(autoConfirmError) // AutoConfirm fails
.mockResolvedValueOnce({} as any); // SingleOrg rollback
await expect(component["handleSubmit"](false)).rejects.toThrow("AutoConfirm failed");
// Verify: SingleOrg enabled, AutoConfirm attempted, SingleOrg rolled back
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(3);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
1,
mockOrgId,
PolicyType.SingleOrg,
{ policy: { enabled: true, data: null } },
);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
2,
mockOrgId,
PolicyType.AutoConfirm,
{ policy: { enabled: true, data: null } },
);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
3,
mockOrgId,
PolicyType.SingleOrg,
{ policy: { enabled: false, data: null } },
);
});
it("should not rollback SingleOrg policy when AutoConfirm fails but SingleOrg was already enabled", async () => {
const autoConfirmError = new Error("AutoConfirm failed");
// AutoConfirm call fails (SingleOrg was already enabled, so no SingleOrg calls)
mockPolicyApiService.putPolicyVNext.mockRejectedValue(autoConfirmError);
await expect(component["handleSubmit"](true)).rejects.toThrow("AutoConfirm failed");
// Verify only AutoConfirm was called (no SingleOrg enable/rollback)
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(1);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
mockOrgId,
PolicyType.AutoConfirm,
{ policy: { enabled: true, data: null } },
);
});
it("should keep both policies enabled when both submissions succeed", async () => {
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
await component["handleSubmit"](false);
// Verify two calls: SingleOrg enable and AutoConfirm enable
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(2);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
1,
mockOrgId,
PolicyType.SingleOrg,
{ policy: { enabled: true, data: null } },
);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith(
2,
mockOrgId,
PolicyType.AutoConfirm,
{ policy: { enabled: true, data: null } },
);
});
it("should re-throw the error after rollback", async () => {
const autoConfirmError = new Error("Network error");
mockPolicyApiService.putPolicyVNext
.mockResolvedValueOnce({} as any) // SingleOrg enable
.mockRejectedValueOnce(autoConfirmError) // AutoConfirm fails
.mockResolvedValueOnce({} as any); // SingleOrg rollback
await expect(component["handleSubmit"](false)).rejects.toThrow("Network error");
});
});
describe("setSingleOrgPolicy", () => {
it("should call putPolicyVNext with enabled: true when enabling", async () => {
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
await component["setSingleOrgPolicy"](true);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
mockOrgId,
PolicyType.SingleOrg,
{ policy: { enabled: true, data: null } },
);
});
it("should call putPolicyVNext with enabled: false when disabling", async () => {
mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any);
await component["setSingleOrgPolicy"](false);
expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith(
mockOrgId,
PolicyType.SingleOrg,
{ policy: { enabled: false, data: null } },
);
});
});
});

View File

@ -1,298 +0,0 @@
import {
AfterViewInit,
ChangeDetectorRef,
Component,
Inject,
signal,
Signal,
TemplateRef,
viewChild,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { Router } from "@angular/router";
import {
combineLatest,
firstValueFrom,
map,
Observable,
of,
shareReplay,
startWith,
switchMap,
tap,
} from "rxjs";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { getById } from "@bitwarden/common/platform/misc";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../../shared";
import { AutoConfirmPolicyEditComponent } from "../policy-edit-definitions/auto-confirm-policy.component";
import {
PolicyEditDialogComponent,
PolicyEditDialogData,
PolicyEditDialogResult,
} from "../policy-edit-dialog.component";
import { MultiStepSubmit } from "./models";
export type AutoConfirmPolicyDialogData = PolicyEditDialogData & {
firstTimeDialog?: boolean;
};
/**
* Custom policy dialog component for Auto-Confirm policy.
* Satisfies the PolicyDialogComponent interface structurally
* via its static open() function.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "auto-confirm-edit-policy-dialog.component.html",
imports: [SharedModule],
})
export class AutoConfirmPolicyDialogComponent
extends PolicyEditDialogComponent
implements AfterViewInit
{
policyType = PolicyType;
protected readonly firstTimeDialog = signal(false);
protected readonly currentStep = signal(0);
protected multiStepSubmit: Observable<MultiStepSubmit[]> = of([]);
protected autoConfirmEnabled$: Observable<boolean> = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm)?.enabled ?? false),
);
// Users with manage policies custom permission should not see the dialog's second step since
// they do not have permission to configure the setting. This will only allow them to configure
// the policy.
protected managePoliciesOnly$: Observable<boolean> = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.organizationService.organizations$(userId)),
getById(this.data.organization.id),
map((organization) => (!organization?.isAdmin && organization?.canManagePolicies) ?? false),
);
private readonly submitPolicy: Signal<TemplateRef<unknown> | undefined> = viewChild("step0");
private readonly openExtension: Signal<TemplateRef<unknown> | undefined> = viewChild("step1");
private readonly submitPolicyTitle: Signal<TemplateRef<unknown> | undefined> =
viewChild("step0Title");
private readonly openExtensionTitle: Signal<TemplateRef<unknown> | undefined> =
viewChild("step1Title");
protected saveDisabled$: Observable<boolean> | undefined;
private get typedPolicyComponent(): AutoConfirmPolicyEditComponent | undefined {
const component = this.policyComponent();
return component instanceof AutoConfirmPolicyEditComponent ? component : undefined;
}
constructor(
@Inject(DIALOG_DATA) protected data: AutoConfirmPolicyDialogData,
accountService: AccountService,
policyApiService: PolicyApiServiceAbstraction,
i18nService: I18nService,
cdr: ChangeDetectorRef,
formBuilder: FormBuilder,
dialogRef: DialogRef<PolicyEditDialogResult>,
toastService: ToastService,
keyService: KeyService,
private organizationService: OrganizationService,
private policyService: PolicyService,
private router: Router,
private autoConfirmService: AutomaticUserConfirmationService,
) {
super(
data,
accountService,
policyApiService,
i18nService,
cdr,
formBuilder,
dialogRef,
toastService,
keyService,
);
this.firstTimeDialog.set(data.firstTimeDialog ?? false);
}
/**
* Instantiates the child policy component and inserts it into the view.
*/
async ngAfterViewInit() {
await super.ngAfterViewInit();
const typedComponent = this.typedPolicyComponent;
if (typedComponent) {
this.saveDisabled$ = combineLatest([
this.autoConfirmEnabled$,
typedComponent.enabled.valueChanges.pipe(startWith(typedComponent.enabled.value)),
]).pipe(map(([policyEnabled, value]) => !policyEnabled && !value));
}
this.multiStepSubmit = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
map((policies) => policies.find((p) => p.type === PolicyType.SingleOrg)?.enabled ?? false),
tap((singleOrgPolicyEnabled) =>
this.typedPolicyComponent?.setSingleOrgEnabled(singleOrgPolicyEnabled),
),
switchMap((singleOrgPolicyEnabled) => this.buildMultiStepSubmit(singleOrgPolicyEnabled)),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
private buildMultiStepSubmit(singleOrgPolicyEnabled: boolean): Observable<MultiStepSubmit[]> {
return this.managePoliciesOnly$.pipe(
map((managePoliciesOnly) => {
const submitSteps = [
{
sideEffect: () => this.handleSubmit(singleOrgPolicyEnabled ?? false),
footerContent: this.submitPolicy,
titleContent: this.submitPolicyTitle,
},
];
if (!managePoliciesOnly) {
submitSteps.push({
sideEffect: () => this.openBrowserExtension(),
footerContent: this.openExtension,
titleContent: this.openExtensionTitle,
});
}
return submitSteps;
}),
);
}
private async handleSubmit(singleOrgEnabled: boolean) {
const enabledSingleOrgDuringAction = !singleOrgEnabled;
if (enabledSingleOrgDuringAction) {
await this.setSingleOrgPolicy(true);
}
try {
await this.submitAutoConfirm();
} catch (error) {
// Roll back SingleOrg if we enabled it during this action
if (enabledSingleOrgDuringAction) {
await this.setSingleOrgPolicy(false);
}
throw error;
}
}
/**
* Triggers policy submission for auto confirm.
* @returns boolean: true if multi-submit workflow should continue, false otherwise.
*/
private async submitAutoConfirm() {
const typedComponent = this.typedPolicyComponent;
if (!typedComponent) {
throw new Error("PolicyComponent not initialized.");
}
const autoConfirmRequest = await typedComponent.buildRequest();
await this.policyApiService.putPolicyVNext(this.data.organization.id, this.data.policy.type, {
policy: autoConfirmRequest,
});
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const currentAutoConfirmState = await firstValueFrom(
this.autoConfirmService.configuration$(userId),
);
await this.autoConfirmService.upsert(userId, {
...currentAutoConfirmState,
showSetupDialog: false,
});
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("editedPolicyId", this.i18nService.t(this.data.policy.name)),
});
if (!typedComponent.enabled.value) {
this.dialogRef.close("saved");
}
}
private async setSingleOrgPolicy(enabled: boolean): Promise<void> {
const singleOrgRequest: PolicyRequest = {
enabled,
data: null,
};
await this.policyApiService.putPolicyVNext(this.data.organization.id, PolicyType.SingleOrg, {
policy: singleOrgRequest,
});
}
private async openBrowserExtension() {
await this.router.navigate(["/browser-extension-prompt"], {
queryParams: { url: "AutoConfirm" },
});
}
submit = async () => {
const typedComponent = this.typedPolicyComponent;
if (!typedComponent) {
throw new Error("PolicyComponent not initialized.");
}
if ((await typedComponent.confirm()) == false) {
this.dialogRef.close();
return;
}
try {
const multiStepSubmit = await firstValueFrom(this.multiStepSubmit);
const sideEffect = multiStepSubmit[this.currentStep()].sideEffect;
if (sideEffect) {
await sideEffect();
}
if (this.currentStep() === multiStepSubmit.length - 1) {
this.dialogRef.close("saved");
return;
}
this.currentStep.update((value) => value + 1);
typedComponent.setStep(this.currentStep());
} catch (error: any) {
this.toastService.showToast({
variant: "error",
message: error.message,
});
}
};
static open = (
dialogService: DialogService,
config: DialogConfig<AutoConfirmPolicyDialogData>,
) => {
return dialogService.open<PolicyEditDialogResult>(AutoConfirmPolicyDialogComponent, config);
};
}

View File

@ -1,3 +1,2 @@
export * from "./auto-confirm-edit-policy-dialog.component";
export * from "./organization-data-ownership-edit-policy-dialog.component";
export * from "./multi-step-policy-edit-dialog.component";
export * from "./models";

View File

@ -1,7 +1,24 @@
import { Signal, TemplateRef } from "@angular/core";
import { Observable } from "rxjs";
export type MultiStepSubmit = {
sideEffect?: () => Promise<void>;
footerContent: Signal<TemplateRef<unknown> | undefined>;
titleContent: Signal<TemplateRef<unknown> | undefined>;
export type PolicyStepResult = { closeDialog?: boolean } | undefined;
export type PolicyStep = {
// Side effect to execute when submitting this step.
// Return { closeDialog: true } to show the success toast and close the dialog immediately,
// bypassing any remaining steps. Useful when a step conditionally ends the workflow early
// (e.g. when disabling a policy or when the user lacks permission to see subsequent steps).
sideEffect?: () => Promise<PolicyStepResult | void>;
// Optional: Custom title template. If undefined, uses default: "Edit policy" with policy name subtitle
titleContent?: Signal<TemplateRef<unknown> | undefined>;
// Optional: Custom body template. If undefined, renders the policy component's template
bodyContent?: Signal<TemplateRef<unknown> | undefined>;
// Optional: Custom footer template. If undefined, uses default: "Save" (primary) + "Cancel" (secondary)
footerContent?: Signal<TemplateRef<unknown> | undefined>;
// Optional: Observable to disable save button. If undefined, defaults to form validation state
disableSave?: Observable<boolean>;
};

View File

@ -0,0 +1,47 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
@let titleTemplate = policySteps()[currentStep()]?.titleContent?.();
<bit-dialog
[loading]="loading()"
[title]="titleTemplate ? undefined : ('editPolicy' | i18n)"
[subtitle]="titleTemplate ? undefined : (policy.name | i18n)"
>
<ng-container bitDialogTitle>
@if (titleTemplate) {
<ng-container [ngTemplateOutlet]="titleTemplate"></ng-container>
}
</ng-container>
<ng-container bitDialogContent>
@if (policy.showDescription) {
<p bitTypography="body1">{{ policy.description | i18n }}</p>
}
@let bodyTemplate = policySteps()[currentStep()]?.bodyContent?.();
@if (bodyTemplate) {
<ng-container [ngTemplateOutlet]="bodyTemplate"></ng-container>
}
<!-- Always rendered so viewChild resolves before ngAfterViewInit runs. -->
<ng-template #policyForm></ng-template>
</ng-container>
<ng-container bitDialogFooter>
@let footerTemplate = policySteps()[currentStep()]?.footerContent?.();
@if (footerTemplate) {
<ng-container [ngTemplateOutlet]="footerTemplate"></ng-container>
} @else {
<button
bitButton
buttonType="primary"
[disabled]="loading() || saveDisabled()"
bitFormButton
type="submit"
>
{{ (currentStep() === policySteps().length - 1 ? "save" : "next") | i18n }}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
}
</ng-container>
</bit-dialog>
</form>

View File

@ -0,0 +1,189 @@
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ReactiveFormsModule, UntypedFormGroup } from "@angular/forms";
import { MockProxy, mock } from "jest-mock-extended";
import { of } from "rxjs";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { BasePolicyEditComponent, BasePolicyEditDefinition } from "../base-policy-edit.component";
import { PolicyEditDialogData, PolicyEditDialogResult } from "../policy-edit-dialog.component";
import { PolicyStep } from "./models";
import { MultiStepPolicyEditDialogComponent } from "./multi-step-policy-edit-dialog.component";
describe("MultiStepPolicyEditDialogComponent", () => {
let component: MultiStepPolicyEditDialogComponent;
let fixture: ComponentFixture<MultiStepPolicyEditDialogComponent>;
let toastService: MockProxy<ToastService>;
let i18nService: MockProxy<I18nService>;
let dialogRef: MockProxy<DialogRef<PolicyEditDialogResult>>;
let policyComponent: MockProxy<BasePolicyEditComponent>;
const dialogData: PolicyEditDialogData = {
policy: {
name: "testPolicy",
description: "testDesc",
type: 0,
component: class {} as any,
showDescription: true,
display$: () => of(true),
} as BasePolicyEditDefinition,
organizationId: "org-1",
};
beforeEach(async () => {
toastService = mock<ToastService>();
i18nService = mock<I18nService>();
i18nService.t.mockReturnValue("translated");
dialogRef = mock<DialogRef<PolicyEditDialogResult>>();
policyComponent = mock<BasePolicyEditComponent>();
await TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
providers: [
{ provide: DIALOG_DATA, useValue: dialogData },
{ provide: AccountService, useValue: mock<AccountService>() },
{ provide: PolicyApiServiceAbstraction, useValue: mock<PolicyApiServiceAbstraction>() },
{ provide: I18nService, useValue: i18nService },
{ provide: DialogRef, useValue: dialogRef },
{ provide: ToastService, useValue: toastService },
{ provide: KeyService, useValue: mock<KeyService>() },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(MultiStepPolicyEditDialogComponent);
component = fixture.componentInstance;
// Intentionally skip detectChanges() to avoid triggering ngAfterViewInit,
// which calls load() and policyFormViewRef() in the real component.
});
/** Sets up the component state as if ngAfterViewInit had run with the given steps. */
function setupSteps(steps: PolicyStep[]) {
(component as any).policySteps.set(steps);
(component as any).policyComponent.set(policyComponent);
}
describe("submit()", () => {
it("throws when policyComponent is not initialized", async () => {
await expect(component.submit()).rejects.toThrow("PolicyComponent not initialized.");
});
it("advances to next step when side effect returns undefined on a non-last step", async () => {
const sideEffect0 = jest.fn().mockResolvedValue(undefined);
setupSteps([{ sideEffect: sideEffect0 }, {}]);
await component.submit();
expect(component.currentStep()).toBe(1);
expect(dialogRef.close).not.toHaveBeenCalled();
});
it("closes dialog with success toast when side effect resolves on the last step", async () => {
const sideEffect = jest.fn().mockResolvedValue(undefined);
setupSteps([{ sideEffect }]);
await component.submit();
expect(toastService.showToast).toHaveBeenCalledWith(
expect.objectContaining({ variant: "success" }),
);
expect(dialogRef.close).toHaveBeenCalledWith("saved");
});
it("closes dialog immediately when side effect returns { closeDialog: true } on a non-last step", async () => {
const sideEffect0 = jest.fn().mockResolvedValue({ closeDialog: true });
const sideEffect1 = jest.fn().mockResolvedValue(undefined);
setupSteps([{ sideEffect: sideEffect0 }, { sideEffect: sideEffect1 }]);
await component.submit();
expect(dialogRef.close).toHaveBeenCalledWith("saved");
expect(toastService.showToast).toHaveBeenCalledWith(
expect.objectContaining({ variant: "success" }),
);
// Step was not advanced since we closed early
expect(component.currentStep()).toBe(0);
// Subsequent side effect was never invoked
expect(sideEffect1).not.toHaveBeenCalled();
});
it("shows error toast and does not advance step when side effect throws", async () => {
const error = new Error("Save failed");
setupSteps([{ sideEffect: jest.fn().mockRejectedValue(error) }, {}]);
await component.submit();
expect(toastService.showToast).toHaveBeenCalledWith(
expect.objectContaining({ variant: "error", message: "Save failed" }),
);
expect(component.currentStep()).toBe(0);
expect(dialogRef.close).not.toHaveBeenCalled();
});
it("advances step on a non-last step when no side effect is defined", async () => {
setupSteps([{}, {}]);
await component.submit();
expect(component.currentStep()).toBe(1);
expect(dialogRef.close).not.toHaveBeenCalled();
});
it("closes dialog with success toast on the last step when no side effect is defined", async () => {
setupSteps([{}]);
await component.submit();
expect(toastService.showToast).toHaveBeenCalledWith(
expect.objectContaining({ variant: "success" }),
);
expect(dialogRef.close).toHaveBeenCalledWith("saved");
});
});
describe("saveDisabled signal", () => {
// These tests set state directly via setupSteps() and use TestBed.flushEffects() to
// propagate signal changes. This avoids detectChanges(), which would trigger the async
// ngAfterViewInit and its createComponent() call against the bare (undecorated) test class.
it("is true when the current step's disableSave observable emits true", () => {
setupSteps([{ disableSave: of(true) }]);
TestBed.flushEffects();
expect((component as any).saveDisabled()).toBe(true);
});
it("is false when step has no disableSave and policyComponent has no data", () => {
policyComponent.data = undefined;
setupSteps([{}]);
TestBed.flushEffects();
expect((component as any).saveDisabled()).toBe(false);
});
it("is false when step has no disableSave and the data form is valid", () => {
policyComponent.data = new UntypedFormGroup({});
setupSteps([{}]);
TestBed.flushEffects();
expect((component as any).saveDisabled()).toBe(false);
});
it("reflects the new step's disableSave after advancing to the next step", () => {
policyComponent.data = undefined;
setupSteps([{}, { disableSave: of(true) }]);
TestBed.flushEffects();
component.currentStep.set(1);
TestBed.flushEffects();
expect((component as any).saveDisabled()).toBe(true);
});
});
});

View File

@ -0,0 +1,166 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
Signal,
ViewContainerRef,
WritableSignal,
computed,
signal,
viewChild,
} from "@angular/core";
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
import { FormBuilder } from "@angular/forms";
import { map, of, startWith, switchMap } from "rxjs";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../../shared";
import {
PolicyEditDialogComponent,
PolicyEditDialogData,
PolicyEditDialogResult,
} from "../policy-edit-dialog.component";
import { PolicyStep } from "./models";
@Component({
selector: "app-multi-step-policy-edit-dialog",
templateUrl: "multi-step-policy-edit-dialog.component.html",
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MultiStepPolicyEditDialogComponent
extends PolicyEditDialogComponent
implements AfterViewInit
{
private readonly policyFormViewRef: Signal<ViewContainerRef | undefined> = viewChild(
"policyForm",
{ read: ViewContainerRef },
);
protected readonly policySteps: WritableSignal<PolicyStep[]> = signal([]);
readonly currentStep: WritableSignal<number> = signal(0);
private readonly currentStepConfig = computed(() => this.policySteps()[this.currentStep()]);
protected readonly saveDisabled = toSignal(
toObservable(this.currentStepConfig).pipe(
switchMap((stepConfig) => {
if (stepConfig?.disableSave) {
return stepConfig.disableSave;
}
const policyComponent = this.policyComponent();
if (policyComponent?.data) {
return policyComponent.data.statusChanges.pipe(
startWith(policyComponent.data.status),
map((status) => status !== "VALID"),
);
}
return of(false);
}),
),
{ initialValue: false },
);
constructor(
@Inject(DIALOG_DATA) data: PolicyEditDialogData,
accountService: AccountService,
policyApiService: PolicyApiServiceAbstraction,
i18nService: I18nService,
changeDetectorRef: ChangeDetectorRef,
formBuilder: FormBuilder,
dialogRef: DialogRef<PolicyEditDialogResult>,
toastService: ToastService,
keyService: KeyService,
) {
super(
data,
accountService,
policyApiService,
i18nService,
changeDetectorRef,
formBuilder,
dialogRef,
toastService,
keyService,
);
}
override async ngAfterViewInit() {
const policyResponse = await this.load();
this.loading.set(false);
const policyFormRef = this.policyFormViewRef();
if (!policyFormRef) {
throw new Error("Template not initialized.");
}
// Create the policy component instance
const componentRef = policyFormRef.createComponent(this.data.policy.component);
componentRef.setInput("policyResponse", policyResponse);
componentRef.setInput("policy", this.data.policy);
componentRef.setInput("currentStep", this.currentStep);
componentRef.setInput("organizationId", this.data.organization.id);
const component = componentRef.instance;
this.policyComponent.set(component);
// Read step configuration from child component.
// Setting policySteps triggers currentStepConfig to recompute, which re-evaluates saveDisabled.
this.policySteps.set(component.policySteps ?? []);
}
override readonly submit = async () => {
if (!this.policyComponent()) {
throw new Error("PolicyComponent not initialized.");
}
try {
// Execute side effect for current step (if defined)
const sideEffect = this.policySteps()[this.currentStep()]?.sideEffect;
const result = sideEffect ? await sideEffect() : undefined;
// A sideEffect can return { closeDialog: true } to end the workflow early
// (e.g. when disabling a policy or for users without permission to see later steps).
const isLastStep = this.currentStep() === this.policySteps().length - 1;
if (isLastStep || (typeof result === "object" && result.closeDialog)) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("editedPolicyId", this.i18nService.t(this.data.policy.name)),
});
this.dialogRef.close("saved");
return;
}
// Not the last step - advance to next step
this.currentStep.update((value) => value + 1);
} catch (error: any) {
this.toastService.showToast({
variant: "error",
message: error.message,
});
}
};
static override readonly open = (
dialogService: DialogService,
config: DialogConfig<PolicyEditDialogData>,
) => {
return dialogService.open<PolicyEditDialogResult, PolicyEditDialogData>(
MultiStepPolicyEditDialogComponent,
config,
);
};
}

View File

@ -1,55 +0,0 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [loading]="loading()">
<ng-container bitDialogTitle>
@let title = multiStepSubmit()[currentStep()]?.titleContent();
@if (title) {
<ng-container [ngTemplateOutlet]="title"></ng-container>
}
</ng-container>
<ng-container bitDialogContent>
@if (loading()) {
<div>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
}
<div [hidden]="loading()">
@if (policy.showDescription) {
<p bitTypography="body1">{{ policy.description | i18n }}</p>
}
</div>
<ng-template #policyForm></ng-template>
</ng-container>
<ng-container bitDialogFooter>
@let footer = multiStepSubmit()[currentStep()]?.footerContent();
@if (footer) {
<ng-container [ngTemplateOutlet]="footer"></ng-container>
}
</ng-container>
</bit-dialog>
</form>
<ng-template #step0Title>
{{ policy.name | i18n }}
</ng-template>
<ng-template #step0>
<button
bitButton
buttonType="primary"
[disabled]="saveDisabled$ | async"
bitFormButton
type="submit"
>
{{ "save" | i18n }}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
</ng-template>

View File

@ -1,212 +0,0 @@
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 {
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<boolean> = defer(() =>
from(
this.policyApiService.getPolicy(
this.data.organization.id,
PolicyType.OrganizationDataOwnership,
),
).pipe(
map((policy) => policy.enabled),
catchError(() => of(false)),
),
);
protected readonly currentStep: WritableSignal<number> = signal(0);
protected readonly multiStepSubmit: WritableSignal<MultiStepSubmit[]> = signal([]);
private readonly policyForm = viewChild.required<TemplateRef<unknown>>("step0");
private readonly policyFormTitle = viewChild.required<TemplateRef<unknown>>("step0Title");
protected saveDisabled$: Observable<boolean> | undefined;
private get typedPolicyComponent(): vNextOrganizationDataOwnershipPolicyComponent | undefined {
const component = this.policyComponent();
return component instanceof vNextOrganizationDataOwnershipPolicyComponent
? component
: undefined;
}
constructor(
@Inject(DIALOG_DATA) protected data: PolicyEditDialogData,
accountService: AccountService,
policyApiService: PolicyApiServiceAbstraction,
i18nService: I18nService,
cdr: ChangeDetectorRef,
formBuilder: FormBuilder,
dialogRef: DialogRef<PolicyEditDialogResult>,
toastService: ToastService,
protected keyService: KeyService,
) {
super(
data,
accountService,
policyApiService,
i18nService,
cdr,
formBuilder,
dialogRef,
toastService,
keyService,
);
}
async ngAfterViewInit() {
await super.ngAfterViewInit();
const typedComponent = this.typedPolicyComponent;
if (typedComponent) {
this.saveDisabled$ = combineLatest([
this.centralizeDataOwnershipEnabled$,
typedComponent.enabled.valueChanges.pipe(startWith(typedComponent.enabled.value)),
]).pipe(map(([policyEnabled, value]) => !policyEnabled && !value));
}
this.multiStepSubmit.set(this.buildMultiStepSubmit());
}
private buildMultiStepSubmit(): MultiStepSubmit[] {
return [
{
sideEffect: () => this.handleSubmit(),
footerContent: this.policyForm,
titleContent: this.policyFormTitle,
},
];
}
private async handleSubmit() {
const typedComponent = this.typedPolicyComponent;
if (!typedComponent) {
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 typedComponent.buildVNextRequest(orgKey[this.data.organization.id]);
await this.policyApiService.putPolicyVNext(
this.data.organization.id,
this.data.policy.type,
request,
);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("editedPolicyId", this.i18nService.t(this.data.policy.name)),
});
if (!typedComponent.enabled.value) {
this.dialogRef.close("saved");
}
}
submit = async () => {
const typedComponent = this.typedPolicyComponent;
if (!typedComponent) {
throw new Error("PolicyComponent not initialized.");
}
if ((await typedComponent.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);
} catch (error: any) {
this.toastService.showToast({
variant: "error",
message: error.message,
});
}
};
static open = (dialogService: DialogService, config: DialogConfig<PolicyEditDialogData>) => {
return dialogService.open<PolicyEditDialogResult>(
OrganizationDataOwnershipPolicyDialogComponent,
config,
);
};
}

View File

@ -93,8 +93,14 @@ export class WebVaultExtensionPromptService {
);
const now = new Date();
const accountAgeMs = now.getTime() - creationDate.getTime();
const accountAgeDays = accountAgeMs / (1000 * 60 * 60 * 24);
// Use UTC midnight boundaries to avoid DST skewing the day count.
const nowUtcDay = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
const creationUtcDay = Date.UTC(
creationDate.getFullYear(),
creationDate.getMonth(),
creationDate.getDate(),
);
const accountAgeDays = (nowUtcDay - creationUtcDay) / (1000 * 60 * 60 * 24);
const minAgeDays = minAccountAgeDays ?? 0;
const maxAgeDays = 30;

View File

@ -15,7 +15,7 @@ import { LogService } from "@bitwarden/logging";
import { VaultItemsTransferService } from "@bitwarden/vault";
import {
AutoConfirmPolicyDialogComponent,
MultiStepPolicyEditDialogComponent,
PolicyEditDialogResult,
} from "../../admin-console/organizations/policies";
import { UnifiedUpgradePromptService } from "../../billing/individual/upgrade/services";
@ -137,7 +137,7 @@ describe("WebVaultPromptService", () => {
} as unknown as DialogRef<PolicyEditDialogResult>;
const openSpy = jest
.spyOn(AutoConfirmPolicyDialogComponent, "open")
.spyOn(MultiStepPolicyEditDialogComponent, "open")
.mockReturnValue(dialogRefMock);
void service.conditionallyPromptUser();
@ -148,10 +148,12 @@ describe("WebVaultPromptService", () => {
data: {
policy: expect.any(Object),
organization: expect.objectContaining({ id: mockOrganizationId }),
firstTimeDialog: true,
},
});
const passedPolicy = openSpy.mock.calls[0][1].data.policy;
expect(passedPolicy.firstTimeDialog).toBe(true);
dialogClosedSubject.next(null);
}));
@ -167,7 +169,7 @@ describe("WebVaultPromptService", () => {
} as Organization;
organizations$.mockReturnValueOnce(of([mockOrg]));
const openSpy = jest.spyOn(AutoConfirmPolicyDialogComponent, "open");
const openSpy = jest.spyOn(MultiStepPolicyEditDialogComponent, "open");
void service.conditionallyPromptUser();
@ -193,7 +195,7 @@ describe("WebVaultPromptService", () => {
} as Organization;
organizations$.mockReturnValueOnce(of([mockOrg]));
const openSpy = jest.spyOn(AutoConfirmPolicyDialogComponent, "open");
const openSpy = jest.spyOn(MultiStepPolicyEditDialogComponent, "open");
void service.conditionallyPromptUser();
@ -214,7 +216,7 @@ describe("WebVaultPromptService", () => {
} as Organization;
organizations$.mockReturnValueOnce(of([mockOrg]));
const openSpy = jest.spyOn(AutoConfirmPolicyDialogComponent, "open");
const openSpy = jest.spyOn(MultiStepPolicyEditDialogComponent, "open");
void service.conditionallyPromptUser();
@ -231,7 +233,7 @@ describe("WebVaultPromptService", () => {
policies$.mockReturnValueOnce(of([]));
organizations$.mockReturnValueOnce(of([]));
const openSpy = jest.spyOn(AutoConfirmPolicyDialogComponent, "open");
const openSpy = jest.spyOn(MultiStepPolicyEditDialogComponent, "open");
void service.conditionallyPromptUser();
@ -254,7 +256,7 @@ describe("WebVaultPromptService", () => {
organizations$.mockReturnValueOnce(of([mockOrg]));
const openSpy = jest.spyOn(AutoConfirmPolicyDialogComponent, "open");
const openSpy = jest.spyOn(MultiStepPolicyEditDialogComponent, "open");
void service.conditionallyPromptUser();

View File

@ -15,8 +15,8 @@ import { LogService } from "@bitwarden/logging";
import { VaultItemsTransferService } from "@bitwarden/vault";
import {
AutoConfirmPolicyDialogComponent,
AutoConfirmPolicy,
MultiStepPolicyEditDialogComponent,
} from "../../admin-console/organizations/policies";
import { UnifiedUpgradePromptService } from "../../billing/individual/upgrade/services";
@ -64,12 +64,11 @@ export class WebVaultPromptService {
this.checkForAutoConfirm();
}
private async openAutoConfirmFeatureDialog(organization: Organization) {
AutoConfirmPolicyDialogComponent.open(this.dialogService, {
private openAutoConfirmFeatureDialog(organization: Organization) {
MultiStepPolicyEditDialogComponent.open(this.dialogService, {
data: {
policy: new AutoConfirmPolicy(),
policy: new AutoConfirmPolicy(true),
organization: organization,
firstTimeDialog: true,
},
});
}
@ -108,7 +107,7 @@ export class WebVaultPromptService {
organization.canEnableAutoConfirmPolicy;
if (showDialog) {
await this.openAutoConfirmFeatureDialog(organization);
this.openAutoConfirmFeatureDialog(organization);
await this.autoConfirmService.upsert(userId, {
...autoConfirmState,

View File

@ -6,7 +6,9 @@ import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
import { Observable, of } from "rxjs";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
SessionTimeoutAction,
SessionTimeoutType,
@ -14,6 +16,7 @@ import {
import { VaultTimeoutAction } from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogRef, DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { SessionTimeoutConfirmationNeverComponent } from "./session-timeout-confirmation-never.component";
import { SessionTimeoutPolicyComponent } from "./session-timeout.component";
@ -47,7 +50,13 @@ describe("SessionTimeoutPolicyComponent", () => {
const testBed = TestBed.configureTestingModule({
imports: [SessionTimeoutPolicyComponent, ReactiveFormsModule],
providers: [FormBuilder, { provide: I18nService, useValue: mockI18nService }],
providers: [
FormBuilder,
{ provide: I18nService, useValue: mockI18nService },
{ provide: AccountService, useValue: mock<AccountService>() },
{ provide: KeyService, useValue: mock<KeyService>() },
{ provide: PolicyApiServiceAbstraction, useValue: mock<PolicyApiServiceAbstraction>() },
],
});
// Override DialogService provided from SharedModule (which includes DialogModule)
@ -80,13 +89,16 @@ describe("SessionTimeoutPolicyComponent", () => {
}
function setPolicyResponseType(type: SessionTimeoutType) {
component.policyResponse = new PolicyResponse({
Data: {
type,
minutes: 480,
action: null,
},
});
fixture.componentRef.setInput(
"policyResponse",
new PolicyResponse({
Data: {
type,
minutes: 480,
action: null,
},
}),
);
}
describe("initialization and data loading", () => {
@ -104,7 +116,7 @@ describe("SessionTimeoutPolicyComponent", () => {
}
it("should initialize with default state when policy have no value", () => {
component.policyResponse = undefined;
fixture.componentRef.setInput("policyResponse", undefined);
fixture.detectChanges();
@ -122,12 +134,15 @@ describe("SessionTimeoutPolicyComponent", () => {
// This is for backward compatibility when type field did not exist
it("should load as custom type when type field does not exist but minutes does", () => {
component.policyResponse = new PolicyResponse({
Data: {
minutes: 500,
action: VaultTimeoutAction.Lock,
},
});
fixture.componentRef.setInput(
"policyResponse",
new PolicyResponse({
Data: {
minutes: 500,
action: VaultTimeoutAction.Lock,
},
}),
);
fixture.detectChanges();
@ -159,13 +174,16 @@ describe("SessionTimeoutPolicyComponent", () => {
["custom", VaultTimeoutAction.Lock],
["custom", VaultTimeoutAction.LogOut],
])("should load correctly when policy type is %s and action is %s", (type, action) => {
component.policyResponse = new PolicyResponse({
Data: {
type,
minutes: 510,
action,
},
});
fixture.componentRef.setInput(
"policyResponse",
new PolicyResponse({
Data: {
type,
minutes: 510,
action,
},
}),
);
fixture.detectChanges();

View File

@ -166,7 +166,7 @@ export class SessionTimeoutPolicyComponent
}
private get policyData(): MaximumSessionTimeoutPolicyData | null {
return this.policyResponse?.data ?? null;
return this.policyResponse()?.data ?? null;
}
private async confirmTypeChange(newType: SessionTimeoutType): Promise<boolean> {