Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<PoliciesComponent> {
canDeactivate(component: PoliciesComponent): Promise<boolean> {
constructor(
private readonly accountService: AccountService,
private readonly authService: AuthService,
) {}

async canDeactivate(component: PoliciesComponent): Promise<boolean> {
// 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
{{ "continue" | i18n }}
}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
<button bitButton buttonType="secondary" type="button" (click)="close()">
{{ "cancel" | i18n }}
</button>
</ng-template>
Expand Down Expand Up @@ -113,7 +113,7 @@
{{ "openExtension" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
<button bitButton buttonType="secondary" type="button" (click)="close()">
{{ "close" | i18n }}
</button>
</ng-template>
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
ChangeDetectorRef,
Component,
DestroyRef,
HostListener,
Inject,
Signal,
ViewContainerRef,
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: New required AuthService constructor dependency breaks existing tests.

Details and fix

PolicyEditDialogComponent and MultiStepPolicyEditDialogComponent now require AuthService via constructor injection (not @Optional()), but neither policy-edit-dialog.component.spec.ts nor multi-step-policy-edit-dialog.component.spec.ts provides one in TestBed.configureTestingModule. TestBed.createComponent(...) in the existing beforeEach blocks will throw NullInjectorError: No provider for AuthService!, failing every test in both spec files.

Fix: add a mock provider in both spec files, e.g.:

import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
// ...
{ provide: AuthService, useValue: mock<AuthService>() },

Also consider provisioning authService.authStatusFor$ to return a non-emitting observable (e.g. NEVER or of(AuthenticationStatus.Unlocked)) so the new subscription in setupDiscardGuard doesn't disrupt other tests if ngAfterViewInit runs.

) {}

get policy(): BasePolicyEditDefinition {
Expand All @@ -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 = {
Expand Down Expand Up @@ -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);
}
Comment on lines +152 to +158
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ DEBT: Comment references a beforeunload handler that no longer exists.

Details

This same PR removes the @HostListener("window:beforeunload") handler, so the rationale "Disarm the beforeunload handler immediately" no longer applies. After this point, discardGuardEnabled is read only by cancel(), which won't be invoked again on this close path since closePredicate is about to resolve true and tear down the dialog. Either drop discardGuardEnabled.set(false) here or update the comment to reflect the actual reason for the assignment (currently it appears load-bearing, but isn't).

The same stale rationale appears at lines 162–166 ("so the browser's beforeunload dialog cannot block the lock/logout reload") for the auth-status subscription — worth aligning that comment too.

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;
});
}
}

Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -90,6 +91,7 @@ export class MultiStepPolicyEditDialogComponent
dialogService: DialogService,
cdkDialogRef: CdkDialogRef,
configService: ConfigService,
authService: AuthService,
) {
super(
data,
Expand All @@ -104,6 +106,7 @@ export class MultiStepPolicyEditDialogComponent
dialogService,
cdkDialogRef,
configService,
authService,
);
}

Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion libs/components/src/dialog/dialog/dialog.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
bitIconButton="bwi-close"
buttonType="primaryGhost"
size="default"
bitDialogClose
(click)="dialogRef?.close()"
[label]="'close' | i18n"
></button>
}
Expand Down
Loading