From ff30df3dd6244e2ed5a1605c179e7c4d33bb1edc Mon Sep 17 00:00:00 2001
From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>
Date: Tue, 28 Oct 2025 20:28:34 +0100
Subject: [PATCH 001/510] [PM-19300] Session timeout policy (#16583)
* Session timeout policy
* default "custom" is 8 hours, validation fixes
* ownership update
* default max allowed timeout is not selected
* adjusting defaults, fixing backwards compatibility, skip type confirmation dialog when switching between the never and on system lock
* unit test coverage
* wording update, custom hours, minutes jumping on errors
* wording update
* wrong session timeout action dropdown label
* show dialog as valid when opened first time, use @for loop, use controls instead of get
* dialog static opener
* easier to understand type value listener
* unit tests
* explicit maximum allowed timeout required error
* eslint revert
---
.github/CODEOWNERS | 1 +
apps/web/src/locales/en/messages.json | 46 +-
.../policies/policy-edit-definitions/index.ts | 1 -
.../maximum-vault-timeout.component.html | 32 --
.../maximum-vault-timeout.component.ts | 79 ----
.../policies/policy-edit-register.ts | 4 +-
...-timeout-confirmation-never.component.html | 38 ++
...meout-confirmation-never.component.spec.ts | 79 ++++
...on-timeout-confirmation-never.component.ts | 18 +
.../policies/session-timeout.component.html | 39 ++
.../session-timeout.component.spec.ts | 441 ++++++++++++++++++
.../policies/session-timeout.component.ts | 197 ++++++++
12 files changed, 853 insertions(+), 122 deletions(-)
delete mode 100644 bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.html
delete mode 100644 bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.ts
create mode 100644 bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.html
create mode 100644 bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.spec.ts
create mode 100644 bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts
create mode 100644 bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.html
create mode 100644 bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.spec.ts
create mode 100644 bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 8affac3387b..676c4b4657b 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -174,6 +174,7 @@ apps/desktop/src/key-management @bitwarden/team-key-management-dev
apps/web/src/app/key-management @bitwarden/team-key-management-dev
apps/browser/src/key-management @bitwarden/team-key-management-dev
apps/cli/src/key-management @bitwarden/team-key-management-dev
+bitwarden_license/bit-web/src/app/key-management @bitwarden/team-key-management-dev
libs/key-management @bitwarden/team-key-management-dev
libs/key-management-ui @bitwarden/team-key-management-dev
libs/common/src/key-management @bitwarden/team-key-management-dev
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index 72ca4d73976..aa0353e754d 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -6495,17 +6495,32 @@
"tdeDisabledMasterPasswordRequired": {
"message": "Your organization has updated your decryption options. Please set a master password to access your vault."
},
- "maximumVaultTimeout": {
- "message": "Vault timeout"
+ "sessionTimeoutPolicyTitle": {
+ "message": "Session timeout"
},
- "maximumVaultTimeoutDesc": {
- "message": "Set a maximum vault timeout for members."
+ "sessionTimeoutPolicyDescription": {
+ "message": "Set a maximum session timeout for all members except owners."
},
- "maximumVaultTimeoutLabel": {
- "message": "Maximum vault timeout"
+ "maximumAllowedTimeout": {
+ "message": "Maximum allowed timeout"
},
- "invalidMaximumVaultTimeout": {
- "message": "Invalid maximum vault timeout."
+ "maximumAllowedTimeoutRequired": {
+ "message": "Maximum allowed timeout is required."
+ },
+ "sessionTimeoutPolicyInvalidTime": {
+ "message": "Time is invalid. Change at least one value."
+ },
+ "sessionTimeoutAction": {
+ "message": "Session timeout action"
+ },
+ "immediately": {
+ "message": "Immediately"
+ },
+ "onSystemLock": {
+ "message": "On system lock"
+ },
+ "onAppRestart": {
+ "message": "On app restart"
},
"hours": {
"message": "Hours"
@@ -6513,6 +6528,21 @@
"minutes": {
"message": "Minutes"
},
+ "sessionTimeoutConfirmationNeverTitle": {
+ "message": "Are you certain you want to allow a maximum timeout of \"Never\" for all members?"
+ },
+ "sessionTimeoutConfirmationNeverDescription": {
+ "message": "This option will save your members' encryption keys on their devices. If you choose this option, ensure that their devices are adequately protected."
+ },
+ "learnMoreAboutDeviceProtection": {
+ "message": "Learn more about device protection"
+ },
+ "sessionTimeoutConfirmationOnSystemLockTitle": {
+ "message": "\"System lock\" will only apply to the browser and desktop app"
+ },
+ "sessionTimeoutConfirmationOnSystemLockDescription": {
+ "message": "The mobile and web app will use \"on app restart\" as their maximum allowed timeout, since the option is not supported."
+ },
"vaultTimeoutPolicyInEffect": {
"message": "Your organization policies have set your maximum allowed vault timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).",
"placeholders": {
diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/index.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/index.ts
index 8c4be2eeea1..52325eae160 100644
--- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/index.ts
+++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/index.ts
@@ -1,4 +1,3 @@
export { ActivateAutofillPolicy } from "./activate-autofill.component";
export { AutomaticAppLoginPolicy } from "./automatic-app-login.component";
export { DisablePersonalVaultExportPolicy } from "./disable-personal-vault-export.component";
-export { MaximumVaultTimeoutPolicy } from "./maximum-vault-timeout.component";
diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.html b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.html
deleted file mode 100644
index deb72cfb3b5..00000000000
--- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.html
+++ /dev/null
@@ -1,32 +0,0 @@
-
- {{ "requireSsoPolicyReq" | i18n }}
-
-
-
-
- {{ "turnOn" | i18n }}
-
-
-
diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.ts
deleted file mode 100644
index 277388e2883..00000000000
--- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/maximum-vault-timeout.component.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-// FIXME: Update this file to be type safe and remove this and next line
-// @ts-strict-ignore
-import { Component } from "@angular/core";
-import { FormBuilder, FormControl } from "@angular/forms";
-
-import { PolicyType } from "@bitwarden/common/admin-console/enums";
-import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
-import { VaultTimeoutAction } from "@bitwarden/common/key-management/vault-timeout";
-import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
-import {
- BasePolicyEditDefinition,
- BasePolicyEditComponent,
-} from "@bitwarden/web-vault/app/admin-console/organizations/policies";
-import { SharedModule } from "@bitwarden/web-vault/app/shared";
-
-export class MaximumVaultTimeoutPolicy extends BasePolicyEditDefinition {
- name = "maximumVaultTimeout";
- description = "maximumVaultTimeoutDesc";
- type = PolicyType.MaximumVaultTimeout;
- component = MaximumVaultTimeoutPolicyComponent;
-}
-
-// 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: "maximum-vault-timeout.component.html",
- imports: [SharedModule],
-})
-export class MaximumVaultTimeoutPolicyComponent extends BasePolicyEditComponent {
- vaultTimeoutActionOptions: { name: string; value: string }[];
- data = this.formBuilder.group({
- hours: new FormControl(null),
- minutes: new FormControl(null),
- action: new FormControl(null),
- });
-
- constructor(
- private formBuilder: FormBuilder,
- private i18nService: I18nService,
- ) {
- super();
- this.vaultTimeoutActionOptions = [
- { name: i18nService.t("userPreference"), value: null },
- { name: i18nService.t(VaultTimeoutAction.Lock), value: VaultTimeoutAction.Lock },
- { name: i18nService.t(VaultTimeoutAction.LogOut), value: VaultTimeoutAction.LogOut },
- ];
- }
-
- protected loadData() {
- const minutes = this.policyResponse.data?.minutes;
- const action = this.policyResponse.data?.action;
-
- this.data.patchValue({
- hours: minutes ? Math.floor(minutes / 60) : null,
- minutes: minutes ? minutes % 60 : null,
- action: action,
- });
- }
-
- protected buildRequestData() {
- if (this.data.value.hours == null && this.data.value.minutes == null) {
- return null;
- }
-
- return {
- minutes: this.data.value.hours * 60 + this.data.value.minutes,
- action: this.data.value.action,
- };
- }
-
- async buildRequest(): Promise {
- const request = await super.buildRequest();
- if (request.data?.minutes == null || request.data?.minutes <= 0) {
- throw new Error(this.i18nService.t("invalidMaximumVaultTimeout"));
- }
-
- return request;
- }
-}
diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-register.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-register.ts
index 3438e706f10..015b4fc17be 100644
--- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-register.ts
+++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-register.ts
@@ -4,12 +4,12 @@ import {
} from "@bitwarden/web-vault/app/admin-console/organizations/policies";
import { FreeFamiliesSponsorshipPolicy } from "../../billing/policies/free-families-sponsorship.component";
+import { SessionTimeoutPolicy } from "../../key-management/policies/session-timeout.component";
import {
ActivateAutofillPolicy,
AutomaticAppLoginPolicy,
DisablePersonalVaultExportPolicy,
- MaximumVaultTimeoutPolicy,
} from "./policy-edit-definitions";
/**
@@ -18,7 +18,7 @@ import {
* It will not appear in the web vault when running in OSS mode.
*/
const policyEditRegister: BasePolicyEditDefinition[] = [
- new MaximumVaultTimeoutPolicy(),
+ new SessionTimeoutPolicy(),
new DisablePersonalVaultExportPolicy(),
new FreeFamiliesSponsorshipPolicy(),
new ActivateAutofillPolicy(),
diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.html b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.html
new file mode 100644
index 00000000000..2b718990c30
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.html
@@ -0,0 +1,38 @@
+
+
+
+
+ {{ "sessionTimeoutConfirmationNeverTitle" | i18n }}
+
+
+
+
+ {{ "sessionTimeoutConfirmationNeverDescription" | i18n }}
+
+ {{ "learnMoreAboutDeviceProtection" | i18n }}
+
+
+
+
+
+
+
+
+
diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.spec.ts b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.spec.ts
new file mode 100644
index 00000000000..332a0e323a7
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.spec.ts
@@ -0,0 +1,79 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { mock } from "jest-mock-extended";
+
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { DialogRef, DialogService } from "@bitwarden/components";
+
+import { SessionTimeoutConfirmationNeverComponent } from "./session-timeout-confirmation-never.component";
+
+describe("SessionTimeoutConfirmationNeverComponent", () => {
+ let component: SessionTimeoutConfirmationNeverComponent;
+ let fixture: ComponentFixture;
+ let mockDialogRef: jest.Mocked;
+
+ const mockI18nService = mock();
+ const mockDialogService = mock();
+
+ beforeEach(async () => {
+ mockDialogRef = mock();
+ mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
+
+ await TestBed.configureTestingModule({
+ imports: [SessionTimeoutConfirmationNeverComponent, NoopAnimationsModule],
+ providers: [
+ { provide: DialogRef, useValue: mockDialogRef },
+ { provide: I18nService, useValue: mockI18nService },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(SessionTimeoutConfirmationNeverComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe("open", () => {
+ it("should call dialogService.open with correct parameters", () => {
+ const mockResult = mock();
+ mockDialogService.open.mockReturnValue(mockResult);
+
+ const result = SessionTimeoutConfirmationNeverComponent.open(mockDialogService);
+
+ expect(mockDialogService.open).toHaveBeenCalledWith(
+ SessionTimeoutConfirmationNeverComponent,
+ {
+ disableClose: true,
+ },
+ );
+ expect(result).toBe(mockResult);
+ });
+ });
+
+ describe("button clicks", () => {
+ it("should close dialog with true when Yes button is clicked", () => {
+ const yesButton = fixture.nativeElement.querySelector(
+ 'button[buttonType="primary"]',
+ ) as HTMLButtonElement;
+
+ yesButton.click();
+
+ expect(mockDialogRef.close).toHaveBeenCalledWith(true);
+ expect(yesButton.textContent?.trim()).toBe("yes-used-i18n");
+ });
+
+ it("should close dialog with false when No button is clicked", () => {
+ const noButton = fixture.nativeElement.querySelector(
+ 'button[buttonType="secondary"]',
+ ) as HTMLButtonElement;
+
+ noButton.click();
+
+ expect(mockDialogRef.close).toHaveBeenCalledWith(false);
+ expect(noButton.textContent?.trim()).toBe("no-used-i18n");
+ });
+ });
+});
diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts
new file mode 100644
index 00000000000..a909baf1c77
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts
@@ -0,0 +1,18 @@
+import { Component } from "@angular/core";
+
+import { DialogRef, DialogService } from "@bitwarden/components";
+import { SharedModule } from "@bitwarden/web-vault/app/shared";
+
+@Component({
+ imports: [SharedModule],
+ templateUrl: "./session-timeout-confirmation-never.component.html",
+})
+export class SessionTimeoutConfirmationNeverComponent {
+ constructor(public dialogRef: DialogRef) {}
+
+ static open(dialogService: DialogService) {
+ return dialogService.open(SessionTimeoutConfirmationNeverComponent, {
+ disableClose: true,
+ });
+ }
+}
diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.html b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.html
new file mode 100644
index 00000000000..22e9e07bea7
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.html
@@ -0,0 +1,39 @@
+
+ {{ "requireSsoPolicyReq" | i18n }}
+
+
+
+
+ {{ "turnOn" | i18n }}
+
+
+
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
new file mode 100644
index 00000000000..694b0f1d1a2
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.spec.ts
@@ -0,0 +1,441 @@
+import { DialogCloseOptions } from "@angular/cdk/dialog";
+import { DebugElement } from "@angular/core";
+import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
+import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
+import { By } from "@angular/platform-browser";
+import { mock } from "jest-mock-extended";
+import { Observable, of } from "rxjs";
+
+import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
+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 { SessionTimeoutConfirmationNeverComponent } from "./session-timeout-confirmation-never.component";
+import {
+ SessionTimeoutAction,
+ SessionTimeoutPolicyComponent,
+ SessionTimeoutType,
+} from "./session-timeout.component";
+
+// Mock DialogRef, so we can mock "readonly closed" property.
+class MockDialogRef extends DialogRef {
+ close(result: unknown | undefined, options: DialogCloseOptions | undefined): void {}
+
+ closed: Observable = of();
+ componentInstance: unknown | null;
+ disableClose: boolean | undefined;
+ isDrawer: boolean = false;
+}
+
+describe("SessionTimeoutPolicyComponent", () => {
+ let component: SessionTimeoutPolicyComponent;
+ let fixture: ComponentFixture;
+
+ const mockI18nService = mock();
+ const mockDialogService = mock();
+ const mockDialogRef = mock();
+
+ beforeEach(async () => {
+ jest.resetAllMocks();
+
+ mockDialogRef.closed = of(true);
+ mockDialogService.open.mockReturnValue(mockDialogRef);
+ mockDialogService.openSimpleDialog.mockResolvedValue(true);
+
+ mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
+
+ const testBed = TestBed.configureTestingModule({
+ imports: [SessionTimeoutPolicyComponent, ReactiveFormsModule],
+ providers: [FormBuilder, { provide: I18nService, useValue: mockI18nService }],
+ });
+
+ // Override DialogService provided from SharedModule (which includes DialogModule)
+ testBed.overrideProvider(DialogService, { useValue: mockDialogService });
+
+ await testBed.compileComponents();
+
+ fixture = TestBed.createComponent(SessionTimeoutPolicyComponent);
+ component = fixture.componentInstance;
+ });
+
+ function assertHoursAndMinutesInputsNotVisible() {
+ const hoursInput = fixture.nativeElement.querySelector('input[formControlName="hours"]');
+ const minutesInput = fixture.nativeElement.querySelector('input[formControlName="minutes"]');
+
+ expect(hoursInput).toBeFalsy();
+ expect(minutesInput).toBeFalsy();
+ }
+
+ function assertHoursAndMinutesInputs(expectedHours: string, expectedMinutes: string) {
+ const hoursInput = fixture.nativeElement.querySelector('input[formControlName="hours"]');
+ const minutesInput = fixture.nativeElement.querySelector('input[formControlName="minutes"]');
+
+ expect(hoursInput).toBeTruthy();
+ expect(minutesInput).toBeTruthy();
+ expect(hoursInput.disabled).toBe(false);
+ expect(minutesInput.disabled).toBe(false);
+ expect(hoursInput.value).toBe(expectedHours);
+ expect(minutesInput.value).toBe(expectedMinutes);
+ }
+
+ function setPolicyResponseType(type: SessionTimeoutType) {
+ component.policyResponse = new PolicyResponse({
+ Data: {
+ type,
+ minutes: 480,
+ action: null,
+ },
+ });
+ }
+
+ describe("initialization and data loading", () => {
+ function assertTypeAndActionSelectElementsVisible() {
+ // Type and action selects should always be present
+ const typeSelectDebug: DebugElement = fixture.debugElement.query(
+ By.css('bit-select[formControlName="type"]'),
+ );
+ const actionSelectDebug: DebugElement = fixture.debugElement.query(
+ By.css('bit-select[formControlName="action"]'),
+ );
+
+ expect(typeSelectDebug).toBeTruthy();
+ expect(actionSelectDebug).toBeTruthy();
+ }
+
+ it("should initialize with default state when policy have no value", () => {
+ component.policyResponse = undefined;
+
+ fixture.detectChanges();
+
+ expect(component.data.controls.type.value).toBeNull();
+ expect(component.data.controls.type.hasError("required")).toBe(true);
+ expect(component.data.controls.hours.value).toBe(8);
+ expect(component.data.controls.hours.disabled).toBe(true);
+ expect(component.data.controls.minutes.value).toBe(0);
+ expect(component.data.controls.minutes.disabled).toBe(true);
+ expect(component.data.controls.action.value).toBeNull();
+
+ assertTypeAndActionSelectElementsVisible();
+ assertHoursAndMinutesInputsNotVisible();
+ });
+
+ // 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.detectChanges();
+
+ expect(component.data.controls.type.value).toBe("custom");
+ expect(component.data.controls.hours.value).toBe(8);
+ expect(component.data.controls.hours.disabled).toBe(false);
+ expect(component.data.controls.minutes.value).toBe(20);
+ expect(component.data.controls.minutes.disabled).toBe(false);
+ expect(component.data.controls.action.value).toBe(VaultTimeoutAction.Lock);
+
+ assertTypeAndActionSelectElementsVisible();
+ assertHoursAndMinutesInputs("8", "20");
+ });
+
+ it.each([
+ ["never", null],
+ ["never", VaultTimeoutAction.Lock],
+ ["never", VaultTimeoutAction.LogOut],
+ ["onAppRestart", null],
+ ["onAppRestart", VaultTimeoutAction.Lock],
+ ["onAppRestart", VaultTimeoutAction.LogOut],
+ ["onSystemLock", null],
+ ["onSystemLock", VaultTimeoutAction.Lock],
+ ["onSystemLock", VaultTimeoutAction.LogOut],
+ ["immediately", null],
+ ["immediately", VaultTimeoutAction.Lock],
+ ["immediately", VaultTimeoutAction.LogOut],
+ ["custom", null],
+ ["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.detectChanges();
+
+ expect(component.data.controls.type.value).toBe(type);
+ expect(component.data.controls.action.value).toBe(action);
+
+ assertTypeAndActionSelectElementsVisible();
+
+ if (type === "custom") {
+ expect(component.data.controls.hours.value).toBe(8);
+ expect(component.data.controls.minutes.value).toBe(30);
+ expect(component.data.controls.hours.disabled).toBe(false);
+ expect(component.data.controls.minutes.disabled).toBe(false);
+
+ assertHoursAndMinutesInputs("8", "30");
+ } else {
+ expect(component.data.controls.hours.disabled).toBe(true);
+ expect(component.data.controls.minutes.disabled).toBe(true);
+
+ assertHoursAndMinutesInputsNotVisible();
+ }
+ });
+
+ it("should have all type options and update form control when value changes", fakeAsync(() => {
+ expect(component.typeOptions.length).toBe(5);
+ expect(component.typeOptions[0].value).toBe("immediately");
+ expect(component.typeOptions[1].value).toBe("custom");
+ expect(component.typeOptions[2].value).toBe("onSystemLock");
+ expect(component.typeOptions[3].value).toBe("onAppRestart");
+ expect(component.typeOptions[4].value).toBe("never");
+ }));
+
+ it("should have all action options and update form control when value changes", () => {
+ expect(component.actionOptions.length).toBe(3);
+ expect(component.actionOptions[0].value).toBeNull();
+ expect(component.actionOptions[1].value).toBe(VaultTimeoutAction.Lock);
+ expect(component.actionOptions[2].value).toBe(VaultTimeoutAction.LogOut);
+ });
+ });
+
+ describe("form controls change detection", () => {
+ it.each(["never", "onAppRestart", "onSystemLock", "immediately"])(
+ "should disable hours and minutes inputs when type changes from custom to %s",
+ fakeAsync((newType: SessionTimeoutType) => {
+ setPolicyResponseType("custom");
+ fixture.detectChanges();
+
+ expect(component.data.controls.hours.value).toBe(8);
+ expect(component.data.controls.minutes.value).toBe(0);
+ expect(component.data.controls.hours.disabled).toBe(false);
+ expect(component.data.controls.minutes.disabled).toBe(false);
+
+ component.data.patchValue({ type: newType });
+ tick();
+ fixture.detectChanges();
+
+ expect(component.data.controls.hours.disabled).toBe(true);
+ expect(component.data.controls.minutes.disabled).toBe(true);
+
+ assertHoursAndMinutesInputsNotVisible();
+ }),
+ );
+
+ it.each(["never", "onAppRestart", "onSystemLock", "immediately"])(
+ "should enable hours and minutes inputs when type changes from %s to custom",
+ fakeAsync((oldType: SessionTimeoutType) => {
+ setPolicyResponseType(oldType);
+ fixture.detectChanges();
+
+ expect(component.data.controls.hours.disabled).toBe(true);
+ expect(component.data.controls.minutes.disabled).toBe(true);
+
+ component.data.patchValue({ type: "custom", hours: 8, minutes: 1 });
+ tick();
+ fixture.detectChanges();
+
+ expect(component.data.controls.hours.value).toBe(8);
+ expect(component.data.controls.minutes.value).toBe(1);
+ expect(component.data.controls.hours.disabled).toBe(false);
+ expect(component.data.controls.minutes.disabled).toBe(false);
+
+ assertHoursAndMinutesInputs("8", "1");
+ }),
+ );
+
+ it.each(["custom", "onAppRestart", "immediately"])(
+ "should not show confirmation dialog when changing to %s type",
+ fakeAsync((newType: SessionTimeoutType) => {
+ setPolicyResponseType(null);
+ fixture.detectChanges();
+
+ component.data.patchValue({ type: newType });
+ tick();
+ fixture.detectChanges();
+
+ expect(mockDialogService.open).not.toHaveBeenCalled();
+ expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
+ }),
+ );
+
+ it("should show never confirmation dialog when changing to never type", fakeAsync(() => {
+ setPolicyResponseType(null);
+ fixture.detectChanges();
+
+ component.data.patchValue({ type: "never" });
+ tick();
+ fixture.detectChanges();
+
+ expect(mockDialogService.open).toHaveBeenCalledWith(
+ SessionTimeoutConfirmationNeverComponent,
+ {
+ disableClose: true,
+ },
+ );
+ expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
+ }));
+
+ it("should show simple confirmation dialog when changing to onSystemLock type", fakeAsync(() => {
+ setPolicyResponseType(null);
+ fixture.detectChanges();
+
+ component.data.patchValue({ type: "onSystemLock" });
+ tick();
+ fixture.detectChanges();
+
+ expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
+ type: "info",
+ title: { key: "sessionTimeoutConfirmationOnSystemLockTitle" },
+ content: { key: "sessionTimeoutConfirmationOnSystemLockDescription" },
+ acceptButtonText: { key: "continue" },
+ cancelButtonText: { key: "cancel" },
+ });
+ expect(mockDialogService.open).not.toHaveBeenCalled();
+ expect(component.data.controls.type.value).toBe("onSystemLock");
+ }));
+
+ it("should revert to previous type when type changed to never and dialog not confirmed", fakeAsync(() => {
+ mockDialogRef.closed = of(false);
+ setPolicyResponseType("immediately");
+ fixture.detectChanges();
+
+ component.data.patchValue({ type: "never" });
+ tick();
+ fixture.detectChanges();
+
+ expect(mockDialogService.open).toHaveBeenCalled();
+ expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
+ expect(component.data.controls.type.value).toBe("immediately");
+ }));
+
+ it("should revert to previous type when type changed to onSystemLock and dialog not confirmed", fakeAsync(() => {
+ mockDialogService.openSimpleDialog.mockResolvedValue(false);
+ setPolicyResponseType("immediately");
+ fixture.detectChanges();
+
+ component.data.patchValue({ type: "onSystemLock" });
+ tick();
+ fixture.detectChanges();
+
+ expect(mockDialogService.openSimpleDialog).toHaveBeenCalled();
+ expect(mockDialogService.open).not.toHaveBeenCalled();
+ expect(component.data.controls.type.value).toBe("immediately");
+ }));
+
+ it("should revert to last confirmed type when canceling multiple times", fakeAsync(() => {
+ mockDialogRef.closed = of(false);
+ mockDialogService.openSimpleDialog.mockResolvedValue(false);
+
+ setPolicyResponseType("custom");
+ fixture.detectChanges();
+
+ // First attempt: custom -> never (cancel)
+ component.data.patchValue({ type: "never" });
+ tick();
+ fixture.detectChanges();
+
+ expect(component.data.controls.type.value).toBe("custom");
+
+ // Second attempt: custom -> onSystemLock (cancel)
+ component.data.patchValue({ type: "onSystemLock" });
+ tick();
+ fixture.detectChanges();
+
+ // Should revert to "custom", not "never"
+ expect(component.data.controls.type.value).toBe("custom");
+ }));
+ });
+
+ describe("buildRequestData", () => {
+ beforeEach(() => {
+ setPolicyResponseType("custom");
+ fixture.detectChanges();
+ });
+
+ it("should throw max allowed timeout required error when type is invalid", () => {
+ component.data.patchValue({ type: null });
+
+ expect(() => component["buildRequestData"]()).toThrow(
+ "maximumAllowedTimeoutRequired-used-i18n",
+ );
+ });
+
+ it.each([
+ [null, null],
+ [null, 0],
+ [0, null],
+ [0, 0],
+ ])(
+ "should throw invalid time error when type is custom, hours is %o and minutes is %o ",
+ (hours, minutes) => {
+ component.data.patchValue({
+ type: "custom",
+ hours: hours,
+ minutes: minutes,
+ });
+
+ expect(() => component["buildRequestData"]()).toThrow(
+ "sessionTimeoutPolicyInvalidTime-used-i18n",
+ );
+ },
+ );
+
+ it("should return correct data when type is custom with valid time", () => {
+ component.data.patchValue({
+ type: "custom",
+ hours: 8,
+ minutes: 30,
+ action: VaultTimeoutAction.Lock,
+ });
+
+ const result = component["buildRequestData"]();
+
+ expect(result).toEqual({
+ type: "custom",
+ minutes: 510,
+ action: VaultTimeoutAction.Lock,
+ });
+ });
+
+ it.each([
+ ["never", null],
+ ["never", VaultTimeoutAction.Lock],
+ ["never", VaultTimeoutAction.LogOut],
+ ["immediately", null],
+ ["immediately", VaultTimeoutAction.Lock],
+ ["immediately", VaultTimeoutAction.LogOut],
+ ["onSystemLock", null],
+ ["onSystemLock", VaultTimeoutAction.Lock],
+ ["onSystemLock", VaultTimeoutAction.LogOut],
+ ["onAppRestart", null],
+ ["onAppRestart", VaultTimeoutAction.Lock],
+ ["onAppRestart", VaultTimeoutAction.LogOut],
+ ])(
+ "should return default 8 hours for backward compatibility when type is %s and action is %s",
+ (type, action) => {
+ component.data.patchValue({
+ type: type as SessionTimeoutType,
+ hours: 5,
+ minutes: 25,
+ action: action as SessionTimeoutAction,
+ });
+
+ const result = component["buildRequestData"]();
+
+ expect(result).toEqual({
+ type,
+ minutes: 480,
+ action,
+ });
+ },
+ );
+ });
+});
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
new file mode 100644
index 00000000000..3e40b9f0d80
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts
@@ -0,0 +1,197 @@
+import { Component, OnDestroy, OnInit } from "@angular/core";
+import { FormBuilder, FormControl, Validators } from "@angular/forms";
+import {
+ BehaviorSubject,
+ concatMap,
+ firstValueFrom,
+ Subject,
+ takeUntil,
+ withLatestFrom,
+} from "rxjs";
+
+import { PolicyType } from "@bitwarden/common/admin-console/enums";
+import { VaultTimeoutAction } from "@bitwarden/common/key-management/vault-timeout";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { DialogService } from "@bitwarden/components";
+import {
+ BasePolicyEditDefinition,
+ BasePolicyEditComponent,
+} from "@bitwarden/web-vault/app/admin-console/organizations/policies";
+import { SharedModule } from "@bitwarden/web-vault/app/shared";
+
+import { SessionTimeoutConfirmationNeverComponent } from "./session-timeout-confirmation-never.component";
+
+export type SessionTimeoutAction = null | "lock" | "logOut";
+export type SessionTimeoutType =
+ | null
+ | "never"
+ | "onAppRestart"
+ | "onSystemLock"
+ | "immediately"
+ | "custom";
+
+export class SessionTimeoutPolicy extends BasePolicyEditDefinition {
+ name = "sessionTimeoutPolicyTitle";
+ description = "sessionTimeoutPolicyDescription";
+ type = PolicyType.MaximumVaultTimeout;
+ component = SessionTimeoutPolicyComponent;
+}
+
+const DEFAULT_HOURS = 8;
+const DEFAULT_MINUTES = 0;
+
+@Component({
+ templateUrl: "session-timeout.component.html",
+ imports: [SharedModule],
+})
+export class SessionTimeoutPolicyComponent
+ extends BasePolicyEditComponent
+ implements OnInit, OnDestroy
+{
+ private destroy$ = new Subject();
+ private lastConfirmedType$ = new BehaviorSubject(null);
+
+ actionOptions: { name: string; value: SessionTimeoutAction }[];
+ typeOptions: { name: string; value: SessionTimeoutType }[];
+ data = this.formBuilder.group({
+ type: new FormControl(null, [Validators.required]),
+ hours: new FormControl(
+ {
+ value: DEFAULT_HOURS,
+ disabled: true,
+ },
+ [Validators.required],
+ ),
+ minutes: new FormControl(
+ {
+ value: DEFAULT_MINUTES,
+ disabled: true,
+ },
+ [Validators.required],
+ ),
+ action: new FormControl(null),
+ });
+
+ constructor(
+ private formBuilder: FormBuilder,
+ private i18nService: I18nService,
+ private dialogService: DialogService,
+ ) {
+ super();
+ this.actionOptions = [
+ { name: i18nService.t("userPreference"), value: null },
+ { name: i18nService.t("lock"), value: VaultTimeoutAction.Lock },
+ { name: i18nService.t("logOut"), value: VaultTimeoutAction.LogOut },
+ ];
+ this.typeOptions = [
+ { name: i18nService.t("immediately"), value: "immediately" },
+ { name: i18nService.t("custom"), value: "custom" },
+ { name: i18nService.t("onSystemLock"), value: "onSystemLock" },
+ { name: i18nService.t("onAppRestart"), value: "onAppRestart" },
+ { name: i18nService.t("never"), value: "never" },
+ ];
+ }
+
+ ngOnInit() {
+ super.ngOnInit();
+
+ const typeControl = this.data.controls.type;
+ this.lastConfirmedType$.next(typeControl.value ?? null);
+
+ typeControl.valueChanges
+ .pipe(
+ withLatestFrom(this.lastConfirmedType$),
+ concatMap(async ([newType, lastConfirmedType]) => {
+ const confirmed = await this.confirmTypeChange(newType);
+ if (confirmed) {
+ this.updateFormControls(newType);
+ this.lastConfirmedType$.next(newType);
+ } else {
+ typeControl.setValue(lastConfirmedType, { emitEvent: false });
+ }
+ }),
+ takeUntil(this.destroy$),
+ )
+ .subscribe();
+ }
+
+ ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ protected override loadData() {
+ const minutes: number | null = this.policyResponse?.data?.minutes ?? null;
+ const action: SessionTimeoutAction =
+ this.policyResponse?.data?.action ?? (null satisfies SessionTimeoutAction);
+ // For backward compatibility, the "type" field might not exist, hence we initialize it based on the presence of "minutes"
+ const type: SessionTimeoutType =
+ this.policyResponse?.data?.type ?? ((minutes ? "custom" : null) satisfies SessionTimeoutType);
+
+ this.updateFormControls(type);
+ this.data.patchValue({
+ type: type,
+ hours: minutes ? Math.floor(minutes / 60) : DEFAULT_HOURS,
+ minutes: minutes ? minutes % 60 : DEFAULT_MINUTES,
+ action: action,
+ });
+ }
+
+ protected override buildRequestData() {
+ this.data.markAllAsTouched();
+ this.data.updateValueAndValidity();
+ if (this.data.invalid) {
+ if (this.data.controls.type.hasError("required")) {
+ throw new Error(this.i18nService.t("maximumAllowedTimeoutRequired"));
+ }
+ throw new Error(this.i18nService.t("sessionTimeoutPolicyInvalidTime"));
+ }
+
+ let minutes = this.data.value.hours! * 60 + this.data.value.minutes!;
+
+ const type = this.data.value.type;
+ if (type === "custom") {
+ if (minutes <= 0) {
+ throw new Error(this.i18nService.t("sessionTimeoutPolicyInvalidTime"));
+ }
+ } else {
+ // For backwards compatibility, we set minutes to 8 hours, so older client's vault timeout will not be broken
+ minutes = DEFAULT_HOURS * 60 + DEFAULT_MINUTES;
+ }
+
+ return {
+ type,
+ minutes,
+ action: this.data.value.action,
+ };
+ }
+
+ private async confirmTypeChange(newType: SessionTimeoutType): Promise {
+ if (newType === "never") {
+ const dialogRef = SessionTimeoutConfirmationNeverComponent.open(this.dialogService);
+ return !!(await firstValueFrom(dialogRef.closed));
+ } else if (newType === "onSystemLock") {
+ return await this.dialogService.openSimpleDialog({
+ type: "info",
+ title: { key: "sessionTimeoutConfirmationOnSystemLockTitle" },
+ content: { key: "sessionTimeoutConfirmationOnSystemLockDescription" },
+ acceptButtonText: { key: "continue" },
+ cancelButtonText: { key: "cancel" },
+ });
+ }
+
+ return true;
+ }
+
+ private updateFormControls(type: SessionTimeoutType) {
+ const hoursControl = this.data.controls.hours;
+ const minutesControl = this.data.controls.minutes;
+ if (type === "custom") {
+ hoursControl.enable();
+ minutesControl.enable();
+ } else {
+ hoursControl.disable();
+ minutesControl.disable();
+ }
+ }
+}
From 460d66d62499f0a5d336d9a651f049d9611cf024 Mon Sep 17 00:00:00 2001
From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com>
Date: Wed, 29 Oct 2025 07:41:35 -0500
Subject: [PATCH 002/510] Remove FF: `pm-17772-admin-initiated-sponsorships`
(#16873)
* Remove FF
* Fix test
---
.../free-families-policy.service.spec.ts | 25 -------------------
.../services/free-families-policy.service.ts | 13 ++--------
libs/common/src/enums/feature-flag.enum.ts | 2 --
3 files changed, 2 insertions(+), 38 deletions(-)
diff --git a/apps/web/src/app/billing/services/free-families-policy.service.spec.ts b/apps/web/src/app/billing/services/free-families-policy.service.spec.ts
index 10ccc448986..5b39a5a848a 100644
--- a/apps/web/src/app/billing/services/free-families-policy.service.spec.ts
+++ b/apps/web/src/app/billing/services/free-families-policy.service.spec.ts
@@ -38,7 +38,6 @@ describe("FreeFamiliesPolicyService", () => {
describe("showSponsoredFamiliesDropdown$", () => {
it("should return true when all conditions are met", async () => {
// Configure mocks
- configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization that meets all criteria
@@ -58,7 +57,6 @@ describe("FreeFamiliesPolicyService", () => {
it("should return false when organization is not Enterprise", async () => {
// Configure mocks
- configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization that is not Enterprise tier
@@ -74,27 +72,8 @@ describe("FreeFamiliesPolicyService", () => {
expect(result).toBe(false);
});
- it("should return false when feature flag is disabled", async () => {
- // Configure mocks to disable feature flag
- configService.getFeatureFlag$.mockReturnValue(of(false));
- policyService.policiesByType$.mockReturnValue(of([]));
-
- // Create a test organization that meets other criteria
- const organization = {
- id: "org-id",
- productTierType: ProductTierType.Enterprise,
- useAdminSponsoredFamilies: true,
- isAdmin: true,
- } as Organization;
-
- // Test the method
- const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization)));
- expect(result).toBe(false);
- });
-
it("should return false when families feature is disabled by policy", async () => {
// Configure mocks with a policy that disables the feature
- configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(
of([{ organizationId: "org-id", enabled: true } as Policy]),
);
@@ -114,7 +93,6 @@ describe("FreeFamiliesPolicyService", () => {
it("should return false when useAdminSponsoredFamilies is false", async () => {
// Configure mocks
- configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization with useAdminSponsoredFamilies set to false
@@ -132,7 +110,6 @@ describe("FreeFamiliesPolicyService", () => {
it("should return true when user is an owner but not admin", async () => {
// Configure mocks
- configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization where user is owner but not admin
@@ -152,7 +129,6 @@ describe("FreeFamiliesPolicyService", () => {
it("should return true when user can manage users but is not admin or owner", async () => {
// Configure mocks
- configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization where user can manage users but is not admin or owner
@@ -172,7 +148,6 @@ describe("FreeFamiliesPolicyService", () => {
it("should return false when user has no admin permissions", async () => {
// Configure mocks
- configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization where user has no admin permissions
diff --git a/apps/web/src/app/billing/services/free-families-policy.service.ts b/apps/web/src/app/billing/services/free-families-policy.service.ts
index 52041936e50..68e333d53ba 100644
--- a/apps/web/src/app/billing/services/free-families-policy.service.ts
+++ b/apps/web/src/app/billing/services/free-families-policy.service.ts
@@ -8,8 +8,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
-import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
-import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
interface EnterpriseOrgStatus {
isFreeFamilyPolicyEnabled: boolean;
@@ -23,7 +21,6 @@ export class FreeFamiliesPolicyService {
private policyService: PolicyService,
private organizationService: OrganizationService,
private accountService: AccountService,
- private configService: ConfigService,
) {}
organizations$ = this.accountService.activeAccount$.pipe(
@@ -58,20 +55,14 @@ export class FreeFamiliesPolicyService {
userId,
);
- return combineLatest([
- enterpriseOrganization$,
- this.configService.getFeatureFlag$(FeatureFlag.PM17772_AdminInitiatedSponsorships),
- organization,
- policies$,
- ]).pipe(
- map(([isEnterprise, featureFlagEnabled, org, policies]) => {
+ return combineLatest([enterpriseOrganization$, organization, policies$]).pipe(
+ map(([isEnterprise, org, policies]) => {
const familiesFeatureDisabled = policies.some(
(policy) => policy.organizationId === org.id && policy.enabled,
);
return (
isEnterprise &&
- featureFlagEnabled &&
!familiesFeatureDisabled &&
org.useAdminSponsoredFamilies &&
(org.isAdmin || org.isOwner || org.canManageUsers)
diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts
index d9cd1dbfab3..085731b034e 100644
--- a/libs/common/src/enums/feature-flag.enum.ts
+++ b/libs/common/src/enums/feature-flag.enum.ts
@@ -23,7 +23,6 @@ export enum FeatureFlag {
/* Billing */
TrialPaymentOptional = "PM-8163-trial-payment",
- PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button",
@@ -109,7 +108,6 @@ export const DefaultFeatureFlagValue = {
/* Billing */
[FeatureFlag.TrialPaymentOptional]: FALSE,
- [FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
[FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE,
From 5b815c4ae4b997151b7f2cf247d1110925dce14f Mon Sep 17 00:00:00 2001
From: Bryan Cunningham
Date: Wed, 29 Oct 2025 09:49:16 -0400
Subject: [PATCH 003/510] [CL-879] use tooltip on icon button (#16576)
* Add tooltip to icon button to display label
* remove legacy cdr variable
* create overlay on focus or hover
* attach describdedby ids
* fix type errors
* remove aria-describedby when not necessary
* fix failing tests
* implement Claude feedback
* fixing broken specs
* remove host attr binding
* Simplify directive aria logic
* Move id to statis number
* do not render empty tooltip
* pass id to tooltip component
* remove pointer-events none to allow tooltip on normal buttons
* exclude some tooltip stories
* change describedby input name
* add story with tooltip on regular button
* enhanced tooltip docs
* set model directly
* change model to input
---
.../components/src/button/button.component.ts | 1 -
.../src/icon-button/icon-button.component.ts | 19 ++++--
.../src/tooltip/tooltip.component.html | 18 +++---
.../src/tooltip/tooltip.component.ts | 1 +
.../src/tooltip/tooltip.directive.ts | 64 +++++++++++++------
libs/components/src/tooltip/tooltip.mdx | 19 +++++-
libs/components/src/tooltip/tooltip.spec.ts | 9 ++-
.../components/src/tooltip/tooltip.stories.ts | 40 ++++++++++--
.../delete-attachment.component.spec.ts | 2 +-
.../uri-option.component.spec.ts | 10 ++-
.../download-attachment.component.spec.ts | 2 +-
11 files changed, 137 insertions(+), 48 deletions(-)
diff --git a/libs/components/src/button/button.component.ts b/libs/components/src/button/button.component.ts
index 350d493f832..6ef5309b018 100644
--- a/libs/components/src/button/button.component.ts
+++ b/libs/components/src/button/button.component.ts
@@ -92,7 +92,6 @@ export class ButtonComponent implements ButtonLikeAbstraction {
"hover:!tw-text-muted",
"aria-disabled:tw-cursor-not-allowed",
"hover:tw-no-underline",
- "aria-disabled:tw-pointer-events-none",
]
: [],
)
diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts
index f1edee7c089..9887c0bde8b 100644
--- a/libs/components/src/icon-button/icon-button.component.ts
+++ b/libs/components/src/icon-button/icon-button.component.ts
@@ -17,6 +17,7 @@ import { setA11yTitleAndAriaLabel } from "../a11y/set-a11y-title-and-aria-label"
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
import { FocusableElement } from "../shared/focusable-element";
import { SpinnerComponent } from "../spinner";
+import { TooltipDirective } from "../tooltip";
import { ariaDisableElement } from "../utils";
export type IconButtonType = "primary" | "danger" | "contrast" | "main" | "muted" | "nav-contrast";
@@ -100,7 +101,10 @@ const sizes: Record = {
*/
"[attr.bitIconButton]": "icon()",
},
- hostDirectives: [AriaDisableDirective],
+ hostDirectives: [
+ AriaDisableDirective,
+ { directive: TooltipDirective, inputs: ["tooltipPosition"] },
+ ],
})
export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement {
readonly icon = model.required({ alias: "bitIconButton" });
@@ -109,6 +113,9 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
readonly size = model("default");
+ private elementRef = inject(ElementRef);
+ private tooltip = inject(TooltipDirective, { host: true, optional: true });
+
/**
* label input will be used to set the `aria-label` attributes on the button.
* This is for accessibility purposes, as it provides a text alternative for the icon button.
@@ -186,8 +193,6 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
return this.elementRef.nativeElement;
}
- private elementRef = inject(ElementRef);
-
constructor() {
const element = this.elementRef.nativeElement;
@@ -198,9 +203,15 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
effect(() => {
setA11yTitleAndAriaLabel({
element: this.elementRef.nativeElement,
- title: originalTitle ?? this.label(),
+ title: undefined,
label: this.label(),
});
+
+ const tooltipContent: string = originalTitle || this.label();
+
+ if (tooltipContent) {
+ this.tooltip?.tooltipContent.set(tooltipContent);
+ }
});
}
}
diff --git a/libs/components/src/tooltip/tooltip.component.html b/libs/components/src/tooltip/tooltip.component.html
index 4d354fc2765..ce9f1ceeffe 100644
--- a/libs/components/src/tooltip/tooltip.component.html
+++ b/libs/components/src/tooltip/tooltip.component.html
@@ -1,9 +1,11 @@
-