diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index c3bd54b7cf4..a90f0266380 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -105,7 +105,7 @@ const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours
- +
diff --git a/apps/desktop/src/app/app.module.ts b/apps/desktop/src/app/app.module.ts index 41ee28d27c6..a64d8588b07 100644 --- a/apps/desktop/src/app/app.module.ts +++ b/apps/desktop/src/app/app.module.ts @@ -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, diff --git a/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts b/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts index 8bce87111e0..5bd628607d0 100644 --- a/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts @@ -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(undefined); + readonly policy = input(undefined); + readonly currentStep = input>(signal(0)); + readonly organizationId = input(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; + async buildVNextRequest(orgKey: OrgKey): Promise { - 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 { - 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 { + 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 ?? {}); } /** diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts index 3e812a69caa..648e35f3e49 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts @@ -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, diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html index 394f5194b7b..03f10857fd1 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html @@ -1,6 +1,21 @@ - + +
+ @let showBadge = autoConfirmPolicy?.firstTimeDialog ?? false; + @if (showBadge) { + {{ "availableNow" | i18n }} + } + + {{ (showBadge ? "autoConfirm" : "editPolicy") | i18n }} + @if (!showBadge) { + + {{ policy()!.name | i18n }} + + } + +
+
- +

{{ "autoConfirmPolicyEditDescription" | i18n }}

@@ -46,7 +61,32 @@ {{ "autoConfirmCheckBoxLabel" | i18n }}
- + + + + + + + {{ "howToTurnOnAutoConfirm" | i18n }} + + +
@@ -61,3 +101,19 @@
+ + + + + diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.spec.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.spec.ts new file mode 100644 index 00000000000..31e383e1f1e --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.spec.ts @@ -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; + let accountService: FakeAccountService; + let organizationService: MockProxy; + let policyService: MockProxy; + let policyApiService: MockProxy; + let autoConfirmService: MockProxy; + let router: MockProxy; + + 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(); + policyService = mock(); + policyApiService = mock(); + autoConfirmService = mock(); + router = mock(); + + // 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() }, + { 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, + }); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts index b6c3dc0d0de..728d5f159b8 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts @@ -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 { 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 | undefined> = viewChild("step0"); - private readonly extensionButton: Signal | undefined> = viewChild("step1"); - - protected step: number = 0; - protected steps = [this.policyForm, this.extensionButton]; - - protected singleOrgEnabled$: BehaviorSubject = 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> = viewChild.required("step0Title"); + private readonly step0Content: Signal> = viewChild.required("step0Content"); + private readonly step0Footer: Signal> = viewChild.required("step0Footer"); + + private readonly step1Title: Signal> = viewChild.required("step1Title"); + private readonly step1Content: Signal> = viewChild.required("step1Content"); + private readonly step1Footer: Signal> = viewChild.required("step1Footer"); + + protected readonly autoConfirmEnabled$: Observable = + 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 = + 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 = 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 { + 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 { + await this.router.navigate(["/browser-extension-prompt"], { + queryParams: { url: "AutoConfirm" }, + }); } } diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.spec.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.spec.ts index b22f5687dd2..c11d87682e7 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.spec.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.spec.ts @@ -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() }, { provide: OrganizationService, useValue: mock() }, { provide: AccountService, useValue: mock() }, + { provide: KeyService, useValue: mock() }, + { provide: PolicyApiServiceAbstraction, useValue: mock() }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts index 38206b979a7..e7123e7c01c 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts @@ -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; } diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts index 403ec7b807c..97f65546c9f 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts @@ -58,8 +58,8 @@ export class OrganizationDataOwnershipPolicyComponent // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("dialog", { static: true }) warningContent!: TemplateRef; - override async confirm(): Promise { - if (this.policyResponse?.enabled && !this.enabled.value) { + async confirm(): Promise { + 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 { - if (!this.policy) { + if (!this.policy()) { throw new Error("Policy was not found"); } diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.spec.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.spec.ts index da0e9ede217..a8f9571110c 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.spec.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.spec.ts @@ -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() }, { provide: I18nService, useValue: i18nService }, + { provide: AccountService, useValue: mock() }, + { provide: KeyService, useValue: mock() }, + { provide: PolicyApiServiceAbstraction, useValue: mock() }, ], 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"); diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/reset-password.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/reset-password.component.ts index 30e50cc258a..5e08718ccd3 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/reset-password.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/reset-password.component.ts @@ -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) { diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/single-org.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/single-org.component.ts index f471a0ba1c4..78216b84662 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/single-org.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/single-org.component.ts @@ -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(); } } diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/uri-match-default.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/uri-match-default.component.ts index 48d5dfc9c4a..869c05c9d57 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/uri-match-default.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/uri-match-default.component.ts @@ -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, diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.spec.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.spec.ts index 970350a6ab1..ebe72de27ab 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.spec.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.spec.ts @@ -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() }, { provide: OrganizationService, useValue: mockOrganizationService }, { provide: AccountService, useValue: mockAccountService }, + { provide: KeyService, useValue: mock() }, + { provide: PolicyApiServiceAbstraction, useValue: mock() }, ], 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(); diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.ts index 5a79c1c67af..968350ab294 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.ts @@ -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 { 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 { 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 { - if (!this.policy) { + if (!this.policy()) { throw new Error("Policy was not found"); } diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts index de8c36d0368..c0666772f9f 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts @@ -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 = this._saveDisabled; protected readonly policyComponent = signal(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; } diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.html deleted file mode 100644 index 16d7e136d70..00000000000 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.html +++ /dev/null @@ -1,93 +0,0 @@ -
- - - @let title = (multiStepSubmit | async)[currentStep()]?.titleContent(); - @if (title) { - - } - - - - @if (loading()) { -
- - {{ "loading" | i18n }} -
- } -
- @if (policy.showDescription) { -

{{ policy.description | i18n }}

- } -
- -
- - @let footer = (multiStepSubmit | async)[currentStep()]?.footerContent(); - @if (footer) { - - } - -
-
- - -
- @let showBadge = firstTimeDialog(); - @if (showBadge) { - {{ "availableNow" | i18n }} - } - - {{ (showBadge ? "automaticUserConfirmation" : "editPolicy") | i18n }} - @if (!showBadge) { - - {{ policy.name | i18n }} - - } - -
-
- - - {{ "howToTurnOnAutoConfirm" | i18n }} - - - - - - - - - - - diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.spec.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.spec.ts deleted file mode 100644 index b81e26f827b..00000000000 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.spec.ts +++ /dev/null @@ -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; - - let mockPolicyApiService: MockProxy; - let mockAccountService: FakeAccountService; - let mockOrganizationService: MockProxy; - let mockPolicyService: MockProxy; - let mockRouter: MockProxy; - let mockAutoConfirmService: MockProxy; - let mockDialogRef: MockProxy; - let mockToastService: MockProxy; - let mockI18nService: MockProxy; - let mockKeyService: MockProxy; - - 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(); - mockAccountService = mockAccountServiceWith(mockUserId); - mockOrganizationService = mock(); - mockPolicyService = mock(); - mockRouter = mock(); - mockAutoConfirmService = mock(); - mockDialogRef = mock(); - mockToastService = mock(); - mockI18nService = mock(); - mockKeyService = mock(); - - 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: "
" }, - }) - .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 } }, - ); - }); - }); -}); diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts deleted file mode 100644 index ee3fb335276..00000000000 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts +++ /dev/null @@ -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 = of([]); - protected autoConfirmEnabled$: Observable = 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 = 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 | undefined> = viewChild("step0"); - private readonly openExtension: Signal | undefined> = viewChild("step1"); - - private readonly submitPolicyTitle: Signal | undefined> = - viewChild("step0Title"); - private readonly openExtensionTitle: Signal | undefined> = - viewChild("step1Title"); - - protected saveDisabled$: Observable | 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, - 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 { - 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 { - 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, - ) => { - return dialogService.open(AutoConfirmPolicyDialogComponent, config); - }; -} diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/index.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/index.ts index 307d0da04b0..8670b2e7a37 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/index.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/index.ts @@ -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"; diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/models.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/models.ts index 86120623701..7c15536b4ae 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/models.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/models.ts @@ -1,7 +1,24 @@ import { Signal, TemplateRef } from "@angular/core"; +import { Observable } from "rxjs"; -export type MultiStepSubmit = { - sideEffect?: () => Promise; - footerContent: Signal | undefined>; - titleContent: Signal | 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; + + // Optional: Custom title template. If undefined, uses default: "Edit policy" with policy name subtitle + titleContent?: Signal | undefined>; + + // Optional: Custom body template. If undefined, renders the policy component's template + bodyContent?: Signal | undefined>; + + // Optional: Custom footer template. If undefined, uses default: "Save" (primary) + "Cancel" (secondary) + footerContent?: Signal | undefined>; + + // Optional: Observable to disable save button. If undefined, defaults to form validation state + disableSave?: Observable; }; diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/multi-step-policy-edit-dialog.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/multi-step-policy-edit-dialog.component.html new file mode 100644 index 00000000000..fb93a828972 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/multi-step-policy-edit-dialog.component.html @@ -0,0 +1,47 @@ +
+ @let titleTemplate = policySteps()[currentStep()]?.titleContent?.(); + + + @if (titleTemplate) { + + } + + + + @if (policy.showDescription) { +

{{ policy.description | i18n }}

+ } + + @let bodyTemplate = policySteps()[currentStep()]?.bodyContent?.(); + @if (bodyTemplate) { + + } + + +
+ + + @let footerTemplate = policySteps()[currentStep()]?.footerContent?.(); + @if (footerTemplate) { + + } @else { + + + } + +
+
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/multi-step-policy-edit-dialog.component.spec.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/multi-step-policy-edit-dialog.component.spec.ts new file mode 100644 index 00000000000..b74f2f6b6ca --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/multi-step-policy-edit-dialog.component.spec.ts @@ -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; + let toastService: MockProxy; + let i18nService: MockProxy; + let dialogRef: MockProxy>; + let policyComponent: MockProxy; + + 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(); + i18nService = mock(); + i18nService.t.mockReturnValue("translated"); + dialogRef = mock>(); + policyComponent = mock(); + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule], + providers: [ + { provide: DIALOG_DATA, useValue: dialogData }, + { provide: AccountService, useValue: mock() }, + { provide: PolicyApiServiceAbstraction, useValue: mock() }, + { provide: I18nService, useValue: i18nService }, + { provide: DialogRef, useValue: dialogRef }, + { provide: ToastService, useValue: toastService }, + { provide: KeyService, useValue: mock() }, + ], + 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); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/multi-step-policy-edit-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/multi-step-policy-edit-dialog.component.ts new file mode 100644 index 00000000000..0818db27139 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/multi-step-policy-edit-dialog.component.ts @@ -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 = viewChild( + "policyForm", + { read: ViewContainerRef }, + ); + + protected readonly policySteps: WritableSignal = signal([]); + readonly currentStep: WritableSignal = 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, + 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, + ) => { + return dialogService.open( + MultiStepPolicyEditDialogComponent, + config, + ); + }; +} diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.html deleted file mode 100644 index 9a74db6554d..00000000000 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.html +++ /dev/null @@ -1,55 +0,0 @@ -
- - - @let title = multiStepSubmit()[currentStep()]?.titleContent(); - @if (title) { - - } - - - - @if (loading()) { -
- - {{ "loading" | i18n }} -
- } -
- @if (policy.showDescription) { -

{{ policy.description | i18n }}

- } -
- -
- - @let footer = multiStepSubmit()[currentStep()]?.footerContent(); - @if (footer) { - - } - -
-
- - - {{ policy.name | i18n }} - - - - - - - diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.ts deleted file mode 100644 index a911d510cb1..00000000000 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.ts +++ /dev/null @@ -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 = defer(() => - from( - this.policyApiService.getPolicy( - this.data.organization.id, - PolicyType.OrganizationDataOwnership, - ), - ).pipe( - map((policy) => policy.enabled), - catchError(() => of(false)), - ), - ); - - protected readonly currentStep: WritableSignal = signal(0); - protected readonly multiStepSubmit: WritableSignal = signal([]); - - private readonly policyForm = viewChild.required>("step0"); - private readonly policyFormTitle = viewChild.required>("step0Title"); - - protected saveDisabled$: Observable | 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, - 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) => { - return dialogService.open( - OrganizationDataOwnershipPolicyDialogComponent, - config, - ); - }; -} diff --git a/apps/web/src/app/vault/services/web-vault-extension-prompt.service.ts b/apps/web/src/app/vault/services/web-vault-extension-prompt.service.ts index 3e13935f94c..7f570a88f9d 100644 --- a/apps/web/src/app/vault/services/web-vault-extension-prompt.service.ts +++ b/apps/web/src/app/vault/services/web-vault-extension-prompt.service.ts @@ -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; diff --git a/apps/web/src/app/vault/services/web-vault-prompt.service.spec.ts b/apps/web/src/app/vault/services/web-vault-prompt.service.spec.ts index fac78788a21..11931392bbc 100644 --- a/apps/web/src/app/vault/services/web-vault-prompt.service.spec.ts +++ b/apps/web/src/app/vault/services/web-vault-prompt.service.spec.ts @@ -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; 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(); diff --git a/apps/web/src/app/vault/services/web-vault-prompt.service.ts b/apps/web/src/app/vault/services/web-vault-prompt.service.ts index fb7726faf23..fd42d116d14 100644 --- a/apps/web/src/app/vault/services/web-vault-prompt.service.ts +++ b/apps/web/src/app/vault/services/web-vault-prompt.service.ts @@ -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, diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.spec.ts b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.spec.ts index 322d383434a..0339492cfa3 100644 --- a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.spec.ts @@ -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() }, + { provide: KeyService, useValue: mock() }, + { provide: PolicyApiServiceAbstraction, useValue: mock() }, + ], }); // 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(); diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts index 39be5f48c1d..c279ed63ea6 100644 --- a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts +++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts @@ -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 {