diff --git a/apps/web/src/app/admin-console/organizations/policies/policies-deactivate.guard.ts b/apps/web/src/app/admin-console/organizations/policies/policies-deactivate.guard.ts index e43e22804eb1..d793980c435a 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies-deactivate.guard.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policies-deactivate.guard.ts @@ -1,11 +1,35 @@ import { Injectable } from "@angular/core"; import { CanDeactivate } from "@angular/router"; +import { firstValueFrom } from "rxjs"; -import { PoliciesComponent } from "./policies.component"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; + +// `import type` avoids a circular runtime dependency: the guard only needs +// PoliciesComponent as a TypeScript type (for the CanDeactivate generic and +// the canDeactivate() parameter), never as a runtime value. +import type { PoliciesComponent } from "./policies.component"; @Injectable({ providedIn: "root" }) export class PoliciesDeactivateGuard implements CanDeactivate { - canDeactivate(component: PoliciesComponent): Promise { + constructor( + private readonly accountService: AccountService, + private readonly authService: AuthService, + ) {} + + async canDeactivate(component: PoliciesComponent): Promise { + // If the user is already locked or logged out (e.g. during a lock/logout flow), + // always allow navigation so the discard-edits dialog is never shown. + // Guard against a null active account (switchAccount(null) during logout). + const account = await firstValueFrom(this.accountService.activeAccount$); + if (account?.id == null) { + return true; + } + const status = await firstValueFrom(this.authService.authStatusFor$(account.id)); + if (status !== AuthenticationStatus.Unlocked) { + return true; + } return component.canDeactivate(); } } 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 03f10857fd18..8b8332c48a1c 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 @@ -77,7 +77,7 @@ {{ "continue" | i18n }} } - @@ -113,7 +113,7 @@ {{ "openExtension" | i18n }} - 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 1fd9cf27738d..e5d377b6fd28 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,4 +1,11 @@ -import { ChangeDetectionStrategy, Component, Signal, TemplateRef, viewChild } from "@angular/core"; +import { + ChangeDetectionStrategy, + Component, + Signal, + TemplateRef, + inject, + viewChild, +} from "@angular/core"; import { Router } from "@angular/router"; import { combineLatest, @@ -19,6 +26,7 @@ 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 { getById } from "@bitwarden/common/platform/misc"; +import { DialogRef } from "@bitwarden/components"; import { SharedModule } from "../../../../shared"; import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component"; @@ -55,6 +63,10 @@ export class AutoConfirmPolicy extends BasePolicyEditDefinition { changeDetection: ChangeDetectionStrategy.OnPush, }) export class AutoConfirmPolicyEditComponent extends BasePolicyEditComponent { + // Injected so custom footer templates can call close() and go through closePredicate, + // which shows the discard-edits dialog when there are unsaved changes. + private readonly dialogRef = inject(DialogRef, { optional: true }); + constructor( private readonly organizationService: OrganizationService, private readonly policyService: PolicyService, @@ -66,6 +78,10 @@ export class AutoConfirmPolicyEditComponent extends BasePolicyEditComponent { protected readonly autoConfirmSvg = AutoConfirmSvg; + protected close(): void { + void this.dialogRef?.close(); + } + protected get autoConfirmPolicy(): AutoConfirmPolicy | undefined { return this.policy() as AutoConfirmPolicy | undefined; } 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 dd599a5f0517..db7f9cc54579 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,7 +5,6 @@ import { ChangeDetectorRef, Component, DestroyRef, - HostListener, Inject, Signal, ViewContainerRef, @@ -15,13 +14,15 @@ import { } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder } from "@angular/forms"; -import { map, firstValueFrom, switchMap, filter } from "rxjs"; +import { map, firstValueFrom, switchMap, filter, of } from "rxjs"; 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 { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { 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"; @@ -86,6 +87,7 @@ export class PolicyEditDialogComponent implements AfterViewInit { protected readonly dialogService: DialogService, protected readonly cdkDialogRef: CdkDialogRef, protected readonly configService: ConfigService, + private readonly authService: AuthService, ) {} get policy(): BasePolicyEditDefinition { @@ -94,7 +96,10 @@ export class PolicyEditDialogComponent implements AfterViewInit { private isFormDirty(): boolean { const component = this.policyComponent(); - return (component?.enabled?.dirty ?? false) || (component?.data?.dirty ?? false); + if (!component) { + return false; + } + return component.enabled.dirty || (component.data?.dirty ?? false); } private readonly discardDialogOptions = { @@ -143,8 +148,38 @@ export class PolicyEditDialogComponent implements AfterViewInit { if (result !== undefined || !this.isFormDirty()) { return true; } - return this.dialogService.openSimpleDialog(this.discardDialogOptions); + const confirmed = await this.dialogService.openSimpleDialog(this.discardDialogOptions); + if (confirmed) { + // Disarm the beforeunload handler immediately. Angular won't destroy this component + // until its next change-detection cycle, but a lock/logout can trigger + // window.location.reload() before that cycle runs. Clearing the flag here ensures + // the handler doesn't block the reload after the user has already confirmed discard. + this.discardGuardEnabled.set(false); + } + return confirmed; }; + + // When the vault is locked or the user is logged out, disarm both guards immediately + // so the browser's beforeunload dialog cannot block the lock/logout reload, and so + // the closePredicate won't show the discard dialog during the subsequent router teardown. + // If the active account becomes null (switchAccount(null) during logout), treat that + // as a non-Unlocked state and disarm as well. + this.accountService.activeAccount$ + .pipe( + switchMap((account) => { + if (account?.id == null) { + return of(null); // no active account — disarm immediately + } + return this.authService + .authStatusFor$(account.id) + .pipe(filter((status) => status !== AuthenticationStatus.Unlocked)); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => { + this.discardGuardEnabled.set(false); + this.dialogRef.closePredicate = undefined; + }); } } @@ -161,14 +196,6 @@ export class PolicyEditDialogComponent implements AfterViewInit { } }; - @HostListener("window:beforeunload", ["$event"]) - onBeforeUnload(event: BeforeUnloadEvent): void { - if (this.discardGuardEnabled() && this.isFormDirty()) { - event.preventDefault(); - event.returnValue = ""; - } - } - async ngAfterViewInit() { const policyResponse = await this.load(); this.loading.set(false); 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 index 06d0ed61bf83..5dbc17c2a2f3 100644 --- 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 @@ -18,6 +18,7 @@ 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 { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { @@ -90,6 +91,7 @@ export class MultiStepPolicyEditDialogComponent dialogService: DialogService, cdkDialogRef: CdkDialogRef, configService: ConfigService, + authService: AuthService, ) { super( data, @@ -104,6 +106,7 @@ export class MultiStepPolicyEditDialogComponent dialogService, cdkDialogRef, configService, + authService, ); } @@ -154,8 +157,14 @@ export class MultiStepPolicyEditDialogComponent return; } - // Not the last step - advance to next step + // Not the last step - advance to next step. Reset dirty state so that + // the discard-edits guard treats the saved values as the new baseline. this.currentStep.update((value) => value + 1); + const component = this.policyComponent(); + if (component) { + component.enabled.markAsPristine(); + component.data?.markAsPristine(); + } } catch (error: any) { this.toastService.showToast({ variant: "error", diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index c438d9464176..b522629e48ed 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -45,7 +45,7 @@ bitIconButton="bwi-close" buttonType="primaryGhost" size="default" - bitDialogClose + (click)="dialogRef?.close()" [label]="'close' | i18n" > }